Coverage for mcpgateway / main.py: 99%
3058 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 03:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 03:05 +0000
1# -*- coding: utf-8 -*-
2# pylint: disable=wrong-import-position, import-outside-toplevel, no-name-in-module
3"""Location: ./mcpgateway/main.py
4Copyright 2025
5SPDX-License-Identifier: Apache-2.0
6Authors: Mihai Criveti
8ContextForge AI Gateway - Main FastAPI Application.
10This module defines the core FastAPI application for the Model Context Protocol (MCP) Gateway.
11It serves as the entry point for handling all HTTP and WebSocket traffic.
13Features and Responsibilities:
14- Initializes and orchestrates services for tools, resources, prompts, servers, gateways, and roots.
15- Supports full MCP protocol operations: initialize, ping, notify, complete, and sample.
16- Integrates authentication (JWT and basic), CORS, caching, and middleware.
17- Serves a rich Admin UI for managing gateway entities via HTMX-based frontend.
18- Exposes routes for JSON-RPC, SSE, and WebSocket transports.
19- Manages application lifecycle including startup and graceful shutdown of all services.
21Structure:
22- Declares routers for MCP protocol operations and administration.
23- Registers dependencies (e.g., DB sessions, auth handlers).
24- Applies middleware including custom documentation protection.
25- Configures resource caching and session registry using pluggable backends.
26- Provides OpenAPI metadata and redirect handling depending on UI feature flags.
27"""
29# Standard
30import asyncio
31from contextlib import asynccontextmanager, suppress
32from datetime import datetime, timezone
33from functools import lru_cache
34import hashlib
35import html
36import sys
37from typing import Any, AsyncIterator, Dict, List, Optional, Union
38from urllib.parse import urlparse, urlunparse
39import uuid
40import warnings
42# Third-Party
43from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Query, Request, status, WebSocket, WebSocketDisconnect
44from fastapi.background import BackgroundTasks
45from fastapi.exception_handlers import request_validation_exception_handler as fastapi_default_validation_handler
46from fastapi.exceptions import RequestValidationError
47from fastapi.middleware.cors import CORSMiddleware
48from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse
49from fastapi.security import HTTPAuthorizationCredentials
50from fastapi.staticfiles import StaticFiles
51from fastapi.templating import Jinja2Templates
52from jinja2 import Environment, FileSystemLoader
53from jsonpath_ng.ext import parse
54from jsonpath_ng.jsonpath import JSONPath
55import orjson
56from pydantic import ValidationError
57from sqlalchemy import text
58from sqlalchemy.exc import IntegrityError
59from sqlalchemy.orm import Session
60from starlette.middleware.base import BaseHTTPMiddleware
61from starlette.requests import Request as starletteRequest
62from starlette.responses import Response as starletteResponse
63from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
65# First-Party
66from mcpgateway import __version__
67from mcpgateway.admin import admin_router, set_logging_service
68from mcpgateway.auth import _check_token_revoked_sync, _lookup_api_token_sync, _resolve_teams_from_db, get_current_user, get_user_team_roles, normalize_token_teams
69from mcpgateway.bootstrap_db import main as bootstrap_db
70from mcpgateway.cache import ResourceCache, SessionRegistry
71from mcpgateway.common.models import InitializeResult
72from mcpgateway.common.models import JSONRPCError as PydanticJSONRPCError
73from mcpgateway.common.models import ListResourceTemplatesResult, LogLevel, Root
74from mcpgateway.config import settings
75from mcpgateway.db import refresh_slugs_on_startup, SessionLocal
76from mcpgateway.db import Tool as DbTool
77from mcpgateway.handlers.sampling import SamplingHandler
78from mcpgateway.middleware.compression import SSEAwareCompressMiddleware
79from mcpgateway.middleware.correlation_id import CorrelationIDMiddleware
80from mcpgateway.middleware.http_auth_middleware import HttpAuthMiddleware
81from mcpgateway.middleware.protocol_version import MCPProtocolVersionMiddleware
82from mcpgateway.middleware.rbac import _ACCESS_DENIED_MSG, get_current_user_with_permissions, PermissionChecker, require_permission
83from mcpgateway.middleware.request_logging_middleware import RequestLoggingMiddleware
84from mcpgateway.middleware.security_headers import SecurityHeadersMiddleware
85from mcpgateway.middleware.token_scoping import token_scoping_middleware
86from mcpgateway.middleware.validation_middleware import ValidationMiddleware
87from mcpgateway.observability import init_telemetry
88from mcpgateway.plugins.framework import PluginError, PluginManager, PluginViolationError
89from mcpgateway.routers.server_well_known import router as server_well_known_router
90from mcpgateway.routers.well_known import router as well_known_router
91from mcpgateway.schemas import (
92 A2AAgentCreate,
93 A2AAgentRead,
94 A2AAgentUpdate,
95 CursorPaginatedA2AAgentsResponse,
96 CursorPaginatedGatewaysResponse,
97 CursorPaginatedPromptsResponse,
98 CursorPaginatedResourcesResponse,
99 CursorPaginatedServersResponse,
100 CursorPaginatedToolsResponse,
101 GatewayCreate,
102 GatewayRead,
103 GatewayRefreshResponse,
104 GatewayUpdate,
105 JsonPathModifier,
106 MetricsResponse,
107 PromptCreate,
108 PromptExecuteArgs,
109 PromptRead,
110 PromptUpdate,
111 ResourceCreate,
112 ResourceRead,
113 ResourceSubscription,
114 ResourceUpdate,
115 RPCRequest,
116 ServerCreate,
117 ServerRead,
118 ServerUpdate,
119 TaggedEntity,
120 TagInfo,
121 ToolCreate,
122 ToolRead,
123 ToolUpdate,
124)
125from mcpgateway.services.a2a_service import A2AAgentError, A2AAgentNameConflictError, A2AAgentNotFoundError, A2AAgentService
126from mcpgateway.services.cancellation_service import cancellation_service
127from mcpgateway.services.completion_service import CompletionService
128from mcpgateway.services.email_auth_service import EmailAuthService
129from mcpgateway.services.export_service import ExportError, ExportService
130from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayDuplicateConflictError, GatewayError, GatewayNameConflictError, GatewayNotFoundError
131from mcpgateway.services.import_service import ConflictStrategy, ImportConflictError
132from mcpgateway.services.import_service import ImportError as ImportServiceError
133from mcpgateway.services.import_service import ImportService, ImportValidationError
134from mcpgateway.services.log_aggregator import get_log_aggregator
135from mcpgateway.services.logging_service import LoggingService
136from mcpgateway.services.metrics import setup_metrics
137from mcpgateway.services.permission_service import PermissionService
138from mcpgateway.services.prompt_service import PromptError, PromptLockConflictError, PromptNameConflictError, PromptNotFoundError
139from mcpgateway.services.resource_service import ResourceError, ResourceLockConflictError, ResourceNotFoundError, ResourceURIConflictError
140from mcpgateway.services.server_service import ServerError, ServerLockConflictError, ServerNameConflictError, ServerNotFoundError
141from mcpgateway.services.tag_service import TagService
142from mcpgateway.services.tool_service import ToolError, ToolLockConflictError, ToolNameConflictError, ToolNotFoundError
143from mcpgateway.transports.sse_transport import SSETransport
144from mcpgateway.transports.streamablehttp_transport import SessionManagerWrapper, set_shared_session_registry, streamable_http_auth
145from mcpgateway.utils.db_isready import wait_for_db_ready
146from mcpgateway.utils.error_formatter import ErrorFormatter
147from mcpgateway.utils.metadata_capture import MetadataCapture
148from mcpgateway.utils.orjson_response import ORJSONResponse
149from mcpgateway.utils.passthrough_headers import set_global_passthrough_headers
150from mcpgateway.utils.redis_client import close_redis_client, get_redis_client
151from mcpgateway.utils.redis_isready import wait_for_redis_ready
152from mcpgateway.utils.retry_manager import ResilientHttpClient
153from mcpgateway.utils.token_scoping import validate_server_access
154from mcpgateway.utils.verify_credentials import extract_websocket_bearer_token, is_proxy_auth_trust_active, require_admin_auth, require_docs_auth_override, verify_jwt_token
155from mcpgateway.validation.jsonrpc import JSONRPCError
157# Import the admin routes from the new module
158from mcpgateway.version import router as version_router
160# Initialize logging service first
161logging_service = LoggingService()
162logger = logging_service.get_logger("mcpgateway")
164# Share the logging service with admin module
165set_logging_service(logging_service)
167# Note: Logging configuration is handled by LoggingService during startup
168# Don't use basicConfig here as it conflicts with our dual logging setup
170# Wait for database to be ready before creating tables
171wait_for_db_ready(max_tries=int(settings.db_max_retries), interval=int(settings.db_retry_interval_ms) / 1000, sync=True) # Converting ms to s
173# Create database tables
174try:
175 loop = asyncio.get_running_loop()
176except RuntimeError:
177 asyncio.run(bootstrap_db())
178else:
179 loop.create_task(bootstrap_db())
181# Initialize plugin manager as a singleton.
182_PLUGINS_ENABLED = settings.plugins.enabled
183if _PLUGINS_ENABLED:
184 _plugin_settings = settings.plugins
185 # First-Party
186 from mcpgateway.plugins.policy import HOOK_PAYLOAD_POLICIES # noqa: E402
188 plugin_manager: PluginManager | None = PluginManager(_plugin_settings.config_file, timeout=_plugin_settings.plugin_timeout, hook_policies=HOOK_PAYLOAD_POLICIES)
189else:
190 plugin_manager = None # pylint: disable=invalid-name
193# First-Party
194# First-Party - import module-level service singletons
195from mcpgateway.services.gateway_service import gateway_service # noqa: E402
196from mcpgateway.services.prompt_service import prompt_service # noqa: E402
197from mcpgateway.services.resource_service import resource_service # noqa: E402
198from mcpgateway.services.root_service import root_service, RootServiceNotFoundError # noqa: E402
199from mcpgateway.services.server_service import server_service # noqa: E402
200from mcpgateway.services.tool_service import tool_service # noqa: E402
202# Services that do not expose module-level singletons are instantiated here
203completion_service = CompletionService()
204sampling_handler = SamplingHandler()
205tag_service = TagService()
206export_service = ExportService()
207import_service = ImportService()
208# Initialize A2A service only if A2A features are enabled
209a2a_service = A2AAgentService() if settings.mcpgateway_a2a_enabled else None
211# Initialize session manager for Streamable HTTP transport
212streamable_http_session = SessionManagerWrapper()
214# Wait for redis to be ready
215if settings.cache_type == "redis" and settings.redis_url is not None:
216 wait_for_redis_ready(redis_url=settings.redis_url, max_retries=int(settings.redis_max_retries), retry_interval_ms=int(settings.redis_retry_interval_ms), sync=True)
218# Initialize session registry
219session_registry = SessionRegistry(
220 backend=settings.cache_type,
221 redis_url=settings.redis_url if settings.cache_type == "redis" else None,
222 database_url=settings.database_url if settings.cache_type == "database" else None,
223 session_ttl=settings.session_ttl,
224 message_ttl=settings.message_ttl,
225)
226set_shared_session_registry(session_registry)
229# Helper function for authentication compatibility
230def get_user_email(user):
231 """Extract email from user object, handling both string and dict formats.
233 Args:
234 user: User object, can be either a dict (new RBAC format) or string (legacy format)
236 Returns:
237 str: User email address or 'unknown' if not available
239 Examples:
240 Test with dictionary user containing email:
241 >>> from mcpgateway import main
242 >>> user_dict = {'email': 'alice@example.com', 'role': 'admin'}
243 >>> main.get_user_email(user_dict)
244 'alice@example.com'
246 Test with dictionary user containing sub (JWT standard claim):
247 >>> user_dict_sub = {'sub': 'bob@example.com', 'role': 'user'}
248 >>> main.get_user_email(user_dict_sub)
249 'bob@example.com'
251 Test with dictionary user containing both email and sub (email takes precedence):
252 >>> user_dict_both = {'email': 'alice@example.com', 'sub': 'bob@example.com'}
253 >>> main.get_user_email(user_dict_both)
254 'alice@example.com'
256 Test with dictionary user without email or sub:
257 >>> user_dict_no_email = {'username': 'charlie', 'role': 'user'}
258 >>> main.get_user_email(user_dict_no_email)
259 'unknown'
261 Test with string user (legacy format):
262 >>> user_string = 'charlie@company.com'
263 >>> main.get_user_email(user_string)
264 'charlie@company.com'
266 Test with None user:
267 >>> main.get_user_email(None)
268 'unknown'
270 Test with empty dictionary:
271 >>> main.get_user_email({})
272 'unknown'
274 Test with integer (non-string, non-dict):
275 >>> main.get_user_email(123)
276 '123'
278 Test with user object having various data types:
279 >>> user_complex = {'email': 'david@test.org', 'id': 456, 'active': True}
280 >>> main.get_user_email(user_complex)
281 'david@test.org'
283 Test with empty string user:
284 >>> main.get_user_email('')
285 'unknown'
287 Test with boolean user:
288 >>> main.get_user_email(True)
289 'True'
290 >>> main.get_user_email(False)
291 'unknown'
292 """
293 if isinstance(user, dict):
294 # First try 'email', then 'sub' (JWT standard claim)
295 return user.get("email") or user.get("sub") or "unknown"
296 return str(user) if user else "unknown"
299def _normalize_token_teams(teams: Optional[List]) -> List[str]:
300 """
301 Normalize token teams to list of team IDs.
303 SSO tokens may contain team dicts like {"id": "...", "name": "..."}.
304 This normalizes to just IDs for consistent filtering.
306 Args:
307 teams: Raw teams from token payload (may be None, list of IDs, or list of dicts)
309 Returns:
310 List of team ID strings (empty list if None)
312 Examples:
313 >>> from mcpgateway import main
314 >>> main._normalize_token_teams(None)
315 []
316 >>> main._normalize_token_teams([])
317 []
318 >>> main._normalize_token_teams(["team_a", "team_b"])
319 ['team_a', 'team_b']
320 >>> main._normalize_token_teams([{"id": "team_a", "name": "Team A"}])
321 ['team_a']
322 >>> main._normalize_token_teams([{"id": "t1"}, "t2", {"name": "no_id"}])
323 ['t1', 't2']
324 """
325 if not teams:
326 return []
328 normalized = []
329 for team in teams:
330 if isinstance(team, dict):
331 team_id = team.get("id")
332 if team_id:
333 normalized.append(team_id)
334 elif isinstance(team, str):
335 normalized.append(team)
336 return normalized
339def _get_token_teams_from_request(request: Request) -> Optional[List[str]]:
340 """
341 Extract and normalize teams from verified JWT token.
343 SECURITY: Uses normalize_token_teams for consistent secure-first semantics:
344 - teams key missing → [] (public-only, secure default)
345 - teams key null + is_admin=true → None (admin bypass)
346 - teams key null + is_admin=false → [] (public-only)
347 - teams key [] → [] (explicit public-only)
348 - teams key [...] → normalized list of string IDs
350 First checks request.state.token_teams (set by auth.py), then falls back
351 to calling normalize_token_teams on the JWT payload.
353 Args:
354 request: FastAPI request object
356 Returns:
357 None for admin bypass, [] for public-only, or list of normalized team ID strings.
359 Examples:
360 >>> from mcpgateway import main
361 >>> from unittest.mock import MagicMock
362 >>> req = MagicMock()
363 >>> req.state = MagicMock()
364 >>> req.state.token_teams = ["team_a"] # Already normalized by auth.py
365 >>> main._get_token_teams_from_request(req)
366 ['team_a']
367 >>> req.state.token_teams = [] # Public-only
368 >>> main._get_token_teams_from_request(req)
369 []
370 """
371 # SECURITY: First check request.state.token_teams (already normalized by auth.py)
372 # This is the preferred path as auth.py has already applied normalize_token_teams
373 # Use getattr with a sentinel to distinguish "not set" from "set to None"
374 _not_set = object()
375 token_teams = getattr(request.state, "token_teams", _not_set)
376 if token_teams is not _not_set and (token_teams is None or isinstance(token_teams, list)):
377 return token_teams
379 # Fallback: Use cached verified payload and call normalize_token_teams
380 cached = getattr(request.state, "_jwt_verified_payload", None)
381 if cached and isinstance(cached, tuple) and len(cached) == 2:
382 _, payload = cached
383 if payload:
384 # Use normalize_token_teams for consistent secure-first semantics
385 return normalize_token_teams(payload)
387 # No JWT payload - return [] for public-only (secure default)
388 return []
391def _get_rpc_filter_context(request: Request, user) -> tuple:
392 """
393 Extract user_email, token_teams, and is_admin for RPC filtering.
395 Args:
396 request: FastAPI request object
397 user: User object from auth dependency
399 Returns:
400 Tuple of (user_email, token_teams, is_admin)
402 Examples:
403 >>> from mcpgateway import main
404 >>> from unittest.mock import MagicMock
405 >>> req = MagicMock()
406 >>> req.state = MagicMock()
407 >>> req.state._jwt_verified_payload = ("token", {"teams": ["t1"], "is_admin": True})
408 >>> user = {"email": "test@x.com", "is_admin": True} # User's is_admin is ignored
409 >>> email, teams, is_admin = main._get_rpc_filter_context(req, user)
410 >>> email
411 'test@x.com'
412 >>> teams
413 ['t1']
414 >>> is_admin # From token payload, not user dict
415 True
416 """
417 # Get user email
418 if hasattr(user, "email"):
419 user_email = getattr(user, "email", None)
420 elif isinstance(user, dict):
421 user_email = user.get("sub") or user.get("email")
422 else:
423 user_email = str(user) if user else None
425 # Get normalized teams from verified token
426 token_teams = _get_token_teams_from_request(request)
428 # Check if user is admin - MUST come from token, not DB user
429 # This ensures that tokens with restricted scope (empty teams) don't inherit admin bypass
430 is_admin = False
431 cached = getattr(request.state, "_jwt_verified_payload", None)
432 if cached and isinstance(cached, tuple) and len(cached) == 2:
433 _, payload = cached
434 if payload:
435 # Check both top-level is_admin and nested user.is_admin in token
436 is_admin = payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False)
438 # If token has empty teams array (public-only token), admin bypass is disabled
439 # This allows admins to create properly scoped tokens for restricted access
440 if token_teams is not None and len(token_teams) == 0:
441 is_admin = False
443 return user_email, token_teams, is_admin
446def _has_verified_jwt_payload(request: Request) -> bool:
447 """Return whether request has a verified JWT payload cached in request state.
449 Args:
450 request: Incoming request context.
452 Returns:
453 ``True`` when a verified payload tuple is present, otherwise ``False``.
454 """
455 cached = getattr(request.state, "_jwt_verified_payload", None)
456 return bool(cached and isinstance(cached, tuple) and len(cached) == 2 and cached[1])
459def _get_request_identity(request: Request, user) -> tuple[str, bool]:
460 """Return requester email and admin state honoring scoped-token semantics.
462 Args:
463 request: Incoming request context.
464 user: Authenticated user context from dependency resolution.
466 Returns:
467 Tuple of ``(requester_email, requester_is_admin)``.
468 """
469 user_email, _token_teams, token_is_admin = _get_rpc_filter_context(request, user)
470 resolved_email = user_email or get_user_email(user)
472 # If a JWT payload exists, respect token-derived admin semantics (including
473 # public-only admin tokens where bypass is intentionally disabled).
474 if _has_verified_jwt_payload(request):
475 return resolved_email, token_is_admin
477 fallback_is_admin = False
478 if hasattr(user, "is_admin"):
479 fallback_is_admin = bool(getattr(user, "is_admin", False))
480 elif isinstance(user, dict):
481 fallback_is_admin = bool(user.get("is_admin", False) or user.get("user", {}).get("is_admin", False))
483 return resolved_email, token_is_admin or fallback_is_admin
486def _get_scoped_resource_access_context(request: Request, user) -> tuple[Optional[str], Optional[List[str]]]:
487 """Resolve scoped resource access context for the current requester.
489 Args:
490 request: Incoming request context.
491 user: Authenticated user context from dependency resolution.
493 Returns:
494 Tuple of ``(user_email, token_teams)`` where ``(None, None)`` represents
495 unrestricted admin access and ``[]`` represents public-only scope.
496 """
497 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
499 # Non-JWT admin contexts (for example basic-auth development mode) should
500 # keep unrestricted access semantics.
501 if not _has_verified_jwt_payload(request):
502 _requester_email, fallback_admin = _get_request_identity(request, user)
503 if fallback_admin:
504 return None, None
506 if is_admin and token_teams is None:
507 return None, None
508 if token_teams is None:
509 return user_email, []
510 return user_email, token_teams
513def _build_rpc_permission_user(user, db: Session) -> dict[str, Any]:
514 """Build PermissionChecker user payload for method-level RPC checks.
516 Args:
517 user: Authenticated user context.
518 db: Active database session.
520 Returns:
521 Permission checker payload with email and ``db`` keys.
522 """
523 permission_user = dict(user) if isinstance(user, dict) else {"email": get_user_email(user)}
524 if not permission_user.get("email"):
525 permission_user["email"] = get_user_email(user)
526 permission_user["db"] = db
527 return permission_user
530def _extract_scoped_permissions(request: Request) -> set[str] | None:
531 """Extract token scopes.permissions from cached JWT payload.
533 Args:
534 request: Incoming request context.
536 Returns:
537 None: no explicit scope cap (empty permissions or no JWT — defer to RBAC)
538 set: explicit permission set (may contain '*' for wildcard)
539 """
540 cached = getattr(request.state, "_jwt_verified_payload", None)
541 if not cached or not isinstance(cached, tuple) or len(cached) != 2:
542 return None
543 _, payload = cached
544 if not payload or not isinstance(payload, dict):
545 return None
546 scopes = payload.get("scopes")
547 if not scopes or not isinstance(scopes, dict):
548 return None
549 permissions = scopes.get("permissions")
550 if not permissions: # Empty list or None = defer to RBAC
551 return None
552 return set(permissions)
555async def _ensure_rpc_permission(user, db: Session, permission: str, method: str, request: Request | None = None) -> None:
556 """Require a specific RPC permission for a method branch.
558 Enforces both layers:
559 1. Token scopes.permissions cap (if explicit permissions present)
560 2. RBAC role-based permission check
562 Args:
563 user: Authenticated user context.
564 db: Active database session.
565 permission: Permission required for the method.
566 method: JSON-RPC method name being authorized.
567 request: Optional FastAPI request for extracting token scopes.
569 Raises:
570 JSONRPCError: If the requester lacks the required permission.
571 """
572 # Layer 1: Token scope cap
573 if request is not None:
574 scoped = _extract_scoped_permissions(request)
575 if scoped is not None and "*" not in scoped and permission not in scoped:
576 logger.warning("RPC permission denied (token scope): method=%s, required=%s", method, permission)
577 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method})
579 # Layer 2: RBAC check
580 # Session tokens have no explicit team_id, so check across all team-scoped roles.
581 # Mirrors the @require_permission decorator's check_any_team fallback (rbac.py:562-576).
582 check_any_team = isinstance(user, dict) and user.get("token_use") == "session"
583 checker = PermissionChecker(_build_rpc_permission_user(user, db))
584 if not await checker.has_permission(permission, check_any_team=check_any_team):
585 logger.warning("RPC permission denied (RBAC): method=%s, required=%s", method, permission)
586 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method})
589def _enforce_scoped_resource_access(request: Request, db: Session, user, resource_path: str) -> None:
590 """Apply token-scope ownership checks for a concrete resource path.
592 This provides defense-in-depth for ID-based handlers so they continue to
593 enforce visibility even if middleware coverage regresses.
595 Args:
596 request: Incoming request context.
597 db: Active database session.
598 user: Authenticated user context.
599 resource_path: Canonical resource path (e.g. ``/tools/{id}``).
601 Raises:
602 HTTPException: If access to the target resource is not allowed.
603 """
604 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user)
606 # Admin bypass / unrestricted scope
607 if scoped_token_teams is None:
608 return
610 if not token_scoping_middleware._check_resource_team_ownership( # pylint: disable=protected-access
611 resource_path,
612 scoped_token_teams,
613 db=db,
614 _user_email=scoped_user_email,
615 ):
616 logger.warning("Scoped resource access denied: user=%s, resource=%s", scoped_user_email, resource_path)
617 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_ACCESS_DENIED_MSG)
620async def _assert_session_owner_or_admin(request: Request, user, session_id: str) -> None:
621 """Ensure session operations are limited to the owner unless requester is admin.
623 Args:
624 request: Incoming request context.
625 user: Authenticated user context.
626 session_id: Target session identifier.
628 Raises:
629 HTTPException: If session is missing or requester is not authorized.
630 """
631 session_owner = await session_registry.get_session_owner(session_id)
632 if not session_owner:
633 session_exists = await session_registry.session_exists(session_id)
634 if session_exists is False:
635 raise HTTPException(status_code=404, detail="Session not found")
636 raise HTTPException(status_code=403, detail="Session owner metadata unavailable")
638 requester_email, requester_is_admin = _get_request_identity(request, user)
639 if requester_is_admin:
640 return
641 if requester_email and requester_email == session_owner:
642 return
643 raise HTTPException(status_code=403, detail="Session access denied")
646async def _authorize_run_cancellation(request: Request, user, request_id: str, *, as_jsonrpc_error: bool) -> None:
647 """Authorize a notifications/cancelled request for a specific run id.
649 Args:
650 request: Incoming request context.
651 user: Authenticated user context.
652 request_id: Run/request identifier to cancel.
653 as_jsonrpc_error: Raise ``JSONRPCError`` when True, otherwise ``HTTPException``.
655 Raises:
656 JSONRPCError: When ``as_jsonrpc_error`` is True and cancellation is not authorized.
657 HTTPException: When ``as_jsonrpc_error`` is False and cancellation is not authorized.
658 """
659 requester_email, requester_token_teams, requester_is_admin = _get_rpc_filter_context(request, user)
660 requester_teams = [] if requester_token_teams is None else list(requester_token_teams)
661 run_status = await cancellation_service.get_status(request_id)
663 unauthorized = False
664 if run_status is None:
665 # Default deny for non-admin users when run is not known on this worker.
666 # Session-affinity clients should route cancellation to the worker that owns the run.
667 unauthorized = not requester_is_admin
668 else:
669 run_owner_email = run_status.get("owner_email")
670 run_owner_team_ids = run_status.get("owner_team_ids") or []
671 requester_is_owner = bool(run_owner_email and requester_email and run_owner_email == requester_email)
672 requester_shares_team = bool(run_owner_team_ids and requester_teams and any(team in run_owner_team_ids for team in requester_teams))
673 unauthorized = not requester_is_admin and not requester_is_owner and not requester_shares_team
675 if unauthorized:
676 if as_jsonrpc_error:
677 raise JSONRPCError(-32003, "Not authorized to cancel this run", {"requestId": request_id})
678 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to cancel this run")
681# Initialize cache
682resource_cache = ResourceCache(max_size=settings.resource_cache_size, ttl=settings.resource_cache_ttl)
685@lru_cache(maxsize=512)
686def _parse_jsonpath(jsonpath: str) -> JSONPath:
687 """Cache parsed JSONPath expression.
689 Args:
690 jsonpath: The JSONPath expression string.
692 Returns:
693 Parsed JSONPath object.
695 Raises:
696 Exception: If the JSONPath expression is invalid.
697 """
698 return parse(jsonpath)
701def jsonpath_modifier(data: Any, jsonpath: str = "$[*]", mappings: Optional[Dict[str, str]] = None) -> Union[List, Dict]:
702 """
703 Applies the given JSONPath expression and mappings to the data.
704 Uses cached parsed expressions for performance.
706 Args:
707 data: The JSON data to query.
708 jsonpath: The JSONPath expression to apply.
709 mappings: Optional dictionary of mappings where keys are new field names
710 and values are JSONPath expressions.
712 Returns:
713 Union[List, Dict]: A list (or mapped list) or a Dict of extracted data.
715 Raises:
716 HTTPException: If there's an error parsing or executing the JSONPath expressions.
718 Examples:
719 >>> jsonpath_modifier({'a': 1, 'b': 2}, '$.a')
720 [1]
721 >>> jsonpath_modifier([{'a': 1}, {'a': 2}], '$[*].a')
722 [1, 2]
723 >>> jsonpath_modifier({'a': {'b': 2}}, '$.a.b')
724 [2]
725 >>> jsonpath_modifier({'a': 1}, '$.b')
726 []
727 """
728 if not jsonpath:
729 jsonpath = "$[*]"
731 try:
732 main_expr: JSONPath = _parse_jsonpath(jsonpath)
733 except Exception as e:
734 raise HTTPException(status_code=400, detail=f"Invalid main JSONPath expression: {e}")
736 try:
737 main_matches = main_expr.find(data)
738 except Exception as e:
739 raise HTTPException(status_code=400, detail=f"Error executing main JSONPath: {e}")
741 results = [match.value for match in main_matches]
743 if mappings:
744 results = transform_data_with_mappings(results, mappings)
746 if len(results) == 1 and isinstance(results[0], dict):
747 return results[0]
749 return results
752def transform_data_with_mappings(data: list[Any], mappings: dict[str, str]) -> list[Any]:
753 """
754 Applies mappings to data using cached JSONPath expressions.
755 Parses each mapping expression once per call, not per item.
757 Args:
758 data: The set of data to apply mappings to.
759 mappings: dictionary of mappings where keys are new field names
761 Returns:
762 list[Any]: A list (or mapped list) of re-mapped data
764 Raises:
765 HTTPException: If there's an error parsing or executing the JSONPath expressions.
767 Examples:
768 >>> transform_data_with_mappings([{'first_name': "Bruce", 'second_name': "Wayne"},{'first_name': "Diana", 'second_name': "Prince"}], {"n": "$.first_name"})
769 [{'n': 'Bruce'}, {'n': 'Diana'}]
770 """
771 # Pre-parse all mapping expressions once (not per item)
772 parsed_mappings: Dict[str, JSONPath] = {}
773 for new_key, mapping_expr_str in mappings.items():
774 try:
775 parsed_mappings[new_key] = _parse_jsonpath(mapping_expr_str)
776 except Exception as e:
777 raise HTTPException(status_code=400, detail=f"Invalid mapping JSONPath for key '{new_key}': {e}")
779 mapped_results = []
780 for item in data:
781 mapped_item = {}
782 for new_key, mapping_expr in parsed_mappings.items():
783 try:
784 mapping_matches = mapping_expr.find(item)
785 except Exception as e:
786 raise HTTPException(status_code=400, detail=f"Error executing mapping JSONPath for key '{new_key}': {e}")
788 if not mapping_matches:
789 mapped_item[new_key] = None
790 elif len(mapping_matches) == 1:
791 mapped_item[new_key] = mapping_matches[0].value
792 else:
793 mapped_item[new_key] = [m.value for m in mapping_matches]
794 mapped_results.append(mapped_item)
796 return mapped_results
799async def attempt_to_bootstrap_sso_providers():
800 """
801 Try to bootstrap SSO provider services based on settings.
802 """
803 try:
804 # First-Party
805 from mcpgateway.utils.sso_bootstrap import bootstrap_sso_providers # pylint: disable=import-outside-toplevel
807 await bootstrap_sso_providers()
808 logger.info("SSO providers bootstrapped successfully")
809 except Exception as e:
810 logger.warning(f"Failed to bootstrap SSO providers: {e}")
813####################
814# Startup/Shutdown #
815####################
816@asynccontextmanager
817async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
818 """
819 Manage the application's startup and shutdown lifecycle.
821 The function initialises every core service on entry and then
822 shuts them down in reverse order on exit.
824 Args:
825 _app (FastAPI): FastAPI app
827 Yields:
828 None
830 Raises:
831 SystemExit: When a critical startup error occurs that prevents
832 the application from starting successfully.
833 Exception: Any unhandled error that occurs during service
834 initialisation or shutdown is re-raised to the caller.
835 """
836 aggregation_stop_event: Optional[asyncio.Event] = None
837 aggregation_loop_task: Optional[asyncio.Task] = None
838 aggregation_backfill_task: Optional[asyncio.Task] = None
840 # Initialize logging service FIRST to ensure all logging goes to dual output
841 await logging_service.initialize()
842 logger.info("Starting ContextForge services")
844 # Initialize Redis client early (shared pool for all services)
845 await get_redis_client()
847 # Initialize shared HTTP client (connection pool for all outbound requests)
848 # First-Party
849 from mcpgateway.services.http_client_service import SharedHttpClient # pylint: disable=import-outside-toplevel
851 await SharedHttpClient.get_instance()
853 # Update HTTP pool metrics after SharedHttpClient is initialized
854 if hasattr(app.state, "update_http_pool_metrics"):
855 app.state.update_http_pool_metrics()
857 # Initialize MCP session pool (for session reuse across tool invocations)
858 # Also initialize if session affinity is enabled (needs the ownership registry)
859 if settings.mcp_session_pool_enabled or settings.mcpgateway_session_affinity_enabled:
860 # First-Party
861 from mcpgateway.services.mcp_session_pool import init_mcp_session_pool # pylint: disable=import-outside-toplevel
863 # Auto-align pool health check interval to min of pool and gateway settings
864 effective_health_check_interval = min(
865 settings.health_check_interval,
866 settings.mcp_session_pool_health_check_interval,
867 )
869 max_sessions_per_key = settings.mcpgateway_session_affinity_max_sessions if settings.mcpgateway_session_affinity_enabled else settings.mcp_session_pool_max_per_key
870 init_mcp_session_pool(
871 max_sessions_per_key=max_sessions_per_key,
872 session_ttl_seconds=settings.mcp_session_pool_ttl,
873 health_check_interval_seconds=effective_health_check_interval,
874 acquire_timeout_seconds=settings.mcp_session_pool_acquire_timeout,
875 session_create_timeout_seconds=settings.mcp_session_pool_create_timeout,
876 circuit_breaker_threshold=settings.mcp_session_pool_circuit_breaker_threshold,
877 circuit_breaker_reset_seconds=settings.mcp_session_pool_circuit_breaker_reset,
878 identity_headers=frozenset(settings.mcp_session_pool_identity_headers),
879 idle_pool_eviction_seconds=settings.mcp_session_pool_idle_eviction,
880 # Use dedicated transport timeout (default 30s to match MCP SDK default).
881 # This is separate from health_check_timeout to allow long-running tool calls.
882 default_transport_timeout_seconds=settings.mcp_session_pool_transport_timeout,
883 # Configurable health check chain - ordered list of methods to try.
884 health_check_methods=settings.mcp_session_pool_health_check_methods,
885 health_check_timeout_seconds=settings.mcp_session_pool_health_check_timeout,
886 )
887 logger.info("MCP session pool initialized")
889 # Initialize LLM chat router Redis client
890 # First-Party
891 from mcpgateway.routers.llmchat_router import init_redis as init_llmchat_redis # pylint: disable=import-outside-toplevel
893 await init_llmchat_redis()
895 # Initialize observability (Phoenix tracing)
896 init_telemetry()
897 logger.info("Observability initialized")
899 try:
900 # Validate security configuration
901 validate_security_configuration()
903 if plugin_manager:
904 await plugin_manager.initialize()
905 logger.info(f"Plugin manager initialized with {plugin_manager.plugin_count} plugins")
907 if settings.enable_header_passthrough:
908 await setup_passthrough_headers()
909 else:
910 logger.info("🔒 Header Passthrough: DISABLED")
912 await tool_service.initialize()
913 await resource_service.initialize()
914 await prompt_service.initialize()
915 await gateway_service.initialize()
917 # Start notification service for event-driven refresh (after gateway_service is ready)
918 if settings.mcp_session_pool_enabled:
919 # First-Party
920 from mcpgateway.services.mcp_session_pool import start_pool_notification_service # pylint: disable=import-outside-toplevel
922 await start_pool_notification_service(gateway_service)
924 # Start RPC listener for multi-worker session affinity
925 if settings.mcpgateway_session_affinity_enabled:
926 # First-Party
927 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool # pylint: disable=import-outside-toplevel
929 pool = get_mcp_session_pool()
930 pool._rpc_listener_task = asyncio.create_task(pool.start_rpc_listener()) # pylint: disable=protected-access
931 logger.info("Multi-worker session affinity RPC listener started")
933 await root_service.initialize()
934 await completion_service.initialize()
935 await sampling_handler.initialize()
936 await export_service.initialize()
937 await import_service.initialize()
938 if a2a_service:
939 await a2a_service.initialize()
940 await resource_cache.initialize()
941 await streamable_http_session.initialize()
942 await session_registry.initialize()
944 # Initialize OrchestrationService for tool cancellation if enabled
945 if settings.mcpgateway_tool_cancellation_enabled:
946 await cancellation_service.initialize()
947 logger.info("Tool cancellation feature enabled")
948 else:
949 logger.info("Tool cancellation feature disabled")
951 # Initialize elicitation service
952 if settings.mcpgateway_elicitation_enabled:
953 # First-Party
954 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel
956 elicitation_service = get_elicitation_service()
957 await elicitation_service.start()
958 logger.info("Elicitation service initialized")
960 # Initialize metrics buffer service for batching metric writes
961 if settings.metrics_buffer_enabled:
962 # First-Party
963 from mcpgateway.services.metrics_buffer_service import get_metrics_buffer_service # pylint: disable=import-outside-toplevel
965 metrics_buffer_service = get_metrics_buffer_service()
966 await metrics_buffer_service.start()
967 if settings.db_metrics_recording_enabled:
968 logger.info("Metrics buffer service initialized")
969 else:
970 logger.info("Metrics buffer service initialized (recording disabled)")
972 # Initialize metrics cleanup service for automatic deletion of old metrics
973 if settings.metrics_cleanup_enabled:
974 # First-Party
975 from mcpgateway.services.metrics_cleanup_service import get_metrics_cleanup_service # pylint: disable=import-outside-toplevel
977 metrics_cleanup_service = get_metrics_cleanup_service()
978 await metrics_cleanup_service.start()
979 logger.info("Metrics cleanup service initialized (retention: %d days)", settings.metrics_retention_days)
981 # Initialize metrics rollup service for hourly aggregation
982 if settings.metrics_rollup_enabled:
983 # First-Party
984 from mcpgateway.services.metrics_rollup_service import get_metrics_rollup_service # pylint: disable=import-outside-toplevel
986 metrics_rollup_service = get_metrics_rollup_service()
987 await metrics_rollup_service.start()
988 logger.info("Metrics rollup service initialized (interval: %dh)", settings.metrics_rollup_interval_hours)
990 refresh_slugs_on_startup()
992 # Bootstrap SSO providers from environment configuration
993 if settings.sso_enabled:
994 await attempt_to_bootstrap_sso_providers()
996 logger.info("All services initialized successfully")
998 # Start cache invalidation subscriber for cross-worker cache synchronization
999 # First-Party
1000 from mcpgateway.cache.registry_cache import get_cache_invalidation_subscriber # pylint: disable=import-outside-toplevel
1002 cache_invalidation_subscriber = get_cache_invalidation_subscriber()
1003 await cache_invalidation_subscriber.start()
1005 # Reconfigure uvicorn loggers after startup to capture access logs in dual output
1006 logging_service.configure_uvicorn_after_startup()
1008 if settings.metrics_aggregation_enabled and settings.metrics_aggregation_auto_start:
1009 aggregation_stop_event = asyncio.Event()
1010 log_aggregator = get_log_aggregator()
1012 async def run_log_backfill() -> None:
1013 """Backfill log aggregation metrics for configured hours."""
1014 hours = getattr(settings, "metrics_aggregation_backfill_hours", 0)
1015 if hours <= 0:
1016 return
1017 try:
1018 await asyncio.to_thread(log_aggregator.backfill, hours)
1019 logger.info("Log aggregation backfill completed for last %s hour(s)", hours)
1020 except Exception as backfill_error: # pragma: no cover - defensive logging
1021 logger.warning("Log aggregation backfill failed: %s", backfill_error)
1023 async def run_log_aggregation_loop() -> None:
1024 """Run continuous log aggregation at configured intervals.
1026 Raises:
1027 asyncio.CancelledError: When aggregation is stopped
1028 """
1029 interval_seconds = max(1, int(settings.metrics_aggregation_window_minutes)) * 60
1030 logger.info(
1031 "Starting log aggregation loop (window=%s min)",
1032 log_aggregator.aggregation_window_minutes,
1033 )
1034 try:
1035 while not aggregation_stop_event.is_set():
1036 try:
1037 await asyncio.to_thread(log_aggregator.aggregate_all_components)
1038 except Exception as agg_error: # pragma: no cover - defensive logging
1039 logger.warning("Log aggregation loop iteration failed: %s", agg_error)
1041 try:
1042 await asyncio.wait_for(aggregation_stop_event.wait(), timeout=interval_seconds)
1043 except asyncio.TimeoutError:
1044 continue
1045 except asyncio.CancelledError:
1046 logger.debug("Log aggregation loop cancelled")
1047 raise
1048 finally:
1049 logger.info("Log aggregation loop stopped")
1051 aggregation_backfill_task = asyncio.create_task(run_log_backfill())
1052 aggregation_loop_task = asyncio.create_task(run_log_aggregation_loop())
1053 elif settings.metrics_aggregation_enabled:
1054 logger.info("Metrics aggregation auto-start disabled; performance metrics will be generated on-demand when requested.")
1056 yield
1057 except Exception as e:
1058 logger.error(f"Error during startup: {str(e)}")
1059 # For plugin errors, exit cleanly without stack trace spam
1060 if "Plugin initialization failed" in str(e):
1061 # Suppress uvicorn error logging for clean exit
1062 # Standard
1063 import logging # pylint: disable=import-outside-toplevel
1065 logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL)
1066 raise SystemExit(1)
1067 raise
1068 finally:
1069 if aggregation_stop_event is not None:
1070 aggregation_stop_event.set()
1071 for task in (aggregation_backfill_task, aggregation_loop_task):
1072 if task:
1073 task.cancel()
1074 with suppress(asyncio.CancelledError):
1075 await task
1077 # Shutdown plugin manager
1078 if plugin_manager:
1079 try:
1080 await plugin_manager.shutdown()
1081 logger.info("Plugin manager shutdown complete")
1082 except Exception as e:
1083 logger.error(f"Error shutting down plugin manager: {str(e)}")
1085 # Stop cache invalidation subscriber
1086 try:
1087 # First-Party
1088 from mcpgateway.cache.registry_cache import get_cache_invalidation_subscriber # pylint: disable=import-outside-toplevel
1090 cache_invalidation_subscriber = get_cache_invalidation_subscriber()
1091 await cache_invalidation_subscriber.stop()
1092 except Exception as e:
1093 logger.debug(f"Error stopping cache invalidation subscriber: {e}")
1095 logger.info("Shutting down ContextForge services")
1096 # await stop_streamablehttp()
1097 # Build service list conditionally
1098 services_to_shutdown: List[Any] = [
1099 resource_cache,
1100 sampling_handler,
1101 import_service,
1102 export_service,
1103 logging_service,
1104 completion_service,
1105 root_service,
1106 gateway_service,
1107 prompt_service,
1108 resource_service,
1109 tool_service,
1110 streamable_http_session,
1111 session_registry,
1112 ]
1114 # Add cancellation service if enabled
1115 if settings.mcpgateway_tool_cancellation_enabled:
1116 services_to_shutdown.insert(0, cancellation_service) # Shutdown early to stop accepting new cancellations
1118 if a2a_service:
1119 services_to_shutdown.insert(4, a2a_service) # Insert after export_service
1121 # Add elicitation service if enabled
1122 if settings.mcpgateway_elicitation_enabled:
1123 # First-Party
1124 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel
1126 elicitation_service = get_elicitation_service()
1127 services_to_shutdown.insert(5, elicitation_service)
1129 # Add metrics buffer service if enabled (flush remaining metrics before shutdown)
1130 if settings.metrics_buffer_enabled:
1131 # First-Party
1132 from mcpgateway.services.metrics_buffer_service import get_metrics_buffer_service # pylint: disable=import-outside-toplevel
1134 metrics_buffer_service = get_metrics_buffer_service()
1135 services_to_shutdown.insert(0, metrics_buffer_service) # Shutdown first to flush metrics
1137 # Add metrics rollup service if enabled (shutdown before cleanup)
1138 if settings.metrics_rollup_enabled:
1139 # First-Party
1140 from mcpgateway.services.metrics_rollup_service import get_metrics_rollup_service # pylint: disable=import-outside-toplevel
1142 metrics_rollup_service = get_metrics_rollup_service()
1143 services_to_shutdown.insert(1, metrics_rollup_service)
1145 # Add metrics cleanup service if enabled
1146 if settings.metrics_cleanup_enabled:
1147 # First-Party
1148 from mcpgateway.services.metrics_cleanup_service import get_metrics_cleanup_service # pylint: disable=import-outside-toplevel
1150 metrics_cleanup_service = get_metrics_cleanup_service()
1151 services_to_shutdown.insert(2, metrics_cleanup_service)
1153 await shutdown_services(services_to_shutdown)
1155 # Shutdown MCP session pool (before shared HTTP client)
1156 if settings.mcp_session_pool_enabled:
1157 # First-Party
1158 from mcpgateway.services.mcp_session_pool import close_mcp_session_pool # pylint: disable=import-outside-toplevel
1160 await close_mcp_session_pool()
1162 # Shutdown shared HTTP client (after services, before Redis)
1163 await SharedHttpClient.shutdown()
1165 # Close Redis client last (after all services that use it)
1166 await close_redis_client()
1168 logger.info("Shutdown complete")
1171async def shutdown_services(services_to_shutdown: list[Any]):
1172 """
1173 Awaits shutdown of services provided in a list
1175 Args:
1176 services_to_shutdown (list[Any]): list of services to shutdown
1177 """
1178 for service in services_to_shutdown:
1179 try:
1180 await service.shutdown()
1181 except Exception as e:
1182 logger.error(f"Error shutting down {service.__class__.__name__}: {str(e)}")
1185async def setup_passthrough_headers():
1186 """
1187 Enables configuration and logs active settings as needed for when passthrough headers are enabled.
1188 """
1189 logger.info(f"🔄 Header Passthrough: ENABLED (default headers: {settings.default_passthrough_headers})")
1190 if settings.enable_overwrite_base_headers:
1191 logger.warning("⚠️ Base Header Override: ENABLED - Client headers can override gateway headers")
1192 else:
1193 logger.info("🔒 Base Header Override: DISABLED - Gateway headers take precedence")
1194 db_gen = get_db()
1195 db = next(db_gen) # pylint: disable=stop-iteration-return
1196 try:
1197 await set_global_passthrough_headers(db)
1198 finally:
1199 db.commit() # End transaction cleanly
1200 db.close()
1203# Initialize FastAPI app with orjson for 2-3x faster JSON serialization
1204app = FastAPI(
1205 title=settings.app_name,
1206 version=__version__,
1207 description="ContextForge AI Gateway — an AI gateway, registry, and proxy for MCP, A2A, and REST/gRPC APIs. Exposes a unified control plane with centralized governance, discovery, and observability. Optimizes agent and tool calling, and supports plugins.",
1208 root_path=settings.app_root_path,
1209 lifespan=lifespan,
1210 default_response_class=ORJSONResponse, # Use orjson for high-performance JSON serialization
1211)
1213# Setup metrics instrumentation
1214setup_metrics(app)
1217def validate_security_configuration():
1218 """
1219 Validate security configuration on startup.
1220 This function encapsulates:
1221 - verifying the configuration,
1222 - logging the output for warnings,
1223 - critical issues
1224 - security recommendations
1226 Args: None
1227 Raises: Passthrough Errors/Exceptions but doesn't raise any of its own.
1228 """
1229 logger.info("🔒 Validating security configuration...")
1231 # Get security status
1232 security_status: settings.SecurityStatus = settings.get_security_status()
1233 security_warnings = security_status["warnings"]
1235 log_security_warnings(security_warnings)
1237 # Critical security checks (fail startup only if REQUIRE_STRONG_SECRETS=true)
1238 critical_issues = []
1240 if settings.jwt_secret_key == "my-test-key" and not settings.dev_mode: # nosec B105 - checking for default value
1241 critical_issues.append("Using default JWT secret in non-dev mode. Set JWT_SECRET_KEY environment variable!")
1243 if settings.basic_auth_password.get_secret_value() == "changeme" and settings.mcpgateway_ui_enabled: # nosec B105 - checking for default value
1244 critical_issues.append("Admin UI enabled with default password. Set BASIC_AUTH_PASSWORD environment variable!")
1246 log_critical_issues(critical_issues)
1248 # Warn about ephemeral storage without strict user-in-DB mode
1249 if not getattr(settings, "require_user_in_db", False):
1250 is_ephemeral = ":memory:" in settings.database_url or settings.database_url == "sqlite:///./mcp.db"
1251 if is_ephemeral:
1252 logger.warning("Using potentially ephemeral storage with platform admin bootstrap enabled. Consider using persistent storage or setting REQUIRE_USER_IN_DB=true for production.")
1254 # Warn about default JWT issuer/audience in non-development environments
1255 if settings.environment != "development":
1256 if settings.jwt_issuer == "mcpgateway":
1257 logger.warning("Using default JWT_ISSUER in %s environment. Set a unique JWT_ISSUER per environment to prevent cross-environment token acceptance.", settings.environment)
1258 if settings.jwt_audience == "mcpgateway-api":
1259 logger.warning("Using default JWT_AUDIENCE in %s environment. Set a unique JWT_AUDIENCE per environment to prevent cross-environment token acceptance.", settings.environment)
1261 log_security_recommendations(security_status)
1264def log_security_warnings(security_warnings: list[str]):
1265 """Log warnings from list of security warnings provided.
1267 Args:
1268 security_warnings: List of security warning messages.
1269 """
1270 if security_warnings:
1271 logger.warning("=" * 60)
1272 logger.warning("🚨 SECURITY WARNINGS DETECTED:")
1273 logger.warning("=" * 60)
1274 for warning in security_warnings:
1275 logger.warning(f" {warning}")
1276 logger.warning("=" * 60)
1279def log_critical_issues(critical_issues: list[Any]):
1280 """
1281 Log critical based on configuration settings
1282 If REQUIRE_STRONG_SECRETS set, this will output critical errors and exit the mcpgateway server.
1284 Args:
1285 critical_issues: List
1287 Returns: None
1288 """
1289 # Handle critical issues based on REQUIRE_STRONG_SECRETS setting
1290 if critical_issues:
1291 if settings.require_strong_secrets:
1292 logger.error("=" * 60)
1293 logger.error("💀 CRITICAL SECURITY ISSUES DETECTED:")
1294 logger.error("=" * 60)
1295 for issue in critical_issues:
1296 logger.error(f" ❌ {issue}")
1297 logger.error("=" * 60)
1298 logger.error("Startup aborted due to REQUIRE_STRONG_SECRETS=true")
1299 logger.error("To proceed anyway, set REQUIRE_STRONG_SECRETS=false")
1300 logger.error("=" * 60)
1301 sys.exit(1)
1302 else:
1303 # Log as warnings if not enforcing
1304 logger.warning("=" * 60)
1305 logger.warning("⚠️ Critical security issues detected (REQUIRE_STRONG_SECRETS=false):")
1306 for issue in critical_issues:
1307 logger.warning(f" • {issue}")
1308 logger.warning("=" * 60)
1311def log_security_recommendations(security_status: settings.SecurityStatus):
1312 """
1313 Log security recommendations based on configuration settings
1315 Args:
1316 security_status (settings.SecurityStatus): The SecurityStatus object for checking and logging current security settings from MCPGateway.
1318 Returns: None
1319 """
1320 if not security_status["secure_secrets"] or not security_status["auth_enabled"]:
1321 logger.info("=" * 60)
1322 logger.info("📋 SECURITY RECOMMENDATIONS:")
1323 logger.info("=" * 60)
1325 if settings.jwt_secret_key == "my-test-key": # nosec B105 - checking for default value
1326 logger.info(" • Generate a strong JWT secret:")
1327 logger.info(" python3 -c 'import secrets; print(secrets.token_urlsafe(32))'")
1329 if settings.basic_auth_password.get_secret_value() == "changeme": # nosec B105 - checking for default value
1330 logger.info(" • Set a strong admin password in BASIC_AUTH_PASSWORD")
1332 if not settings.auth_required:
1333 logger.info(" • Enable authentication: AUTH_REQUIRED=true")
1335 if settings.skip_ssl_verify:
1336 logger.info(" • Enable SSL verification: SKIP_SSL_VERIFY=false")
1338 logger.info("=" * 60)
1340 logger.info("✅ Security validation completed")
1343# Global exceptions handlers
1344@app.exception_handler(ValidationError)
1345async def validation_exception_handler(_request: Request, exc: ValidationError):
1346 """Handle Pydantic validation errors globally.
1348 Intercepts ValidationError exceptions raised anywhere in the application
1349 and returns a properly formatted JSON error response with detailed
1350 validation error information.
1352 Args:
1353 _request: The FastAPI request object that triggered the validation error.
1354 (Unused but required by FastAPI's exception handler interface)
1355 exc: The Pydantic ValidationError exception containing validation
1356 failure details.
1358 Returns:
1359 JSONResponse: A 422 Unprocessable Entity response with formatted
1360 validation error details.
1362 Examples:
1363 >>> from pydantic import ValidationError, BaseModel
1364 >>> from fastapi import Request
1365 >>> import asyncio
1366 >>>
1367 >>> class TestModel(BaseModel):
1368 ... name: str
1369 ... age: int
1370 >>>
1371 >>> # Create a validation error
1372 >>> try:
1373 ... TestModel(name="", age="invalid")
1374 ... except ValidationError as e:
1375 ... # Test our handler
1376 ... result = asyncio.run(validation_exception_handler(None, e))
1377 ... result.status_code
1378 422
1379 """
1380 return ORJSONResponse(status_code=422, content=ErrorFormatter.format_validation_error(exc))
1383@app.exception_handler(RequestValidationError)
1384async def request_validation_exception_handler(_request: Request, exc: RequestValidationError):
1385 """Handle FastAPI request validation errors (automatic request parsing).
1387 This handles ValidationErrors that occur during FastAPI's automatic request
1388 parsing before the request reaches your endpoint.
1390 Args:
1391 _request: The FastAPI request object that triggered validation error.
1392 exc: The RequestValidationError exception containing failure details.
1394 Returns:
1395 JSONResponse: A 422 Unprocessable Entity response with error details.
1396 """
1397 if _request.url.path.startswith("/tools"):
1398 error_details = []
1400 for error in exc.errors():
1401 loc = error.get("loc", [])
1402 msg = error.get("msg", "Unknown error")
1403 ctx = error.get("ctx", {"error": {}})
1404 type_ = error.get("type", "value_error")
1405 # Ensure ctx is JSON serializable
1406 if isinstance(ctx, dict):
1407 ctx_serializable = {k: (str(v) if isinstance(v, Exception) else v) for k, v in ctx.items()}
1408 else:
1409 ctx_serializable = str(ctx)
1410 error_detail = {"type": type_, "loc": loc, "msg": msg, "ctx": ctx_serializable}
1411 error_details.append(error_detail)
1413 response_content = {"detail": error_details}
1414 return ORJSONResponse(status_code=422, content=response_content)
1415 return await fastapi_default_validation_handler(_request, exc)
1418@app.exception_handler(IntegrityError)
1419async def database_exception_handler(_request: Request, exc: IntegrityError):
1420 """Handle SQLAlchemy database integrity constraint violations globally.
1422 Intercepts IntegrityError exceptions (e.g., unique constraint violations,
1423 foreign key constraints) and returns a properly formatted JSON error response.
1424 This provides consistent error handling for database constraint violations
1425 across the entire application.
1427 Args:
1428 _request: The FastAPI request object that triggered the database error.
1429 (Unused but required by FastAPI's exception handler interface)
1430 exc: The SQLAlchemy IntegrityError exception containing constraint
1431 violation details.
1433 Returns:
1434 JSONResponse: A 409 Conflict response with formatted database error details.
1436 Examples:
1437 >>> from sqlalchemy.exc import IntegrityError
1438 >>> from fastapi import Request
1439 >>> import asyncio
1440 >>>
1441 >>> # Create a mock integrity error
1442 >>> mock_error = IntegrityError("statement", {}, Exception("duplicate key"))
1443 >>> result = asyncio.run(database_exception_handler(None, mock_error))
1444 >>> result.status_code
1445 409
1446 >>> # Verify ErrorFormatter.format_database_error is called
1447 >>> hasattr(result, 'body')
1448 True
1449 """
1450 return ORJSONResponse(status_code=409, content=ErrorFormatter.format_database_error(exc))
1453@app.exception_handler(PluginViolationError)
1454async def plugin_violation_exception_handler(_request: Request, exc: PluginViolationError):
1455 """Handle plugins violations globally.
1457 Intercepts PluginViolationError exceptions (e.g., OPA policy violation) and returns a properly formatted JSON error response.
1458 This provides consistent error handling for plugin violation across the entire application.
1460 Args:
1461 _request: The FastAPI request object that triggered the database error.
1462 (Unused but required by FastAPI's exception handler interface)
1463 exc: The PluginViolationError exception containing constraint
1464 violation details.
1466 Returns:
1467 JSONResponse: A 200 response with error details in JSON-RPC format.
1469 Examples:
1470 >>> from mcpgateway.plugins.framework import PluginViolationError
1471 >>> from mcpgateway.plugins.framework.models import PluginViolation
1472 >>> from fastapi import Request
1473 >>> import asyncio
1474 >>> import json
1475 >>>
1476 >>> # Create a plugin violation error
1477 >>> mock_error = PluginViolationError(message="plugin violation",violation = PluginViolation(
1478 ... reason="Invalid input",
1479 ... description="The input contains prohibited content",
1480 ... code="PROHIBITED_CONTENT",
1481 ... details={"field": "message", "value": "test"}
1482 ... ))
1483 >>> result = asyncio.run(plugin_violation_exception_handler(None, mock_error))
1484 >>> result.status_code
1485 200
1486 >>> content = orjson.loads(result.body.decode())
1487 >>> content["error"]["code"]
1488 -32602
1489 >>> "Plugin Violation:" in content["error"]["message"]
1490 True
1491 >>> content["error"]["data"]["plugin_error_code"]
1492 'PROHIBITED_CONTENT'
1493 """
1494 policy_violation = exc.violation.model_dump() if exc.violation else {}
1495 message = exc.violation.description if exc.violation else "A plugin violation occurred."
1496 policy_violation["message"] = exc.message
1497 status_code = exc.violation.mcp_error_code if exc.violation and exc.violation.mcp_error_code else -32602
1498 violation_details: dict[str, Any] = {}
1499 if exc.violation:
1500 if exc.violation.description:
1501 violation_details["description"] = exc.violation.description
1502 if exc.violation.details:
1503 violation_details["details"] = exc.violation.details
1504 if exc.violation.code:
1505 violation_details["plugin_error_code"] = exc.violation.code
1506 if exc.violation.plugin_name:
1507 violation_details["plugin_name"] = exc.violation.plugin_name
1508 json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Violation: " + message, data=violation_details)
1509 return ORJSONResponse(status_code=200, content={"error": json_rpc_error.model_dump()})
1512@app.exception_handler(PluginError)
1513async def plugin_exception_handler(_request: Request, exc: PluginError):
1514 """Handle plugins errors globally.
1516 Intercepts PluginError exceptions and returns a properly formatted JSON error response.
1517 This provides consistent error handling for plugin error across the entire application.
1519 Args:
1520 _request: The FastAPI request object that triggered the database error.
1521 (Unused but required by FastAPI's exception handler interface)
1522 exc: The PluginError exception containing constraint
1523 violation details.
1525 Returns:
1526 JSONResponse: A 200 response with error details in JSON-RPC format.
1528 Examples:
1529 >>> from mcpgateway.plugins.framework import PluginError
1530 >>> from mcpgateway.plugins.framework.models import PluginErrorModel
1531 >>> from fastapi import Request
1532 >>> import asyncio
1533 >>> import json
1534 >>>
1535 >>> # Create a plugin error
1536 >>> mock_error = PluginError(error = PluginErrorModel(
1537 ... message="plugin error",
1538 ... code="timeout",
1539 ... plugin_name="abc",
1540 ... details={"field": "message", "value": "test"}
1541 ... ))
1542 >>> result = asyncio.run(plugin_exception_handler(None, mock_error))
1543 >>> result.status_code
1544 200
1545 >>> content = orjson.loads(result.body.decode())
1546 >>> content["error"]["code"]
1547 -32603
1548 >>> "Plugin Error:" in content["error"]["message"]
1549 True
1550 >>> content["error"]["data"]["plugin_error_code"]
1551 'timeout'
1552 >>> content["error"]["data"]["plugin_name"]
1553 'abc'
1554 """
1555 message = exc.error.message if exc.error else "A plugin error occurred."
1556 status_code = exc.error.mcp_error_code if exc.error else -32603
1557 error_details: dict[str, Any] = {}
1558 if exc.error:
1559 if exc.error.details:
1560 error_details["details"] = exc.error.details
1561 if exc.error.code:
1562 error_details["plugin_error_code"] = exc.error.code
1563 if exc.error.plugin_name:
1564 error_details["plugin_name"] = exc.error.plugin_name
1565 json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Error: " + message, data=error_details)
1566 return ORJSONResponse(status_code=200, content={"error": json_rpc_error.model_dump()})
1569def _normalize_scope_path(scope_path: str, root_path: str) -> str:
1570 """Strip ``root_path`` prefix from *scope_path* when a reverse proxy forwards the full path.
1572 Returns the route-only path (e.g. ``"/qa/gateway/docs"`` -> ``"/docs"``).
1573 A ``root_path`` of ``"/"`` is ignored to avoid stripping the leading slash
1574 from every path. Trailing slashes on *root_path* are stripped before
1575 comparison so that ``"/qa/gateway/"`` is handled identically to
1576 ``"/qa/gateway"``.
1578 Args:
1579 scope_path: The full path from the request scope.
1580 root_path: The root path prefix to be stripped.
1582 Returns:
1583 The normalized path with the root_path prefix removed.
1584 """
1585 if root_path and len(root_path) > 1:
1586 root_path = root_path.rstrip("/")
1587 if root_path and len(root_path) > 1 and scope_path.startswith(root_path):
1588 rest = scope_path[len(root_path) :]
1589 # Ensure we matched a full path segment, not a partial prefix
1590 # e.g. root_path="/app" must not strip from "/application/admin"
1591 if not rest or rest[0] == "/":
1592 return rest or "/"
1593 return scope_path
1596class DocsAuthMiddleware(BaseHTTPMiddleware):
1597 """
1598 Middleware to protect FastAPI's auto-generated documentation routes
1599 (/docs, /redoc, and /openapi.json) using Bearer token authentication.
1601 If a request to one of these paths is made without a valid token,
1602 the request is rejected with a 401 or 403 error.
1604 Note:
1605 OPTIONS requests are exempt from authentication to support CORS preflight
1606 as per RFC 7231 Section 4.3.7 (OPTIONS must not require authentication).
1608 Note:
1609 When DOCS_ALLOW_BASIC_AUTH is enabled, Basic Authentication
1610 is also accepted using BASIC_AUTH_USER and BASIC_AUTH_PASSWORD credentials.
1611 """
1613 async def dispatch(self, request: Request, call_next):
1614 """
1615 Intercepts incoming requests to check if they are accessing protected documentation routes.
1616 If so, it requires a valid Bearer token; otherwise, it allows the request to proceed.
1618 Args:
1619 request (Request): The incoming HTTP request.
1620 call_next (Callable): The function to call the next middleware or endpoint.
1622 Returns:
1623 Response: Either the standard route response or a 401/403 error response.
1625 Examples:
1626 >>> import asyncio
1627 >>> from unittest.mock import Mock, AsyncMock, patch
1628 >>> from fastapi import HTTPException
1629 >>> from fastapi.responses import JSONResponse
1630 >>>
1631 >>> # Test unprotected path - should pass through
1632 >>> middleware = DocsAuthMiddleware(None)
1633 >>> request = Mock()
1634 >>> request.url.path = "/api/tools"
1635 >>> request.scope = {"path": "/api/tools", "root_path": ""}
1636 >>> request.method = "GET"
1637 >>> request.headers.get.return_value = None
1638 >>> call_next = AsyncMock(return_value="response")
1639 >>>
1640 >>> result = asyncio.run(middleware.dispatch(request, call_next))
1641 >>> result
1642 'response'
1643 >>>
1644 >>> # Test that middleware checks protected paths
1645 >>> request.url.path = "/docs"
1646 >>> isinstance(middleware, DocsAuthMiddleware)
1647 True
1648 """
1649 protected_paths = ["/docs", "/redoc", "/openapi.json"]
1651 # Allow OPTIONS requests to pass through for CORS preflight (RFC 7231)
1652 if request.method == "OPTIONS":
1653 return await call_next(request)
1655 # Get path from scope to handle root_path correctly
1656 scope_path = request.scope.get("path", request.url.path)
1657 root_path = request.scope.get("root_path", "")
1658 scope_path = _normalize_scope_path(scope_path, root_path)
1660 is_protected = any(scope_path.startswith(p) for p in protected_paths)
1662 if is_protected:
1663 try:
1664 token = request.headers.get("Authorization")
1665 cookie_token = request.cookies.get("jwt_token")
1667 # Use dedicated docs authentication that bypasses global auth settings
1668 await require_docs_auth_override(token, cookie_token)
1669 except HTTPException as e:
1670 return ORJSONResponse(status_code=e.status_code, content={"detail": e.detail}, headers=e.headers if e.headers else None)
1672 # Proceed to next middleware or route
1673 return await call_next(request)
1676class AdminAuthMiddleware(BaseHTTPMiddleware):
1677 """
1678 Middleware to protect Admin UI routes (/admin/*) requiring admin privileges.
1680 Exempts login-related paths and static assets:
1681 - /admin/login - login page
1682 - /admin/logout - logout action
1683 - /admin/forgot-password - self-service password reset request page
1684 - /admin/reset-password/* - self-service password reset completion page
1685 - /admin/static/* - static assets
1687 All other /admin/* routes require the user to be authenticated AND be an admin.
1688 Non-admin authenticated users receive a 403 Forbidden response.
1690 Note: This middleware respects the auth_required setting. When auth_required=False
1691 (typically in test environments), the middleware allows requests to pass through
1692 and relies on endpoint-level authentication which can be mocked in tests.
1693 """
1695 # Public paths under /admin that do not require prior authentication.
1696 EXEMPT_PATHS = [
1697 "/admin/login",
1698 "/admin/logout",
1699 "/admin/forgot-password",
1700 "/admin/reset-password",
1701 "/admin/static",
1702 ]
1704 @staticmethod
1705 def _error_response(request: Request, root_path: str, status_code: int, detail: str, error_param: str = None):
1706 """Return appropriate error response based on request Accept header.
1708 Args:
1709 request: The incoming HTTP request.
1710 root_path: The root path prefix for the application.
1711 status_code: HTTP status code for JSON responses.
1712 detail: Error message detail.
1713 error_param: Optional error parameter for login redirect URL.
1715 Returns:
1716 Response with HX-Redirect for HTMX requests, RedirectResponse for HTML requests, ORJSONResponse for API requests.
1717 """
1718 accept_header = request.headers.get("accept", "")
1719 is_htmx = request.headers.get("hx-request") == "true"
1720 if "text/html" in accept_header or is_htmx:
1721 login_url = f"{root_path}/admin/login" if root_path else "/admin/login"
1722 if error_param:
1723 login_url = f"{login_url}?error={error_param}"
1724 if is_htmx:
1725 return Response(status_code=200, headers={"HX-Redirect": login_url})
1726 return RedirectResponse(url=login_url, status_code=302)
1727 return ORJSONResponse(status_code=status_code, content={"detail": detail})
1729 async def dispatch(self, request: Request, call_next): # pylint: disable=too-many-return-statements
1730 """
1731 Check admin privileges for admin routes.
1733 Args:
1734 request (Request): The incoming HTTP request.
1735 call_next (Callable): The function to call the next middleware or endpoint.
1737 Returns:
1738 Response: Either the standard route response or a 401/403 error response.
1739 """
1740 # Skip admin auth check if auth is not required (e.g., test environments)
1741 # This allows tests to mock authentication at the dependency level
1742 if not settings.auth_required:
1743 return await call_next(request)
1745 # Get path from scope to handle root_path correctly
1746 scope_path = request.scope.get("path", request.url.path)
1747 root_path = request.scope.get("root_path", "")
1748 scope_path = _normalize_scope_path(scope_path, root_path)
1750 # Allow OPTIONS requests for CORS preflight (RFC 7231)
1751 if request.method == "OPTIONS":
1752 return await call_next(request)
1754 # Check if this is an admin route
1755 is_admin_route = scope_path.startswith("/admin")
1757 if not is_admin_route:
1758 return await call_next(request)
1760 # Check if path is exempt (login, logout, static)
1761 is_exempt = any(scope_path.startswith(p) for p in self.EXEMPT_PATHS)
1762 if is_exempt:
1763 return await call_next(request)
1765 # For protected admin routes, verify admin status
1766 try:
1767 token = request.headers.get("Authorization")
1768 cookie_token = request.cookies.get("jwt_token") or request.cookies.get("access_token")
1770 # Extract token from header or cookie
1771 jwt_token = None
1772 if cookie_token:
1773 jwt_token = cookie_token
1774 elif token and token.startswith("Bearer "):
1775 jwt_token = token.split(" ", 1)[1]
1777 username = None
1778 token_teams = None
1780 if jwt_token:
1781 # Try JWT authentication first
1782 try:
1783 payload = await verify_jwt_token(jwt_token)
1784 username = payload.get("sub") or payload.get("email")
1786 if not username:
1787 return ORJSONResponse(status_code=401, content={"detail": "Invalid token"})
1789 # Check if token is revoked (if JTI exists)
1790 jti = payload.get("jti")
1791 if jti:
1792 try:
1793 is_revoked = await asyncio.to_thread(_check_token_revoked_sync, jti)
1794 if is_revoked:
1795 logger.warning(f"Admin access denied for revoked token: {username}")
1796 return self._error_response(request, root_path, 401, "Token has been revoked", "token_revoked")
1797 except Exception as revoke_error:
1798 logger.warning(f"Token revocation check failed: {revoke_error}")
1799 # Continue - don't fail auth if revocation check fails
1801 # SECURITY: Apply token scope semantics for admin paths.
1802 # Use the same token_use-aware resolution as auth.py.
1803 token_use = payload.get("token_use")
1804 if token_use == "session": # nosec B105 - Not a password; token_use is a JWT claim type
1805 is_admin = payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False)
1806 token_teams = await _resolve_teams_from_db(username, {"is_admin": is_admin})
1807 else:
1808 # API token or legacy path: embedded teams claim semantics
1809 token_teams = normalize_token_teams(payload)
1810 except Exception:
1811 # JWT validation failed, try API token
1812 token_hash = hashlib.sha256(jwt_token.encode()).hexdigest()
1813 api_token_info = await asyncio.to_thread(_lookup_api_token_sync, token_hash)
1815 if api_token_info:
1816 if api_token_info.get("expired"):
1817 return ORJSONResponse(status_code=401, content={"detail": "API token expired"})
1818 if api_token_info.get("revoked"):
1819 return ORJSONResponse(status_code=401, content={"detail": "API token has been revoked"})
1820 username = api_token_info["user_email"]
1821 logger.debug(f"Admin auth via API token: {username}")
1823 # NOTE: Basic auth is NOT supported for admin UI endpoints.
1824 # While AdminAuthMiddleware could validate Basic credentials, the admin
1825 # endpoints use get_current_user_with_permissions which requires JWT tokens.
1826 # Supporting Basic auth would require passing auth context to routes,
1827 # which increases complexity and attack surface. Use JWT or API tokens instead.
1829 if not username and is_proxy_auth_trust_active(settings):
1830 # Proxy authentication path (when MCP client auth is disabled and proxy auth is trusted)
1831 proxy_user = request.headers.get(settings.proxy_user_header)
1832 if proxy_user:
1833 username = proxy_user
1834 logger.debug(f"Admin auth via proxy header: {username}")
1836 if not username:
1837 # No authentication method succeeded - redirect to login or return 401
1838 return self._error_response(request, root_path, 401, "Authentication required")
1840 # SECURITY: Public-only tokens (teams=[]) never grant admin-path access,
1841 # even for admin identities. Admin bypass requires explicit teams=null + is_admin=true.
1842 if token_teams is not None and len(token_teams) == 0:
1843 logger.warning(f"Admin access denied for public-only token: {username}")
1844 return self._error_response(request, root_path, 403, "Admin privileges required", "admin_required")
1846 # Check if user exists, is active, and has admin permissions
1847 db = next(get_db())
1848 try:
1849 auth_service = EmailAuthService(db)
1850 user = await auth_service.get_user_by_email(username)
1852 if not user:
1853 # Platform admin bootstrap (when REQUIRE_USER_IN_DB=false)
1854 platform_admin_email = getattr(settings, "platform_admin_email", "admin@example.com")
1855 if not settings.require_user_in_db and username == platform_admin_email:
1856 logger.info(f"Platform admin bootstrap authentication for {username}")
1857 # Allow platform admin through - they have implicit admin privileges
1858 else:
1859 return self._error_response(request, root_path, 401, "User not found")
1860 else:
1861 # User exists in DB - check active status
1862 if not user.is_active:
1863 logger.warning(f"Admin access denied for disabled user: {username}")
1864 return self._error_response(request, root_path, 403, "Account is disabled", "account_disabled")
1866 # Check if user has admin permissions (either is_admin flag OR admin.* RBAC permissions)
1867 # This allows granular admin access for users with specific admin permissions.
1868 # When the request is team-scoped (?team_id=...), include team-scoped roles
1869 # so that developer/viewer roles with admin.dashboard can access the UI.
1870 permission_service = PermissionService(db)
1871 request_team_id = request.query_params.get("team_id")
1872 # Normalize to hex so hyphenated UUIDs match DB-stored hex IDs.
1873 # Fall back to raw value for non-UUID team IDs (e.g. from legacy tokens).
1874 if request_team_id:
1875 try:
1876 request_team_id = uuid.UUID(request_team_id).hex
1877 except (ValueError, AttributeError):
1878 pass # keep raw value for non-UUID token_teams
1879 # Only trust team_id if it is in the user's DB-resolved teams
1880 validated_team_id = request_team_id if (token_teams and request_team_id and request_team_id in token_teams) else None
1881 has_admin_access = await permission_service.has_admin_permission(username, team_id=validated_team_id)
1882 if not has_admin_access:
1883 logger.warning(f"Admin access denied for user without admin permissions: {username}")
1884 return self._error_response(request, root_path, 403, "Admin privileges required", "admin_required")
1885 finally:
1886 db.close()
1888 except HTTPException as e:
1889 return self._error_response(request, root_path, e.status_code, e.detail)
1890 except Exception as e:
1891 logger.error(f"Admin auth middleware error: {e}")
1892 return ORJSONResponse(status_code=500, content={"detail": "Authentication error"})
1894 # Proceed to next middleware or route
1895 return await call_next(request)
1898class MCPPathRewriteMiddleware:
1899 """
1900 Middleware that rewrites paths ending with '/mcp' to '/mcp/', after performing authentication.
1902 - Rewrites paths like '/servers/<server_id>/mcp' to '/mcp/'.
1903 - Only paths ending with '/mcp' or '/mcp/' (but not exactly '/mcp' or '/mcp/') are rewritten.
1904 - Authentication is performed before any path rewriting.
1905 - If authentication fails, the request is not processed further.
1906 - All other requests are passed through without change.
1907 - Routes through the middleware stack (including CORSMiddleware) for proper CORS preflight handling.
1909 Attributes:
1910 application (Callable): The next ASGI application to process the request.
1911 """
1913 def __init__(self, application, dispatch=None):
1914 """
1915 Initialize the middleware with the ASGI application.
1917 Args:
1918 application (Callable): The next ASGI application to handle the request.
1919 dispatch (Callable, optional): An optional dispatch function for additional middleware processing.
1921 Example:
1922 >>> import asyncio
1923 >>> from unittest.mock import AsyncMock, patch
1924 >>> app_mock = AsyncMock()
1925 >>> middleware = MCPPathRewriteMiddleware(app_mock)
1926 >>> isinstance(middleware.application, AsyncMock)
1927 True
1928 """
1929 self.application = application
1930 self.dispatch = dispatch # this can be TokenScopingMiddleware
1932 async def __call__(self, scope, receive, send):
1933 """
1934 Intercept and potentially rewrite the incoming HTTP request path.
1936 Args:
1937 scope (dict): The ASGI connection scope.
1938 receive (Callable): Awaitable that yields events from the client.
1939 send (Callable): Awaitable used to send events to the client.
1941 Examples:
1942 >>> import asyncio
1943 >>> from unittest.mock import AsyncMock, patch
1944 >>> app_mock = AsyncMock()
1945 >>> middleware = MCPPathRewriteMiddleware(app_mock)
1947 >>> # Test path rewriting for /servers/123/mcp
1948 >>> scope = { "type": "http", "path": "/servers/123/mcp", "headers": [(b"host", b"example.com")] }
1949 >>> receive = AsyncMock()
1950 >>> send = AsyncMock()
1951 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True):
1952 ... asyncio.run(middleware(scope, receive, send))
1953 >>> scope["path"]
1954 '/mcp/'
1955 >>> app_mock.assert_called()
1957 >>> # Test regular path (no rewrite)
1958 >>> scope = { "type": "http","path": "/tools","headers": [(b"host", b"example.com")] }
1959 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True):
1960 ... asyncio.run(middleware(scope, receive, send))
1961 ... scope["path"]
1962 '/tools'
1963 """
1964 if scope["type"] != "http":
1965 await self.application(scope, receive, send)
1966 return
1968 # If a dispatch (request middleware) is provided, adapt it
1969 if self.dispatch is not None:
1970 request = starletteRequest(scope, receive=receive)
1972 async def call_next(_req: starletteRequest) -> starletteResponse:
1973 """
1974 Handles the next request in the middleware chain by calling a streamable HTTP response.
1976 Args:
1977 _req (starletteRequest): The incoming request to be processed.
1979 Returns:
1980 starletteResponse: A response generated from the streamable HTTP call.
1981 """
1982 return await self._call_streamable_http(scope, receive, send)
1984 response = await self.dispatch(request, call_next)
1986 if response is None:
1987 # Either the dispatch handled the response itself,
1988 # or it blocked the request. Just return.
1989 return
1991 await response(scope, receive, send)
1992 return
1994 # Otherwise, just continue as normal
1995 await self._call_streamable_http(scope, receive, send)
1997 async def _call_streamable_http(self, scope, receive, send):
1998 """
1999 Handles the streamable HTTP request after authentication and path rewriting.
2001 If auth succeeds and path ends with /mcp, rewrites to /mcp/ and calls self.application
2002 (continuing through middleware stack including CORSMiddleware).
2004 Args:
2005 scope (dict): The ASGI connection scope containing request metadata.
2006 receive (Callable): The function to receive events from the client.
2007 send (Callable): The function to send events to the client.
2009 Example:
2010 >>> import asyncio
2011 >>> from unittest.mock import AsyncMock, patch
2012 >>> app_mock = AsyncMock()
2013 >>> middleware = MCPPathRewriteMiddleware(app_mock)
2014 >>> scope = {"type": "http", "path": "/servers/123/mcp"}
2015 >>> receive = AsyncMock()
2016 >>> send = AsyncMock()
2017 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True):
2018 ... asyncio.run(middleware._call_streamable_http(scope, receive, send))
2019 >>> app_mock.assert_called_once_with(scope, receive, send)
2020 """
2021 # Auth check first
2022 auth_ok = await streamable_http_auth(scope, receive, send)
2023 if not auth_ok:
2024 return
2026 original_path = scope.get("path", "")
2027 scope["modified_path"] = original_path
2029 # Skip rewriting for well-known URIs (RFC 9728 OAuth metadata, etc.)
2030 # These paths may end with /mcp but should not be rewritten to the MCP transport
2031 if not original_path.startswith("/.well-known/"):
2032 if (original_path.endswith("/mcp") and original_path != "/mcp") or (original_path.endswith("/mcp/") and original_path != "/mcp/"):
2033 # Rewrite to /mcp/ and continue through middleware (lets CORSMiddleware handle preflight)
2034 scope["path"] = "/mcp/"
2035 await self.application(scope, receive, send)
2036 return
2037 await self.application(scope, receive, send)
2040# Configure CORS with environment-aware origins
2041cors_origins = list(settings.allowed_origins) if settings.allowed_origins else []
2043# Ensure we never use wildcard in production
2044if settings.environment == "production" and not cors_origins:
2045 logger.warning("No CORS origins configured for production environment. CORS will be disabled.")
2046 cors_origins = []
2048app.add_middleware(
2049 CORSMiddleware,
2050 allow_origins=cors_origins,
2051 allow_credentials=settings.cors_allow_credentials,
2052 allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
2053 allow_headers=["*"],
2054 expose_headers=["Content-Length", "X-Request-ID", "X-Password-Change-Required"],
2055 max_age=600, # Cache preflight requests for 10 minutes
2056)
2058# Add response compression middleware (Brotli, Zstd, GZip)
2059# Automatically negotiates compression algorithm based on client Accept-Encoding header
2060# Priority: Brotli (best compression) > Zstd (fast) > GZip (universal fallback)
2061# Only compress responses larger than minimum_size to avoid overhead
2062# NOTE: When json_response_enabled=False (SSE mode), /mcp paths are excluded from
2063# compression to prevent buffering/breaking of streaming responses. See middleware/compression.py.
2064if settings.compression_enabled:
2065 app.add_middleware(
2066 SSEAwareCompressMiddleware,
2067 minimum_size=settings.compression_minimum_size,
2068 gzip_level=settings.compression_gzip_level,
2069 brotli_quality=settings.compression_brotli_quality,
2070 zstd_level=settings.compression_zstd_level,
2071 )
2072 logger.info(
2073 f"🗜️ Response compression enabled (SSE-aware): minimum_size={settings.compression_minimum_size}B, "
2074 f"gzip_level={settings.compression_gzip_level}, "
2075 f"brotli_quality={settings.compression_brotli_quality}, "
2076 f"zstd_level={settings.compression_zstd_level}"
2077 )
2078else:
2079 logger.info("🚫 Response compression disabled")
2081# Add security headers middleware
2082app.add_middleware(SecurityHeadersMiddleware)
2084# Add validation middleware if explicitly enabled
2085if settings.validation_middleware_enabled:
2086 app.add_middleware(ValidationMiddleware)
2087 logger.info("🔒 Input validation and output sanitization middleware enabled")
2088else:
2089 logger.info("🔒 Input validation and output sanitization middleware disabled")
2091# Add MCP Protocol Version validation middleware (validates MCP-Protocol-Version header)
2092app.add_middleware(MCPProtocolVersionMiddleware)
2094# Add token scoping middleware (only when email auth is enabled)
2095if settings.email_auth_enabled:
2096 app.add_middleware(BaseHTTPMiddleware, dispatch=token_scoping_middleware)
2097 # Add streamable HTTP middleware for /mcp routes with token scoping
2098 app.add_middleware(MCPPathRewriteMiddleware, dispatch=token_scoping_middleware)
2099else:
2100 # Add streamable HTTP middleware for /mcp routes
2101 app.add_middleware(MCPPathRewriteMiddleware)
2103# Add HTTP authentication hook middleware for plugins (before auth dependencies)
2104if plugin_manager:
2105 app.add_middleware(HttpAuthMiddleware, plugin_manager=plugin_manager)
2106 logger.info("🔌 HTTP authentication hooks enabled for plugins")
2108# Add request logging middleware FIRST (always enabled for gateway boundary logging)
2109# IMPORTANT: Must be registered BEFORE CorrelationIDMiddleware so it executes AFTER correlation ID is set
2110# Gateway boundary logging (request_started/completed) runs regardless of log_requests setting
2111# Detailed payload logging only runs if log_detailed_requests=True
2112app.add_middleware(
2113 RequestLoggingMiddleware,
2114 enable_gateway_logging=True,
2115 log_detailed_requests=settings.log_requests,
2116 log_level=settings.log_level,
2117 max_body_size=settings.log_detailed_max_body_size,
2118 log_resolve_user_identity=settings.log_resolve_user_identity,
2119 log_detailed_skip_endpoints=settings.log_detailed_skip_endpoints,
2120 log_detailed_sample_rate=settings.log_detailed_sample_rate,
2121)
2123# Add custom DocsAuthMiddleware
2124app.add_middleware(DocsAuthMiddleware)
2126# Add AdminAuthMiddleware to protect admin routes (requires admin privileges)
2127# This ensures all /admin/* routes (except login/logout) require admin status
2128app.add_middleware(AdminAuthMiddleware)
2130# Trust all proxies (or lock down with a list of host patterns)
2131app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
2133# Add correlation ID middleware if enabled
2134# Note: Registered AFTER RequestLoggingMiddleware so correlation ID is available when RequestLoggingMiddleware executes
2135if settings.correlation_id_enabled:
2136 app.add_middleware(CorrelationIDMiddleware)
2137 logger.info(f"✅ Correlation ID tracking enabled (header: {settings.correlation_id_header})")
2139# Add authentication context middleware if security logging is enabled
2140# This middleware extracts user context and logs security events (authentication attempts)
2141# Note: This is independent of observability - security logging is always important
2142if settings.security_logging_enabled:
2143 # First-Party
2144 from mcpgateway.middleware.auth_middleware import AuthContextMiddleware
2146 app.add_middleware(AuthContextMiddleware)
2147 logger.info("🔐 Authentication context middleware enabled - logging security events")
2148else:
2149 logger.info("🔐 Security event logging disabled")
2151# Add token usage logging middleware
2152# This tracks API token usage for analytics and security monitoring
2153# Note: Runs after AuthContextMiddleware so request.state.auth_method is available
2154if settings.token_usage_logging_enabled:
2155 # First-Party
2156 from mcpgateway.middleware.token_usage_middleware import TokenUsageMiddleware # noqa: E402
2158 app.add_middleware(TokenUsageMiddleware)
2159 logger.info("📊 Token usage logging middleware enabled - tracking API token usage")
2160else:
2161 logger.info("📊 Token usage logging middleware disabled")
2163# Add observability middleware if enabled
2164# Note: Middleware runs in REVERSE order (last added runs first)
2165# If AuthContextMiddleware is already registered, ObservabilityMiddleware wraps it
2166# Execution order will be: AuthContext -> Observability -> Request Handler
2167# Wire observability adapter into the plugin manager when observability is enabled
2168if settings.observability_enabled:
2169 # First-Party
2170 from mcpgateway.middleware.observability_middleware import ObservabilityMiddleware
2171 from mcpgateway.plugins.observability_adapter import ObservabilityServiceAdapter
2172 from mcpgateway.services.observability_service import ObservabilityService
2174 _service = ObservabilityService()
2175 app.add_middleware(ObservabilityMiddleware, enabled=True, service=_service)
2176 if plugin_manager:
2177 plugin_manager.observability = ObservabilityServiceAdapter(service=_service)
2178 logger.info("🔍 Observability middleware enabled - tracing include-listed requests")
2179else:
2180 logger.info("🔍 Observability middleware disabled")
2182# Database query logging middleware (for N+1 detection)
2183if settings.db_query_log_enabled:
2184 # First-Party
2185 from mcpgateway.db import engine
2186 from mcpgateway.middleware.db_query_logging import setup_query_logging
2188 setup_query_logging(app, engine)
2189 logger.info(f"📊 Database query logging enabled - logs: {settings.db_query_log_file}")
2190else:
2191 logger.debug("📊 Database query logging disabled (enable with DB_QUERY_LOG_ENABLED=true)")
2193# Set up Jinja2 templates and store in app state for later use
2194# auto_reload=False in production prevents re-parsing templates on each request (performance)
2195jinja_env = Environment(
2196 loader=FileSystemLoader(str(settings.templates_dir)),
2197 autoescape=True,
2198 auto_reload=settings.templates_auto_reload,
2199)
2202# Add custom filter to decode HTML entities for backward compatibility with old database records
2203# that were stored with HTML entities (e.g., ' instead of ')
2204# NOTE: This filter can be removed after all deployments have run the c1c2c3c4c5c6 migration,
2205# which decodes all existing HTML entities in the database. After that migration, this filter
2206# becomes a no-op since new data is stored without HTML encoding.
2207def decode_html_entities(value: str) -> str:
2208 """Decode HTML entities in strings for display.
2210 This filter handles legacy data that was stored with HTML entities.
2211 New data is stored without encoding, but this ensures old records display correctly.
2213 TEMPORARY: Can be removed after c1c2c3c4c5c6 migration has been applied to all deployments.
2215 Args:
2216 value: String that may contain HTML entities
2218 Returns:
2219 String with HTML entities decoded to their original characters
2220 """
2221 if not value:
2222 return value
2224 return html.unescape(value)
2227jinja_env.filters["decode_html"] = decode_html_entities
2230def tojson_attr(value: object) -> str:
2231 """JSON-encode a value for safe use inside double-quoted HTML attributes.
2233 Unlike the built-in ``|tojson`` filter (which returns ``Markup``, bypassing
2234 autoescape), this filter returns a plain ``str``. Jinja2 autoescape then
2235 HTML-encodes the ``"`` characters to ``"``, keeping the enclosing
2236 ``"``-delimited HTML attribute intact. The browser decodes the entities
2237 back to ``"`` before passing the value to the JS engine.
2239 Use ``|tojson_attr`` for inline event handlers (``onclick``, ``onsubmit``).
2240 Use the built-in ``|tojson`` for ``<script>`` blocks (where ``Markup`` is fine).
2242 Args:
2243 value: Any JSON-serialisable object.
2245 Returns:
2246 Plain string with JSON content (autoescape will HTML-encode it).
2247 """
2248 s = orjson.dumps(value, default=str).decode()
2249 # Same HTML-safety replacements as Jinja2's htmlsafe_json_dumps,
2250 # but we return a plain str so autoescape encodes the remaining `"`.
2251 s = s.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e").replace("'", "\\u0027")
2252 return s
2255jinja_env.filters["tojson_attr"] = tojson_attr
2257templates = Jinja2Templates(env=jinja_env)
2258if not settings.templates_auto_reload:
2259 logger.info("🎨 Template auto-reload disabled (production mode)")
2260app.state.templates = templates
2262# Store plugin manager in app state for access in routes
2263app.state.plugin_manager = plugin_manager
2265# Initialize plugin service with plugin manager
2266if plugin_manager:
2267 # First-Party
2268 from mcpgateway.services.plugin_service import get_plugin_service
2270 plugin_service = get_plugin_service()
2271 plugin_service.set_plugin_manager(plugin_manager)
2273# Create API routers
2274protocol_router = APIRouter(prefix="/protocol", tags=["Protocol"])
2275tool_router = APIRouter(prefix="/tools", tags=["Tools"])
2276resource_router = APIRouter(prefix="/resources", tags=["Resources"])
2277prompt_router = APIRouter(prefix="/prompts", tags=["Prompts"])
2278gateway_router = APIRouter(prefix="/gateways", tags=["Gateways"])
2279root_router = APIRouter(prefix="/roots", tags=["Roots"])
2280utility_router = APIRouter(tags=["Utilities"])
2281server_router = APIRouter(prefix="/servers", tags=["Servers"])
2282metrics_router = APIRouter(prefix="/metrics", tags=["Metrics"])
2283tag_router = APIRouter(prefix="/tags", tags=["Tags"])
2284export_import_router = APIRouter(tags=["Export/Import"])
2285a2a_router = APIRouter(prefix="/a2a", tags=["A2A Agents"])
2287# Basic Auth setup
2290# Database dependency
2291def get_db():
2292 """
2293 Dependency function to provide a database session.
2295 Commits the transaction on successful completion to avoid implicit rollbacks
2296 for read-only operations. Rolls back explicitly on exception.
2298 This function handles connection failures gracefully by invalidating broken
2299 connections. When a connection is broken (e.g., due to PgBouncer timeout or
2300 network issues), the rollback will fail. In this case, we invalidate the
2301 session to ensure the broken connection is discarded from the pool rather
2302 than being returned in a bad state.
2304 Yields:
2305 Session: A SQLAlchemy session object for interacting with the database.
2307 Raises:
2308 Exception: Re-raises any exception after rolling back the transaction.
2310 Ensures:
2311 The database session is closed after the request completes, even in the case of an exception.
2313 Examples:
2314 >>> # Test that get_db returns a generator
2315 >>> db_gen = get_db()
2316 >>> hasattr(db_gen, '__next__')
2317 True
2318 >>> # Test cleanup happens
2319 >>> try:
2320 ... db = next(db_gen)
2321 ... type(db).__name__
2322 ... finally:
2323 ... try:
2324 ... next(db_gen)
2325 ... except StopIteration:
2326 ... pass # Expected - generator cleanup
2327 'ResilientSession'
2328 """
2329 db = SessionLocal()
2330 try:
2331 yield db
2332 # Only commit if the transaction is still active.
2333 # The transaction can become inactive if an exception occurred during
2334 # async context manager cleanup (e.g., CancelledError during MCP session teardown).
2335 if db.is_active:
2336 db.commit()
2337 except Exception:
2338 try:
2339 # Always call rollback() in exception handler.
2340 # rollback() is safe to call even when is_active=False - it succeeds and
2341 # restores the session to a usable state. When is_active=False (e.g., after
2342 # IntegrityError), rollback() is actually REQUIRED to clear the failed state.
2343 # Skipping rollback when is_active=False would leave the session unusable.
2344 db.rollback()
2345 except Exception:
2346 # Connection is broken - invalidate to remove from pool
2347 # This handles cases like PgBouncer query_wait_timeout where
2348 # the connection is dead and rollback itself fails
2349 try:
2350 db.invalidate()
2351 except Exception:
2352 pass # nosec B110 - Best effort cleanup on connection failure
2353 raise
2354 finally:
2355 db.close()
2358async def _read_request_json(request: Request) -> Any:
2359 """Read JSON payload using orjson.
2361 Args:
2362 request: Incoming FastAPI request to read JSON from.
2364 Returns:
2365 Parsed JSON payload.
2367 Raises:
2368 HTTPException: 400 for invalid JSON bodies.
2369 """
2370 body = await request.body()
2371 if not body:
2372 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON in request body")
2373 try:
2374 return orjson.loads(body)
2375 except orjson.JSONDecodeError as exc:
2376 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON in request body") from exc
2379def require_api_key(api_key: str) -> None:
2380 """Validates the provided API key.
2382 This function checks if the provided API key matches the expected one
2383 based on the settings. If the validation fails, it raises an HTTPException
2384 with a 401 Unauthorized status.
2386 Args:
2387 api_key (str): The API key provided by the user or client.
2389 Raises:
2390 HTTPException: If the API key is invalid, a 401 Unauthorized error is raised.
2392 Examples:
2393 >>> from mcpgateway.config import settings
2394 >>> from pydantic import SecretStr
2395 >>> settings.auth_required = True
2396 >>> settings.basic_auth_user = "admin"
2397 >>> settings.basic_auth_password = SecretStr("secret")
2398 >>>
2399 >>> # Valid API key
2400 >>> require_api_key("admin:secret") # Should not raise
2401 >>>
2402 >>> # Invalid API key
2403 >>> try:
2404 ... require_api_key("wrong:key")
2405 ... except HTTPException as e:
2406 ... e.status_code
2407 401
2408 """
2409 if settings.auth_required:
2410 expected = f"{settings.basic_auth_user}:{settings.basic_auth_password.get_secret_value()}"
2411 if api_key != expected:
2412 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
2415async def invalidate_resource_cache(uri: Optional[str] = None) -> None:
2416 """
2417 Invalidates the resource cache.
2419 If a specific URI is provided, only that resource will be removed from the cache.
2420 If no URI is provided, the entire resource cache will be cleared.
2422 Args:
2423 uri (Optional[str]): The URI of the resource to invalidate from the cache. If None, the entire cache is cleared.
2425 Examples:
2426 >>> import asyncio
2427 >>> # Test clearing specific URI from cache
2428 >>> resource_cache.set("/test/resource", {"content": "test data"})
2429 >>> resource_cache.get("/test/resource") is not None
2430 True
2431 >>> asyncio.run(invalidate_resource_cache("/test/resource"))
2432 >>> resource_cache.get("/test/resource") is None
2433 True
2434 >>>
2435 >>> # Test clearing entire cache
2436 >>> resource_cache.set("/resource1", {"content": "data1"})
2437 >>> resource_cache.set("/resource2", {"content": "data2"})
2438 >>> asyncio.run(invalidate_resource_cache())
2439 >>> resource_cache.get("/resource1") is None and resource_cache.get("/resource2") is None
2440 True
2441 """
2442 if uri:
2443 resource_cache.delete(uri)
2444 else:
2445 resource_cache.clear()
2448def get_protocol_from_request(request: Request) -> str:
2449 """
2450 Return "https" or "http" based on:
2451 1) X-Forwarded-Proto (if set by a proxy)
2452 2) request.url.scheme (e.g. when Gunicorn/Uvicorn is terminating TLS)
2454 Args:
2455 request (Request): The FastAPI request object.
2457 Returns:
2458 str: The protocol used for the request, either "http" or "https".
2460 Examples:
2461 Test with X-Forwarded-Proto header (proxy scenario):
2462 >>> from mcpgateway import main
2463 >>> from fastapi import Request
2464 >>> from urllib.parse import urlparse
2465 >>>
2466 >>> # Mock request with X-Forwarded-Proto
2467 >>> scope = {
2468 ... 'type': 'http',
2469 ... 'scheme': 'http',
2470 ... 'headers': [(b'x-forwarded-proto', b'https')],
2471 ... 'server': ('testserver', 80),
2472 ... 'path': '/',
2473 ... }
2474 >>> req = Request(scope)
2475 >>> main.get_protocol_from_request(req)
2476 'https'
2478 Test with comma-separated X-Forwarded-Proto:
2479 >>> scope_multi = {
2480 ... 'type': 'http',
2481 ... 'scheme': 'http',
2482 ... 'headers': [(b'x-forwarded-proto', b'https,http')],
2483 ... 'server': ('testserver', 80),
2484 ... 'path': '/',
2485 ... }
2486 >>> req_multi = Request(scope_multi)
2487 >>> main.get_protocol_from_request(req_multi)
2488 'https'
2490 Test without X-Forwarded-Proto (direct connection):
2491 >>> scope_direct = {
2492 ... 'type': 'http',
2493 ... 'scheme': 'https',
2494 ... 'headers': [],
2495 ... 'server': ('testserver', 443),
2496 ... 'path': '/',
2497 ... }
2498 >>> req_direct = Request(scope_direct)
2499 >>> main.get_protocol_from_request(req_direct)
2500 'https'
2502 Test with HTTP direct connection:
2503 >>> scope_http = {
2504 ... 'type': 'http',
2505 ... 'scheme': 'http',
2506 ... 'headers': [],
2507 ... 'server': ('testserver', 80),
2508 ... 'path': '/',
2509 ... }
2510 >>> req_http = Request(scope_http)
2511 >>> main.get_protocol_from_request(req_http)
2512 'http'
2513 """
2514 forwarded = request.headers.get("x-forwarded-proto")
2515 if forwarded:
2516 # may be a comma-separated list; take the first
2517 return forwarded.split(",")[0].strip()
2518 return request.url.scheme
2521def update_url_protocol(request: Request) -> str:
2522 """
2523 Update the base URL protocol based on the request's scheme or forwarded headers.
2525 Args:
2526 request (Request): The FastAPI request object.
2528 Returns:
2529 str: The base URL with the correct protocol.
2531 Examples:
2532 Test URL protocol update with HTTPS proxy:
2533 >>> from mcpgateway import main
2534 >>> from fastapi import Request
2535 >>>
2536 >>> # Mock request with HTTPS forwarded proto
2537 >>> scope_https = {
2538 ... 'type': 'http',
2539 ... 'scheme': 'http',
2540 ... 'server': ('example.com', 80),
2541 ... 'path': '/',
2542 ... 'headers': [(b'x-forwarded-proto', b'https')],
2543 ... }
2544 >>> req_https = Request(scope_https)
2545 >>> url = main.update_url_protocol(req_https)
2546 >>> url.startswith('https://example.com')
2547 True
2549 Test URL protocol update with HTTP direct:
2550 >>> scope_http = {
2551 ... 'type': 'http',
2552 ... 'scheme': 'http',
2553 ... 'server': ('localhost', 8000),
2554 ... 'path': '/',
2555 ... 'headers': [],
2556 ... }
2557 >>> req_http = Request(scope_http)
2558 >>> url = main.update_url_protocol(req_http)
2559 >>> url.startswith('http://localhost:8000')
2560 True
2562 Test URL protocol update preserves host and port:
2563 >>> scope_port = {
2564 ... 'type': 'http',
2565 ... 'scheme': 'https',
2566 ... 'server': ('api.test.com', 443),
2567 ... 'path': '/',
2568 ... 'headers': [],
2569 ... }
2570 >>> req_port = Request(scope_port)
2571 >>> url = main.update_url_protocol(req_port)
2572 >>> 'api.test.com' in url and url.startswith('https://')
2573 True
2575 Test trailing slash removal:
2576 >>> # URL should not end with trailing slash
2577 >>> url = main.update_url_protocol(req_http)
2578 >>> url.endswith('/')
2579 False
2580 """
2581 parsed = urlparse(str(request.base_url))
2582 proto = get_protocol_from_request(request)
2583 new_parsed = parsed._replace(scheme=proto)
2584 # urlunparse keeps netloc and path intact
2585 return str(urlunparse(new_parsed)).rstrip("/")
2588# Protocol APIs #
2589@protocol_router.post("/initialize")
2590async def initialize(request: Request, user=Depends(get_current_user)) -> InitializeResult:
2591 """
2592 Initialize a protocol.
2594 This endpoint handles the initialization process of a protocol by accepting
2595 a JSON request body and processing it. The `require_auth` dependency ensures that
2596 the user is authenticated before proceeding.
2598 Args:
2599 request (Request): The incoming request object containing the JSON body.
2600 user (str): The authenticated user (from `require_auth` dependency).
2602 Returns:
2603 InitializeResult: The result of the initialization process.
2605 Raises:
2606 HTTPException: If the request body contains invalid JSON, a 400 Bad Request error is raised.
2607 """
2608 try:
2609 body = await _read_request_json(request)
2611 logger.debug(f"Authenticated user {user} is initializing the protocol.")
2612 return await session_registry.handle_initialize_logic(body)
2614 except orjson.JSONDecodeError:
2615 raise HTTPException(
2616 status_code=status.HTTP_400_BAD_REQUEST,
2617 detail="Invalid JSON in request body",
2618 )
2621@protocol_router.post("/ping")
2622async def ping(request: Request, user=Depends(get_current_user)) -> JSONResponse:
2623 """
2624 Handle a ping request according to the MCP specification.
2626 This endpoint expects a JSON-RPC request with the method "ping" and responds
2627 with a JSON-RPC response containing an empty result, as required by the protocol.
2629 Args:
2630 request (Request): The incoming FastAPI request.
2631 user (str): The authenticated user (dependency injection).
2633 Returns:
2634 JSONResponse: A JSON-RPC response with an empty result or an error response.
2636 Raises:
2637 HTTPException: If the request method is not "ping".
2638 """
2639 req_id: Optional[str] = None
2640 try:
2641 body: dict = await _read_request_json(request)
2642 if body.get("method") != "ping":
2643 raise HTTPException(status_code=400, detail="Invalid method")
2644 req_id = body.get("id")
2645 logger.debug(f"Authenticated user {user} sent ping request.")
2646 # Return an empty result per the MCP ping specification.
2647 response: dict = {"jsonrpc": "2.0", "id": req_id, "result": {}}
2648 return ORJSONResponse(content=response)
2649 except Exception as e:
2650 error_response: dict = {
2651 "jsonrpc": "2.0",
2652 "id": req_id, # Now req_id is always defined
2653 "error": {"code": -32603, "message": "Internal error", "data": str(e)},
2654 }
2655 return ORJSONResponse(status_code=500, content=error_response)
2658@protocol_router.post("/notifications")
2659async def handle_notification(request: Request, user=Depends(get_current_user)) -> None:
2660 """
2661 Handles incoming notifications from clients. Depending on the notification method,
2662 different actions are taken (e.g., logging initialization, cancellation, or messages).
2664 Args:
2665 request (Request): The incoming request containing the notification data.
2666 user (str): The authenticated user making the request.
2667 """
2668 body = await _read_request_json(request)
2669 logger.debug(f"User {user} sent a notification")
2670 if body.get("method") == "notifications/initialized":
2671 logger.info("Client initialized")
2672 await logging_service.notify("Client initialized", LogLevel.INFO)
2673 elif body.get("method") == "notifications/cancelled":
2674 # Note: requestId can be 0 (valid per JSON-RPC), so use 'is not None' and normalize to string
2675 raw_request_id = body.get("params", {}).get("requestId")
2676 request_id = str(raw_request_id) if raw_request_id is not None else None
2677 reason = body.get("params", {}).get("reason")
2678 logger.info(f"Request cancelled: {request_id}, reason: {reason}")
2679 # Attempt local cancellation per MCP spec
2680 if request_id is not None:
2681 await _authorize_run_cancellation(request, user, request_id, as_jsonrpc_error=False)
2682 await cancellation_service.cancel_run(request_id, reason=reason)
2683 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO)
2684 elif body.get("method") == "notifications/message":
2685 params = body.get("params", {})
2686 await logging_service.notify(
2687 params.get("data"),
2688 LogLevel(params.get("level", "info")),
2689 params.get("logger"),
2690 )
2693@protocol_router.post("/completion/complete")
2694async def handle_completion(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
2695 """
2696 Handles the completion of tasks by processing a completion request.
2698 Args:
2699 request (Request): The incoming request with completion data.
2700 db (Session): The database session used to interact with the data store.
2701 user (str): The authenticated user making the request.
2703 Returns:
2704 The result of the completion process.
2705 """
2706 body = await _read_request_json(request)
2707 logger.debug(f"User {user['email']} sent a completion request")
2708 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
2709 if is_admin and token_teams is None:
2710 user_email = None
2711 elif token_teams is None:
2712 token_teams = []
2713 return await completion_service.handle_completion(db, body, user_email=user_email, token_teams=token_teams)
2716@protocol_router.post("/sampling/createMessage")
2717async def handle_sampling(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
2718 """
2719 Handles the creation of a new message for sampling.
2721 Args:
2722 request (Request): The incoming request with sampling data.
2723 db (Session): The database session used to interact with the data store.
2724 user (str): The authenticated user making the request.
2726 Returns:
2727 The result of the message creation process.
2728 """
2729 logger.debug(f"User {user['email']} sent a sampling request")
2730 body = await _read_request_json(request)
2731 return await sampling_handler.create_message(db, body)
2734###############
2735# Server APIs #
2736###############
2737@server_router.get("", response_model=Union[List[ServerRead], CursorPaginatedServersResponse])
2738@server_router.get("/", response_model=Union[List[ServerRead], CursorPaginatedServersResponse])
2739@require_permission("servers.read")
2740async def list_servers(
2741 request: Request,
2742 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
2743 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
2744 limit: Optional[int] = Query(None, ge=0, description="Maximum number of servers to return"),
2745 include_inactive: bool = False,
2746 tags: Optional[str] = None,
2747 team_id: Optional[str] = None,
2748 visibility: Optional[str] = None,
2749 db: Session = Depends(get_db),
2750 user=Depends(get_current_user_with_permissions),
2751) -> Union[List[ServerRead], Dict[str, Any]]:
2752 """
2753 Lists servers accessible to the user, with team filtering and cursor pagination support.
2755 Args:
2756 request (Request): The incoming request object for team_id retrieval.
2757 cursor (Optional[str]): Cursor for pagination.
2758 include_pagination (bool): Include cursor pagination metadata in response.
2759 limit (Optional[int]): Maximum number of servers to return.
2760 include_inactive (bool): Whether to include inactive servers in the response.
2761 tags (Optional[str]): Comma-separated list of tags to filter by.
2762 team_id (Optional[str]): Filter by specific team ID.
2763 visibility (Optional[str]): Filter by visibility (private, team, public).
2764 db (Session): The database session used to interact with the data store.
2765 user (str): The authenticated user making the request.
2767 Returns:
2768 Union[List[ServerRead], Dict[str, Any]]: A list of server objects or paginated response with nextCursor.
2769 """
2770 # Parse tags parameter if provided
2771 tags_list = None
2772 if tags:
2773 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
2774 # Get user email for team filtering
2775 user_email = get_user_email(user)
2777 # Check team ID from token
2778 token_team_id = getattr(request.state, "team_id", None)
2779 token_teams = getattr(request.state, "token_teams", None)
2781 # Check for team ID mismatch
2782 if team_id is not None and token_team_id is not None and team_id != token_team_id:
2783 return ORJSONResponse(
2784 content={"message": "Access issue: This API token does not have the required permissions for this team."},
2785 status_code=status.HTTP_403_FORBIDDEN,
2786 )
2788 # For listing, only narrow by team_id when explicitly requested via query param.
2789 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping
2790 # (public + team resources). Auto-narrowing would exclude public servers.
2792 # SECURITY: token_teams is normalized in auth.py:
2793 # - None: admin bypass (is_admin=true with explicit null teams) - sees ALL resources
2794 # - []: public-only (missing teams or explicit empty) - sees only public
2795 # - [...]: team-scoped - sees public + teams + user's private
2796 is_admin_bypass = token_teams is None
2797 is_public_only_token = token_teams is not None and len(token_teams) == 0
2799 # Use consolidated server listing with optional team filtering
2800 # For admin bypass: pass user_email=None and token_teams=None to skip all filtering
2801 logger.debug(f"User: {user_email} requested server list with include_inactive={include_inactive}, tags={tags_list}, team_id={team_id}, visibility={visibility}")
2802 data, next_cursor = await server_service.list_servers(
2803 db=db,
2804 cursor=cursor,
2805 limit=limit,
2806 include_inactive=include_inactive,
2807 tags=tags_list,
2808 user_email=None if is_admin_bypass else user_email, # Admin bypass: no user filtering
2809 team_id=team_id,
2810 visibility="public" if is_public_only_token and not visibility else visibility,
2811 token_teams=token_teams, # None = admin bypass, [] = public-only, [...] = team-scoped
2812 )
2814 if include_pagination:
2815 return CursorPaginatedServersResponse.model_construct(servers=data, next_cursor=next_cursor)
2816 return data
2819@server_router.get("/{server_id}", response_model=ServerRead)
2820@require_permission("servers.read")
2821async def get_server(server_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> ServerRead:
2822 """
2823 Retrieves a server by its ID.
2825 Args:
2826 server_id (str): The ID of the server to retrieve.
2827 request (Request): The incoming request used for scoped access validation.
2828 db (Session): The database session used to interact with the data store.
2829 user (str): The authenticated user making the request.
2831 Returns:
2832 ServerRead: The server object with the specified ID.
2834 Raises:
2835 HTTPException: If the server is not found.
2836 """
2837 try:
2838 logger.debug(f"User {user} requested server with ID {server_id}")
2839 server = await server_service.get_server(db, server_id)
2840 _enforce_scoped_resource_access(request, db, user, f"/servers/{server_id}")
2841 return server
2842 except ServerNotFoundError as e:
2843 raise HTTPException(status_code=404, detail=str(e))
2846@server_router.post("", response_model=ServerRead, status_code=201)
2847@server_router.post("/", response_model=ServerRead, status_code=201)
2848@require_permission("servers.create")
2849async def create_server(
2850 server: ServerCreate,
2851 request: Request,
2852 team_id: Optional[str] = Body(None, description="Team ID to assign server to"),
2853 visibility: Optional[str] = Body(None, description="Server visibility: private, team, public"),
2854 db: Session = Depends(get_db),
2855 user=Depends(get_current_user_with_permissions),
2856) -> ServerRead:
2857 """
2858 Creates a new server.
2860 Args:
2861 server (ServerCreate): The data for the new server.
2862 request (Request): The incoming request object for extracting metadata.
2863 team_id (Optional[str]): Team ID to assign the server to.
2864 visibility (str): Server visibility level (private, team, public).
2865 db (Session): The database session used to interact with the data store.
2866 user (str): The authenticated user making the request.
2868 Returns:
2869 ServerRead: The created server object.
2871 Raises:
2872 HTTPException: If there is a conflict with the server name or other errors.
2873 """
2874 try:
2875 # Extract metadata from request
2876 metadata = MetadataCapture.extract_creation_metadata(request, user)
2878 # Get user email and handle team assignment
2879 user_email = get_user_email(user)
2881 token_team_id = getattr(request.state, "team_id", None)
2882 token_teams = getattr(request.state, "token_teams", None)
2884 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
2885 is_public_only_token = token_teams is not None and len(token_teams) == 0
2886 if is_public_only_token and visibility in ("team", "private"):
2887 return ORJSONResponse(
2888 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
2889 status_code=status.HTTP_403_FORBIDDEN,
2890 )
2892 # Check for team ID mismatch (only for non-public-only tokens)
2893 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
2894 return ORJSONResponse(
2895 content={"message": "Access issue: This API token does not have the required permissions for this team."},
2896 status_code=status.HTTP_403_FORBIDDEN,
2897 )
2899 # Determine final team ID (public-only tokens get no team)
2900 if is_public_only_token:
2901 team_id = None
2902 else:
2903 team_id = team_id or token_team_id
2905 logger.debug(f"User {user_email} is creating a new server for team {team_id}")
2906 result = await server_service.register_server(
2907 db,
2908 server,
2909 created_by=metadata["created_by"],
2910 created_from_ip=metadata["created_from_ip"],
2911 created_via=metadata["created_via"],
2912 created_user_agent=metadata["created_user_agent"],
2913 team_id=team_id,
2914 owner_email=user_email,
2915 visibility=visibility,
2916 )
2917 db.commit()
2918 db.close()
2919 return result
2920 except ServerNameConflictError as e:
2921 raise HTTPException(status_code=409, detail=str(e))
2922 except ServerError as e:
2923 raise HTTPException(status_code=400, detail=str(e))
2924 except ValidationError as e:
2925 logger.error(f"Validation error while creating server: {e}")
2926 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
2927 except IntegrityError as e:
2928 logger.error(f"Integrity error while creating server: {e}")
2929 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
2932@server_router.put("/{server_id}", response_model=ServerRead)
2933@require_permission("servers.update")
2934async def update_server(
2935 server_id: str,
2936 server: ServerUpdate,
2937 request: Request,
2938 db: Session = Depends(get_db),
2939 user=Depends(get_current_user_with_permissions),
2940) -> ServerRead:
2941 """
2942 Updates the information of an existing server.
2944 Args:
2945 server_id (str): The ID of the server to update.
2946 server (ServerUpdate): The updated server data.
2947 request (Request): The incoming request object containing metadata.
2948 db (Session): The database session used to interact with the data store.
2949 user (str): The authenticated user making the request.
2951 Returns:
2952 ServerRead: The updated server object.
2954 Raises:
2955 HTTPException: If the server is not found, there is a name conflict, or other errors.
2956 """
2957 try:
2958 logger.debug(f"User {user} is updating server with ID {server_id}")
2959 # Extract modification metadata
2960 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
2962 user_email: str = get_user_email(user)
2964 result = await server_service.update_server(
2965 db,
2966 server_id,
2967 server,
2968 user_email,
2969 modified_by=mod_metadata["modified_by"],
2970 modified_from_ip=mod_metadata["modified_from_ip"],
2971 modified_via=mod_metadata["modified_via"],
2972 modified_user_agent=mod_metadata["modified_user_agent"],
2973 )
2974 db.commit()
2975 db.close()
2976 return result
2977 except PermissionError as e:
2978 raise HTTPException(status_code=403, detail=str(e))
2979 except ServerNotFoundError as e:
2980 raise HTTPException(status_code=404, detail=str(e))
2981 except ServerNameConflictError as e:
2982 raise HTTPException(status_code=409, detail=str(e))
2983 except ServerError as e:
2984 raise HTTPException(status_code=400, detail=str(e))
2985 except ValidationError as e:
2986 logger.error(f"Validation error while updating server {server_id}: {e}")
2987 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
2988 except IntegrityError as e:
2989 logger.error(f"Integrity error while updating server {server_id}: {e}")
2990 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
2993@server_router.post("/{server_id}/state", response_model=ServerRead)
2994@require_permission("servers.update")
2995async def set_server_state(
2996 server_id: str,
2997 activate: bool = True,
2998 db: Session = Depends(get_db),
2999 user=Depends(get_current_user_with_permissions),
3000) -> ServerRead:
3001 """
3002 Sets the status of a server (activate or deactivate).
3004 Args:
3005 server_id (str): The ID of the server to set state for.
3006 activate (bool): Whether to activate or deactivate the server.
3007 db (Session): The database session used to interact with the data store.
3008 user (str): The authenticated user making the request.
3010 Returns:
3011 ServerRead: The server object after the status change.
3013 Raises:
3014 HTTPException: If the server is not found or there is an error.
3015 """
3016 try:
3017 user_email = user.get("email") if isinstance(user, dict) else str(user)
3018 logger.debug(f"User {user} is setting server with ID {server_id} to {'active' if activate else 'inactive'}")
3019 return await server_service.set_server_state(db, server_id, activate, user_email=user_email)
3020 except PermissionError as e:
3021 raise HTTPException(status_code=403, detail=str(e))
3022 except ServerNotFoundError as e:
3023 raise HTTPException(status_code=404, detail=str(e))
3024 except ServerLockConflictError as e:
3025 raise HTTPException(status_code=409, detail=str(e))
3026 except ServerError as e:
3027 raise HTTPException(status_code=400, detail=str(e))
3030@server_router.post("/{server_id}/toggle", response_model=ServerRead, deprecated=True)
3031@require_permission("servers.update")
3032async def toggle_server_status(
3033 server_id: str,
3034 activate: bool = True,
3035 db: Session = Depends(get_db),
3036 user=Depends(get_current_user_with_permissions),
3037) -> ServerRead:
3038 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
3040 Sets the status of a server (activate or deactivate).
3042 Args:
3043 server_id: The server ID.
3044 activate: Whether to activate (True) or deactivate (False) the server.
3045 db: Database session.
3046 user: Authenticated user context.
3048 Returns:
3049 The updated server.
3050 """
3052 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
3053 return await set_server_state(server_id, activate, db, user)
3056@server_router.delete("/{server_id}", response_model=Dict[str, str])
3057@require_permission("servers.delete")
3058async def delete_server(
3059 server_id: str,
3060 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this server"),
3061 db: Session = Depends(get_db),
3062 user=Depends(get_current_user_with_permissions),
3063) -> Dict[str, str]:
3064 """
3065 Deletes a server by its ID.
3067 Args:
3068 server_id (str): The ID of the server to delete.
3069 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this server.
3070 db (Session): The database session used to interact with the data store.
3071 user (str): The authenticated user making the request.
3073 Returns:
3074 Dict[str, str]: A success message indicating the server was deleted.
3076 Raises:
3077 HTTPException: If the server is not found or there is an error.
3078 """
3079 try:
3080 logger.debug(f"User {user} is deleting server with ID {server_id}")
3081 user_email = user.get("email") if isinstance(user, dict) else str(user)
3082 await server_service.get_server(db, server_id)
3083 await server_service.delete_server(db, server_id, user_email=user_email, purge_metrics=purge_metrics)
3084 db.commit()
3085 db.close()
3086 return {
3087 "status": "success",
3088 "message": f"Server {server_id} deleted successfully",
3089 }
3090 except PermissionError as e:
3091 raise HTTPException(status_code=403, detail=str(e))
3092 except ServerNotFoundError as e:
3093 raise HTTPException(status_code=404, detail=str(e))
3094 except ServerError as e:
3095 raise HTTPException(status_code=400, detail=str(e))
3098@server_router.get("/{server_id}/sse")
3099@require_permission("servers.use")
3100async def sse_endpoint(request: Request, server_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
3101 """
3102 Establishes a Server-Sent Events (SSE) connection for real-time updates about a server.
3104 Args:
3105 request (Request): The incoming request.
3106 server_id (str): The ID of the server for which updates are received.
3107 db (Session): The database session used for server existence and scope checks.
3108 user (str): The authenticated user making the request.
3110 Returns:
3111 The SSE response object for the established connection.
3113 Raises:
3114 HTTPException: If there is an error in establishing the SSE connection.
3115 asyncio.CancelledError: If the request is cancelled during SSE setup.
3116 """
3117 try:
3118 logger.debug(f"User {user} is establishing SSE connection for server {server_id}")
3119 await server_service.get_server(db, server_id)
3120 _enforce_scoped_resource_access(request, db, user, f"/servers/{server_id}/sse")
3122 base_url = update_url_protocol(request)
3123 server_sse_url = f"{base_url}/servers/{server_id}"
3125 # SSE transport generates its own session_id - server-initiated, not client-provided
3126 transport = SSETransport(base_url=server_sse_url)
3127 await transport.connect()
3128 await session_registry.add_session(transport.session_id, transport)
3129 await session_registry.set_session_owner(transport.session_id, get_user_email(user))
3131 # Extract auth token from request (header OR cookie, like get_current_user_with_permissions)
3132 # MUST be computed BEFORE create_sse_response to avoid race condition (Finding 1)
3133 auth_token = None
3134 auth_header = request.headers.get("authorization", "")
3135 if auth_header.lower().startswith("bearer "):
3136 auth_token = auth_header[7:]
3137 elif hasattr(request, "cookies") and request.cookies:
3138 # Cookie auth (admin UI sessions)
3139 auth_token = request.cookies.get("jwt_token") or request.cookies.get("access_token")
3141 # Extract and normalize token teams
3142 # Returns None if no JWT payload (non-JWT auth), or list if JWT exists
3143 # SECURITY: Preserve None vs [] distinction for admin bypass:
3144 # - None: unrestricted (admin keeps bypass, non-admin gets their accessible resources)
3145 # - []: public-only (admin bypass disabled)
3146 # - [...]: team-scoped access
3147 token_teams = _get_token_teams_from_request(request)
3149 # Preserve is_admin from user object (for cookie-authenticated admins)
3150 is_admin = False
3151 if hasattr(user, "is_admin"):
3152 is_admin = getattr(user, "is_admin", False)
3153 elif isinstance(user, dict):
3154 is_admin = user.get("is_admin", False) or user.get("user", {}).get("is_admin", False)
3156 # Create enriched user dict
3157 user_with_token = dict(user) if isinstance(user, dict) else {"email": getattr(user, "email", str(user))}
3158 user_with_token["auth_token"] = auth_token
3159 user_with_token["token_teams"] = token_teams # None for unrestricted, [] for public-only, [...] for team-scoped
3160 user_with_token["is_admin"] = is_admin # Preserve admin status for fallback token
3162 # Defensive cleanup callback - runs immediately on client disconnect
3163 async def on_disconnect_cleanup() -> None:
3164 """Clean up session when SSE client disconnects."""
3165 try:
3166 await session_registry.remove_session(transport.session_id)
3167 logger.debug("Defensive session cleanup completed: %s", transport.session_id)
3168 except Exception as e:
3169 logger.warning("Defensive session cleanup failed for %s: %s", transport.session_id, e)
3171 # CRITICAL: Create and register respond task BEFORE create_sse_response (Finding 1 fix)
3172 # This ensures the task exists when disconnect callback runs, preventing orphaned tasks
3173 respond_task = asyncio.create_task(session_registry.respond(server_id, user_with_token, session_id=transport.session_id))
3174 session_registry.register_respond_task(transport.session_id, respond_task)
3176 try:
3177 response = await transport.create_sse_response(request, on_disconnect_callback=on_disconnect_cleanup)
3178 except asyncio.CancelledError:
3179 # Request cancelled - still need to clean up to prevent orphaned tasks
3180 logger.debug(f"SSE request cancelled for {transport.session_id}, cleaning up")
3181 try:
3182 await session_registry.remove_session(transport.session_id)
3183 except Exception as cleanup_error:
3184 logger.warning(f"Cleanup after SSE cancellation failed: {cleanup_error}")
3185 raise # Re-raise CancelledError
3186 except Exception as sse_error:
3187 # CRITICAL: Cleanup on failure - respond task and session would be orphaned otherwise
3188 logger.error(f"create_sse_response failed for {transport.session_id}: {sse_error}")
3189 try:
3190 await session_registry.remove_session(transport.session_id)
3191 except Exception as cleanup_error:
3192 logger.warning(f"Cleanup after SSE failure also failed: {cleanup_error}")
3193 raise
3195 tasks = BackgroundTasks()
3196 tasks.add_task(session_registry.remove_session, transport.session_id)
3197 response.background = tasks
3198 logger.info(f"SSE connection established: {transport.session_id}")
3199 return response
3200 except ServerNotFoundError as e:
3201 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
3202 except HTTPException:
3203 raise
3204 except Exception as e:
3205 logger.error(f"SSE connection error: {e}")
3206 raise HTTPException(status_code=500, detail="SSE connection failed")
3209@server_router.post("/{server_id}/message")
3210@require_permission("servers.use")
3211async def message_endpoint(request: Request, server_id: str, user=Depends(get_current_user_with_permissions)):
3212 """
3213 Handles incoming messages for a specific server.
3215 Args:
3216 request (Request): The incoming message request.
3217 server_id (str): The ID of the server receiving the message.
3218 user (str): The authenticated user making the request.
3220 Returns:
3221 JSONResponse: A success status after processing the message.
3223 Raises:
3224 HTTPException: If there are errors processing the message.
3225 """
3226 try:
3227 logger.debug(f"User {user} sent a message to server {server_id}")
3228 session_id = request.query_params.get("session_id")
3229 if not session_id:
3230 logger.error("Missing session_id in message request")
3231 raise HTTPException(status_code=400, detail="Missing session_id")
3233 await _assert_session_owner_or_admin(request, user, session_id)
3235 message = await _read_request_json(request)
3237 # Check if this is an elicitation response (JSON-RPC response with result containing action)
3238 is_elicitation_response = False
3239 if "result" in message and isinstance(message.get("result"), dict):
3240 result_data = message["result"]
3241 if "action" in result_data and result_data.get("action") in ["accept", "decline", "cancel"]:
3242 # This looks like an elicitation response
3243 request_id = message.get("id")
3244 if request_id:
3245 # Try to complete the elicitation
3246 # First-Party
3247 from mcpgateway.common.models import ElicitResult # pylint: disable=import-outside-toplevel
3248 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel
3250 elicitation_service = get_elicitation_service()
3251 try:
3252 elicit_result = ElicitResult(**result_data)
3253 if elicitation_service.complete_elicitation(request_id, elicit_result):
3254 logger.info(f"Completed elicitation {request_id} from session {session_id}")
3255 is_elicitation_response = True
3256 except Exception as e:
3257 logger.warning(f"Failed to process elicitation response: {e}")
3259 # If not an elicitation response, broadcast normally
3260 if not is_elicitation_response:
3261 await session_registry.broadcast(
3262 session_id=session_id,
3263 message=message,
3264 )
3266 return ORJSONResponse(content={"status": "success"}, status_code=202)
3267 except ValueError as e:
3268 logger.error(f"Invalid message format: {e}")
3269 raise HTTPException(status_code=400, detail=str(e))
3270 except HTTPException:
3271 raise
3272 except Exception as e:
3273 logger.error(f"Message handling error: {e}")
3274 raise HTTPException(status_code=500, detail="Failed to process message")
3277@server_router.get("/{server_id}/tools", response_model=List[ToolRead])
3278@require_permission("servers.read")
3279async def server_get_tools(
3280 request: Request,
3281 server_id: str,
3282 include_inactive: bool = False,
3283 include_metrics: bool = False,
3284 db: Session = Depends(get_db),
3285 user=Depends(get_current_user_with_permissions),
3286) -> List[Dict[str, Any]]:
3287 """
3288 List tools for the server with an option to include inactive tools.
3290 This endpoint retrieves a list of tools from the database, optionally including
3291 those that are inactive. The inactive filter helps administrators manage tools
3292 that have been deactivated but not deleted from the system.
3294 Args:
3295 request (Request): FastAPI request object.
3296 server_id (str): ID of the server
3297 include_inactive (bool): Whether to include inactive tools in the results.
3298 include_metrics (bool): Whether to include metrics in the tools results.
3299 db (Session): Database session dependency.
3300 user (str): Authenticated user dependency.
3302 Returns:
3303 List[ToolRead]: A list of tool records formatted with by_alias=True.
3304 """
3305 logger.debug(f"User: {user} has listed tools for the server_id: {server_id}")
3306 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
3307 _req_email, _req_is_admin = user_email, is_admin
3308 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
3309 # Admin bypass - only when token has NO team restrictions (token_teams is None)
3310 # If token has explicit team scope (even empty [] for public-only), respect it
3311 if is_admin and token_teams is None:
3312 user_email = None
3313 token_teams = None # Admin unrestricted
3314 elif token_teams is None:
3315 token_teams = [] # Non-admin without teams = public-only (secure default)
3316 tools = await tool_service.list_server_tools(
3317 db,
3318 server_id=server_id,
3319 include_inactive=include_inactive,
3320 include_metrics=include_metrics,
3321 user_email=user_email,
3322 token_teams=token_teams,
3323 requesting_user_email=_req_email,
3324 requesting_user_is_admin=_req_is_admin,
3325 requesting_user_team_roles=_req_team_roles,
3326 )
3327 return [tool.model_dump(by_alias=True) for tool in tools]
3330@server_router.get("/{server_id}/resources", response_model=List[ResourceRead])
3331@require_permission("servers.read")
3332async def server_get_resources(
3333 request: Request,
3334 server_id: str,
3335 include_inactive: bool = False,
3336 db: Session = Depends(get_db),
3337 user=Depends(get_current_user_with_permissions),
3338) -> List[Dict[str, Any]]:
3339 """
3340 List resources for the server with an option to include inactive resources.
3342 This endpoint retrieves a list of resources from the database, optionally including
3343 those that are inactive. The inactive filter is useful for administrators who need
3344 to view or manage resources that have been deactivated but not deleted.
3346 Args:
3347 request (Request): FastAPI request object.
3348 server_id (str): ID of the server
3349 include_inactive (bool): Whether to include inactive resources in the results.
3350 db (Session): Database session dependency.
3351 user (str): Authenticated user dependency.
3353 Returns:
3354 List[ResourceRead]: A list of resource records formatted with by_alias=True.
3355 """
3356 logger.debug(f"User: {user} has listed resources for the server_id: {server_id}")
3357 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
3358 # Admin bypass - only when token has NO team restrictions (token_teams is None)
3359 # If token has explicit team scope (even empty [] for public-only), respect it
3360 if is_admin and token_teams is None:
3361 user_email = None
3362 token_teams = None # Admin unrestricted
3363 elif token_teams is None:
3364 token_teams = [] # Non-admin without teams = public-only (secure default)
3365 resources = await resource_service.list_server_resources(db, server_id=server_id, include_inactive=include_inactive, user_email=user_email, token_teams=token_teams)
3366 return [resource.model_dump(by_alias=True) for resource in resources]
3369@server_router.get("/{server_id}/prompts", response_model=List[PromptRead])
3370@require_permission("servers.read")
3371async def server_get_prompts(
3372 request: Request,
3373 server_id: str,
3374 include_inactive: bool = False,
3375 db: Session = Depends(get_db),
3376 user=Depends(get_current_user_with_permissions),
3377) -> List[Dict[str, Any]]:
3378 """
3379 List prompts for the server with an option to include inactive prompts.
3381 This endpoint retrieves a list of prompts from the database, optionally including
3382 those that are inactive. The inactive filter helps administrators see and manage
3383 prompts that have been deactivated but not deleted from the system.
3385 Args:
3386 request (Request): FastAPI request object.
3387 server_id (str): ID of the server
3388 include_inactive (bool): Whether to include inactive prompts in the results.
3389 db (Session): Database session dependency.
3390 user (str): Authenticated user dependency.
3392 Returns:
3393 List[PromptRead]: A list of prompt records formatted with by_alias=True.
3394 """
3395 logger.debug(f"User: {user} has listed prompts for the server_id: {server_id}")
3396 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
3397 # Admin bypass - only when token has NO team restrictions (token_teams is None)
3398 # If token has explicit team scope (even empty [] for public-only), respect it
3399 if is_admin and token_teams is None:
3400 user_email = None
3401 token_teams = None # Admin unrestricted
3402 elif token_teams is None:
3403 token_teams = [] # Non-admin without teams = public-only (secure default)
3404 prompts = await prompt_service.list_server_prompts(db, server_id=server_id, include_inactive=include_inactive, user_email=user_email, token_teams=token_teams)
3405 return [prompt.model_dump(by_alias=True) for prompt in prompts]
3408##################
3409# A2A Agent APIs #
3410##################
3411@a2a_router.get("", response_model=Union[List[A2AAgentRead], CursorPaginatedA2AAgentsResponse])
3412@a2a_router.get("/", response_model=Union[List[A2AAgentRead], CursorPaginatedA2AAgentsResponse])
3413@require_permission("a2a.read")
3414async def list_a2a_agents(
3415 request: Request,
3416 include_inactive: bool = False,
3417 tags: Optional[str] = None,
3418 team_id: Optional[str] = Query(None, description="Filter by team ID"),
3419 visibility: Optional[str] = Query(None, description="Filter by visibility (private, team, public)"),
3420 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
3421 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
3422 limit: Optional[int] = Query(None, description="Maximum number of agents to return"),
3423 db: Session = Depends(get_db),
3424 user=Depends(get_current_user_with_permissions),
3425) -> Union[List[A2AAgentRead], Dict[str, Any]]:
3426 """
3427 Lists A2A agents user has access to with cursor pagination and team filtering.
3429 Args:
3430 request (Request): The FastAPI request object for team_id retrieval.
3431 include_inactive (bool): Whether to include inactive agents in the response.
3432 tags (Optional[str]): Comma-separated list of tags to filter by.
3433 team_id (Optional[str]): Team ID to filter by.
3434 visibility (Optional[str]): Visibility level to filter by.
3435 cursor (Optional[str]): Cursor for pagination.
3436 include_pagination (bool): Include cursor pagination metadata in response.
3437 limit (Optional[int]): Maximum number of agents to return.
3438 db (Session): The database session used to interact with the data store.
3439 user (str): The authenticated user making the request.
3441 Returns:
3442 Union[List[A2AAgentRead], Dict[str, Any]]: A list of A2A agent objects or paginated response with nextCursor.
3444 Raises:
3445 HTTPException: If A2A service is not available.
3446 """
3447 # Parse tags parameter if provided
3448 tags_list = None
3449 if tags:
3450 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
3452 if a2a_service is None:
3453 raise HTTPException(status_code=503, detail="A2A service not available")
3455 # Get filtering context from token (respects token scope)
3456 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
3458 # Admin bypass - only when token has NO team restrictions (token_teams is None)
3459 # If token has explicit team scope (even for admins), respect it for least-privilege
3460 if is_admin and token_teams is None:
3461 user_email = None
3462 token_teams = None # Admin unrestricted
3463 elif token_teams is None:
3464 token_teams = [] # Non-admin without teams = public-only (secure default)
3466 # Check team_id from request.state (set during auth)
3467 token_team_id = getattr(request.state, "team_id", None)
3469 # Check for team ID mismatch (only applies when both are specified and token has teams)
3470 if team_id is not None and token_team_id is not None and team_id != token_team_id:
3471 return ORJSONResponse(
3472 content={"message": "Access issue: This API token does not have the required permissions for this team."},
3473 status_code=status.HTTP_403_FORBIDDEN,
3474 )
3476 # For listing, only narrow by team_id when explicitly requested via query param.
3477 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
3479 logger.debug(f"User: {user_email} requested A2A agent list with team_id={team_id}, visibility={visibility}, tags={tags_list}, cursor={cursor}")
3481 # Use consolidated agent listing with token-based team filtering
3482 data, next_cursor = await a2a_service.list_agents(
3483 db=db,
3484 cursor=cursor,
3485 include_inactive=include_inactive,
3486 tags=tags_list,
3487 limit=limit,
3488 user_email=user_email,
3489 token_teams=token_teams,
3490 team_id=team_id,
3491 visibility=visibility,
3492 )
3494 if include_pagination:
3495 return CursorPaginatedA2AAgentsResponse.model_construct(agents=data, next_cursor=next_cursor)
3496 return data
3499@a2a_router.get("/{agent_id}", response_model=A2AAgentRead)
3500@require_permission("a2a.read")
3501async def get_a2a_agent(
3502 agent_id: str,
3503 request: Request,
3504 db: Session = Depends(get_db),
3505 user=Depends(get_current_user_with_permissions),
3506) -> A2AAgentRead:
3507 """
3508 Retrieves an A2A agent by its ID.
3510 Args:
3511 agent_id (str): The ID of the agent to retrieve.
3512 request (Request): The FastAPI request object for team_id retrieval.
3513 db (Session): The database session used to interact with the data store.
3514 user (str): The authenticated user making the request.
3516 Returns:
3517 A2AAgentRead: The agent object with the specified ID.
3519 Raises:
3520 HTTPException: If the agent is not found or user lacks access.
3521 """
3522 try:
3523 logger.debug(f"User {user} requested A2A agent with ID {agent_id}")
3524 if a2a_service is None:
3525 raise HTTPException(status_code=503, detail="A2A service not available")
3527 # Get filtering context from token (respects token scope)
3528 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
3530 # Admin bypass - only when token has NO team restrictions
3531 if is_admin and token_teams is None:
3532 token_teams = None # Admin unrestricted
3533 elif token_teams is None:
3534 token_teams = [] # Non-admin without teams = public-only
3536 return await a2a_service.get_agent(
3537 db,
3538 agent_id,
3539 user_email=user_email,
3540 token_teams=token_teams,
3541 )
3542 except A2AAgentNotFoundError as e:
3543 raise HTTPException(status_code=404, detail=str(e))
3546@a2a_router.post("", response_model=A2AAgentRead, status_code=201)
3547@a2a_router.post("/", response_model=A2AAgentRead, status_code=201)
3548@require_permission("a2a.create")
3549async def create_a2a_agent(
3550 agent: A2AAgentCreate,
3551 request: Request,
3552 team_id: Optional[str] = Body(None, description="Team ID to assign agent to"),
3553 visibility: Optional[str] = Body("public", description="Agent visibility: private, team, public"),
3554 db: Session = Depends(get_db),
3555 user=Depends(get_current_user_with_permissions),
3556) -> A2AAgentRead:
3557 """
3558 Creates a new A2A agent.
3560 Args:
3561 agent (A2AAgentCreate): The data for the new agent.
3562 request (Request): The FastAPI request object for metadata extraction.
3563 team_id (Optional[str]): Team ID to assign the agent to.
3564 visibility (str): Agent visibility level (private, team, public).
3565 db (Session): The database session used to interact with the data store.
3566 user (str): The authenticated user making the request.
3568 Returns:
3569 A2AAgentRead: The created agent object.
3571 Raises:
3572 HTTPException: If there is a conflict with the agent name or other errors.
3573 """
3574 try:
3575 # Extract metadata from request
3576 metadata = MetadataCapture.extract_creation_metadata(request, user)
3578 # Get user email and handle team assignment
3579 user_email = get_user_email(user)
3581 token_team_id = getattr(request.state, "team_id", None)
3582 token_teams = getattr(request.state, "token_teams", None)
3584 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
3585 is_public_only_token = token_teams is not None and len(token_teams) == 0
3586 if is_public_only_token and visibility in ("team", "private"):
3587 return ORJSONResponse(
3588 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
3589 status_code=status.HTTP_403_FORBIDDEN,
3590 )
3592 # Check for team ID mismatch (only for non-public-only tokens)
3593 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
3594 return ORJSONResponse(
3595 content={"message": "Access issue: This API token does not have the required permissions for this team."},
3596 status_code=status.HTTP_403_FORBIDDEN,
3597 )
3599 # Determine final team ID (public-only tokens get no team)
3600 if is_public_only_token:
3601 team_id = None
3602 else:
3603 team_id = team_id or token_team_id
3605 logger.debug(f"User {user_email} is creating a new A2A agent for team {team_id}")
3606 if a2a_service is None:
3607 raise HTTPException(status_code=503, detail="A2A service not available")
3608 return await a2a_service.register_agent(
3609 db,
3610 agent,
3611 created_by=metadata["created_by"],
3612 created_from_ip=metadata["created_from_ip"],
3613 created_via=metadata["created_via"],
3614 created_user_agent=metadata["created_user_agent"],
3615 import_batch_id=metadata["import_batch_id"],
3616 federation_source=metadata["federation_source"],
3617 team_id=team_id,
3618 owner_email=user_email,
3619 visibility=visibility,
3620 )
3621 except A2AAgentNameConflictError as e:
3622 raise HTTPException(status_code=409, detail=str(e))
3623 except A2AAgentError as e:
3624 raise HTTPException(status_code=400, detail=str(e))
3625 except ValidationError as e:
3626 logger.error(f"Validation error while creating A2A agent: {e}")
3627 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
3628 except IntegrityError as e:
3629 logger.error(f"Integrity error while creating A2A agent: {e}")
3630 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
3633@a2a_router.put("/{agent_id}", response_model=A2AAgentRead)
3634@require_permission("a2a.update")
3635async def update_a2a_agent(
3636 agent_id: str,
3637 agent: A2AAgentUpdate,
3638 request: Request,
3639 db: Session = Depends(get_db),
3640 user=Depends(get_current_user_with_permissions),
3641) -> A2AAgentRead:
3642 """
3643 Updates the information of an existing A2A agent.
3645 Args:
3646 agent_id (str): The ID of the agent to update.
3647 agent (A2AAgentUpdate): The updated agent data.
3648 request (Request): The FastAPI request object for metadata extraction.
3649 db (Session): The database session used to interact with the data store.
3650 user (str): The authenticated user making the request.
3652 Returns:
3653 A2AAgentRead: The updated agent object.
3655 Raises:
3656 HTTPException: If the agent is not found, there is a name conflict, or other errors.
3657 """
3658 try:
3659 logger.debug(f"User {user} is updating A2A agent with ID {agent_id}")
3660 # Extract modification metadata
3661 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
3663 if a2a_service is None:
3664 raise HTTPException(status_code=503, detail="A2A service not available")
3665 user_email = user.get("email") if isinstance(user, dict) else str(user)
3666 return await a2a_service.update_agent(
3667 db,
3668 agent_id,
3669 agent,
3670 modified_by=mod_metadata["modified_by"],
3671 modified_from_ip=mod_metadata["modified_from_ip"],
3672 modified_via=mod_metadata["modified_via"],
3673 modified_user_agent=mod_metadata["modified_user_agent"],
3674 user_email=user_email,
3675 )
3676 except PermissionError as e:
3677 raise HTTPException(status_code=403, detail=str(e))
3678 except A2AAgentNotFoundError as e:
3679 raise HTTPException(status_code=404, detail=str(e))
3680 except A2AAgentNameConflictError as e:
3681 raise HTTPException(status_code=409, detail=str(e))
3682 except A2AAgentError as e:
3683 raise HTTPException(status_code=400, detail=str(e))
3684 except ValidationError as e:
3685 logger.error(f"Validation error while updating A2A agent {agent_id}: {e}")
3686 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
3687 except IntegrityError as e:
3688 logger.error(f"Integrity error while updating A2A agent {agent_id}: {e}")
3689 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
3692@a2a_router.post("/{agent_id}/state", response_model=A2AAgentRead)
3693@require_permission("a2a.update")
3694async def set_a2a_agent_state(
3695 agent_id: str,
3696 activate: bool = True,
3697 db: Session = Depends(get_db),
3698 user=Depends(get_current_user_with_permissions),
3699) -> A2AAgentRead:
3700 """
3701 Sets the status of an A2A agent (activate or deactivate).
3703 Args:
3704 agent_id (str): The ID of the agent to update.
3705 activate (bool): Whether to activate or deactivate the agent.
3706 db (Session): The database session used to interact with the data store.
3707 user (str): The authenticated user making the request.
3709 Returns:
3710 A2AAgentRead: The agent object after the status change.
3712 Raises:
3713 HTTPException: If the agent is not found or there is an error.
3714 """
3715 try:
3716 user_email = user.get("email") if isinstance(user, dict) else str(user)
3717 logger.debug(f"User {user} is toggling A2A agent with ID {agent_id} to {'active' if activate else 'inactive'}")
3718 if a2a_service is None:
3719 raise HTTPException(status_code=503, detail="A2A service not available")
3720 return await a2a_service.set_agent_state(db, agent_id, activate, user_email=user_email)
3721 except PermissionError as e:
3722 raise HTTPException(status_code=403, detail=str(e))
3723 except A2AAgentNotFoundError as e:
3724 raise HTTPException(status_code=404, detail=str(e))
3725 except A2AAgentError as e:
3726 raise HTTPException(status_code=400, detail=str(e))
3729@a2a_router.post("/{agent_id}/toggle", response_model=A2AAgentRead, deprecated=True)
3730@require_permission("a2a.update")
3731async def toggle_a2a_agent_status(
3732 agent_id: str,
3733 activate: bool = True,
3734 db: Session = Depends(get_db),
3735 user=Depends(get_current_user_with_permissions),
3736) -> A2AAgentRead:
3737 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
3739 Sets the status of an A2A agent (activate or deactivate).
3741 Args:
3742 agent_id: The A2A agent ID.
3743 activate: Whether to activate (True) or deactivate (False) the agent.
3744 db: Database session.
3745 user: Authenticated user context.
3747 Returns:
3748 The updated A2A agent.
3749 """
3751 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
3752 return await set_a2a_agent_state(agent_id, activate, db, user)
3755@a2a_router.delete("/{agent_id}", response_model=Dict[str, str])
3756@require_permission("a2a.delete")
3757async def delete_a2a_agent(
3758 agent_id: str,
3759 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this agent"),
3760 db: Session = Depends(get_db),
3761 user=Depends(get_current_user_with_permissions),
3762) -> Dict[str, str]:
3763 """
3764 Deletes an A2A agent by its ID.
3766 Args:
3767 agent_id (str): The ID of the agent to delete.
3768 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this agent.
3769 db (Session): The database session used to interact with the data store.
3770 user (str): The authenticated user making the request.
3772 Returns:
3773 Dict[str, str]: A success message indicating the agent was deleted.
3775 Raises:
3776 HTTPException: If the agent is not found or there is an error.
3777 """
3778 try:
3779 logger.debug(f"User {user} is deleting A2A agent with ID {agent_id}")
3780 if a2a_service is None:
3781 raise HTTPException(status_code=503, detail="A2A service not available")
3782 user_email = user.get("email") if isinstance(user, dict) else str(user)
3783 await a2a_service.delete_agent(db, agent_id, user_email=user_email, purge_metrics=purge_metrics)
3784 return {
3785 "status": "success",
3786 "message": f"A2A Agent {agent_id} deleted successfully",
3787 }
3788 except PermissionError as e:
3789 raise HTTPException(status_code=403, detail=str(e))
3790 except A2AAgentNotFoundError as e:
3791 raise HTTPException(status_code=404, detail=str(e))
3792 except A2AAgentError as e:
3793 raise HTTPException(status_code=400, detail=str(e))
3796@a2a_router.post("/{agent_name}/invoke", response_model=Dict[str, Any])
3797@require_permission("a2a.invoke")
3798async def invoke_a2a_agent(
3799 agent_name: str,
3800 request: Request,
3801 parameters: Dict[str, Any] = Body(default_factory=dict),
3802 interaction_type: str = Body(default="query"),
3803 db: Session = Depends(get_db),
3804 user=Depends(get_current_user_with_permissions),
3805) -> Dict[str, Any]:
3806 """
3807 Invokes an A2A agent with the specified parameters.
3809 Args:
3810 agent_name (str): The name of the agent to invoke.
3811 request (Request): The FastAPI request object for team_id retrieval.
3812 parameters (Dict[str, Any]): Parameters for the agent interaction.
3813 interaction_type (str): Type of interaction (query, execute, etc.).
3814 db (Session): The database session used to interact with the data store.
3815 user (str): The authenticated user making the request.
3817 Returns:
3818 Dict[str, Any]: The response from the A2A agent.
3820 Raises:
3821 HTTPException: If the agent is not found, user lacks access, or there is an error during invocation.
3822 """
3823 try:
3824 logger.debug(f"User {user} is invoking A2A agent '{agent_name}' with type '{interaction_type}'")
3825 if a2a_service is None:
3826 raise HTTPException(status_code=503, detail="A2A service not available")
3828 # Get filtering context from token (respects token scope)
3829 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
3831 # Admin bypass - only when token has NO team restrictions
3832 if is_admin and token_teams is None:
3833 token_teams = None # Admin unrestricted
3834 elif token_teams is None:
3835 token_teams = [] # Non-admin without teams = public-only
3837 user_id = None
3838 if isinstance(user, dict):
3839 user_id = str(user.get("id") or user.get("sub") or user_email)
3840 else:
3841 user_id = str(user)
3843 return await a2a_service.invoke_agent(
3844 db,
3845 agent_name,
3846 parameters,
3847 interaction_type,
3848 user_id=user_id,
3849 user_email=user_email,
3850 token_teams=token_teams,
3851 )
3852 except A2AAgentNotFoundError as e:
3853 raise HTTPException(status_code=404, detail=str(e))
3854 except A2AAgentError as e:
3855 raise HTTPException(status_code=400, detail=str(e))
3858#############
3859# Tool APIs #
3860#############
3861@tool_router.get("", response_model=Union[List[ToolRead], CursorPaginatedToolsResponse])
3862@tool_router.get("/", response_model=Union[List[ToolRead], CursorPaginatedToolsResponse])
3863@require_permission("tools.read")
3864async def list_tools(
3865 request: Request,
3866 cursor: Optional[str] = None,
3867 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
3868 limit: Optional[int] = Query(None, ge=0, description="Maximum number of tools to return. 0 means all (no limit). Default uses pagination_default_page_size."),
3869 include_inactive: bool = False,
3870 tags: Optional[str] = None,
3871 team_id: Optional[str] = Query(None, description="Filter by team ID"),
3872 visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, public"),
3873 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID"),
3874 db: Session = Depends(get_db),
3875 apijsonpath: JsonPathModifier = Body(None),
3876 user=Depends(get_current_user_with_permissions),
3877) -> Union[List[ToolRead], List[Dict], Dict]:
3878 """List all registered tools with team-based filtering and pagination support.
3880 Args:
3881 request (Request): The FastAPI request object for team_id retrieval
3882 cursor: Pagination cursor for fetching the next set of results
3883 include_pagination: Whether to include cursor pagination metadata in the response
3884 limit: Maximum number of tools to return. Use 0 for all tools (no limit).
3885 If not specified, uses pagination_default_page_size (default: 50).
3886 include_inactive: Whether to include inactive tools in the results
3887 tags: Comma-separated list of tags to filter by (e.g., "api,data")
3888 team_id: Optional team ID to filter tools by specific team
3889 visibility: Optional visibility filter (private, team, public)
3890 gateway_id: Optional gateway ID to filter tools by specific gateway
3891 db: Database session
3892 apijsonpath: JSON path modifier to filter or transform the response
3893 user: Authenticated user with permissions
3895 Returns:
3896 List of tools or modified result based on jsonpath
3897 """
3899 # Parse tags parameter if provided
3900 tags_list = None
3901 if tags:
3902 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
3904 # Get filtering context from token (respects token scope)
3905 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
3906 # Capture original identity for header masking (before admin bypass modifies user_email)
3907 _req_email, _req_is_admin = user_email, is_admin
3909 # Admin bypass - only when token has NO team restrictions (token_teams is None)
3910 # If token has explicit team scope (even for admins), respect it for least-privilege
3911 if is_admin and token_teams is None:
3912 user_email = None
3913 token_teams = None # Admin unrestricted
3914 elif token_teams is None:
3915 token_teams = [] # Non-admin without teams = public-only (secure default)
3917 # Check team_id from request.state (set during auth)
3918 token_team_id = getattr(request.state, "team_id", None)
3920 # Check for team ID mismatch (only applies when both are specified and token has teams)
3921 if team_id is not None and token_team_id is not None and team_id != token_team_id:
3922 return ORJSONResponse(
3923 content={"message": "Access issue: This API token does not have the required permissions for this team."},
3924 status_code=status.HTTP_403_FORBIDDEN,
3925 )
3927 # For listing, only narrow by team_id when explicitly requested via query param.
3928 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
3930 # Use unified list_tools() with token-based team filtering
3931 # Always apply visibility filtering based on token scope
3932 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
3933 data, next_cursor = await tool_service.list_tools(
3934 db=db,
3935 cursor=cursor,
3936 include_inactive=include_inactive,
3937 tags=tags_list,
3938 gateway_id=gateway_id,
3939 limit=limit,
3940 user_email=user_email,
3941 team_id=team_id,
3942 visibility=visibility,
3943 token_teams=token_teams,
3944 requesting_user_email=_req_email,
3945 requesting_user_is_admin=_req_is_admin,
3946 requesting_user_team_roles=_req_team_roles,
3947 )
3948 # Release transaction before response serialization
3949 db.commit()
3950 db.close()
3952 if apijsonpath is None:
3953 if include_pagination:
3954 return CursorPaginatedToolsResponse.model_construct(tools=data, next_cursor=next_cursor)
3955 return data
3957 tools_dict_list = [tool.to_dict(use_alias=True) for tool in data]
3959 return jsonpath_modifier(tools_dict_list, apijsonpath.jsonpath, apijsonpath.mapping)
3962@tool_router.post("", response_model=ToolRead)
3963@tool_router.post("/", response_model=ToolRead)
3964@require_permission("tools.create")
3965async def create_tool(
3966 tool: ToolCreate,
3967 request: Request,
3968 team_id: Optional[str] = Body(None, description="Team ID to assign tool to"),
3969 db: Session = Depends(get_db),
3970 user=Depends(get_current_user_with_permissions),
3971) -> ToolRead:
3972 """
3973 Creates a new tool in the system with team assignment support.
3975 Args:
3976 tool (ToolCreate): The data needed to create the tool.
3977 request (Request): The FastAPI request object for metadata extraction.
3978 team_id (Optional[str]): Team ID to assign the tool to.
3979 db (Session): The database session dependency.
3980 user: The authenticated user making the request.
3982 Returns:
3983 ToolRead: The created tool data.
3985 Raises:
3986 HTTPException: If the tool name already exists or other validation errors occur.
3987 """
3988 try:
3989 # Extract metadata from request
3990 metadata = MetadataCapture.extract_creation_metadata(request, user)
3992 # Get user email and handle team assignment
3993 user_email = get_user_email(user)
3995 token_team_id = getattr(request.state, "team_id", None)
3996 token_teams = getattr(request.state, "token_teams", None)
3998 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
3999 is_public_only_token = token_teams is not None and len(token_teams) == 0
4000 if is_public_only_token and tool.visibility in ("team", "private"):
4001 return ORJSONResponse(
4002 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
4003 status_code=status.HTTP_403_FORBIDDEN,
4004 )
4006 # Check for team ID mismatch (only for non-public-only tokens)
4007 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
4008 return ORJSONResponse(
4009 content={"message": "Access issue: This API token does not have the required permissions for this team."},
4010 status_code=status.HTTP_403_FORBIDDEN,
4011 )
4013 # Determine final team ID (public-only tokens get no team)
4014 if is_public_only_token:
4015 team_id = None
4016 else:
4017 team_id = team_id or token_team_id
4019 logger.debug(f"User {user_email} is creating a new tool for team {team_id}")
4020 result = await tool_service.register_tool(
4021 db,
4022 tool,
4023 created_by=metadata["created_by"],
4024 created_from_ip=metadata["created_from_ip"],
4025 created_via=metadata["created_via"],
4026 created_user_agent=metadata["created_user_agent"],
4027 import_batch_id=metadata["import_batch_id"],
4028 federation_source=metadata["federation_source"],
4029 team_id=team_id,
4030 owner_email=user_email,
4031 visibility=tool.visibility,
4032 )
4033 db.commit()
4034 db.close()
4035 return result
4036 except Exception as ex:
4037 logger.error(f"Error while creating tool: {ex}")
4038 if isinstance(ex, ToolNameConflictError):
4039 if not ex.enabled and ex.tool_id:
4040 raise HTTPException(
4041 status_code=status.HTTP_409_CONFLICT,
4042 detail=f"Tool name already exists but is inactive. Consider activating it with ID: {ex.tool_id}",
4043 )
4044 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(ex))
4045 if isinstance(ex, (ValidationError, ValueError)):
4046 logger.error(f"Validation error while creating tool: {ex}")
4047 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(ex))
4048 if isinstance(ex, IntegrityError):
4049 logger.error(f"Integrity error while creating tool: {ex}")
4050 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(ex))
4051 if isinstance(ex, ToolError):
4052 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex))
4053 logger.error(f"Unexpected error while creating tool: {ex}")
4054 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while creating the tool")
4057@tool_router.get("/{tool_id}", response_model=Union[ToolRead, Dict])
4058@require_permission("tools.read")
4059async def get_tool(
4060 tool_id: str,
4061 request: Request,
4062 db: Session = Depends(get_db),
4063 user=Depends(get_current_user_with_permissions),
4064 apijsonpath: JsonPathModifier = Body(None),
4065) -> Union[ToolRead, Dict]:
4066 """
4067 Retrieve a tool by ID, optionally applying a JSONPath post-filter.
4069 Args:
4070 tool_id: The numeric ID of the tool.
4071 request: The incoming HTTP request.
4072 db: Active SQLAlchemy session (dependency).
4073 user: Authenticated username (dependency).
4074 apijsonpath: Optional JSON-Path modifier supplied in the body.
4076 Returns:
4077 The raw ``ToolRead`` model **or** a JSON-transformed ``dict`` if
4078 a JSONPath filter/mapping was supplied.
4080 Raises:
4081 HTTPException: If the tool does not exist or the transformation fails.
4082 """
4083 try:
4084 logger.debug(f"User {user} is retrieving tool with ID {tool_id}")
4085 _req_email, _, _req_is_admin = _get_rpc_filter_context(request, user)
4086 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
4087 data = await tool_service.get_tool(db, tool_id, requesting_user_email=_req_email, requesting_user_is_admin=_req_is_admin, requesting_user_team_roles=_req_team_roles)
4088 _enforce_scoped_resource_access(request, db, user, f"/tools/{tool_id}")
4089 if apijsonpath is None:
4090 return data
4092 data_dict = data.to_dict(use_alias=True)
4094 return jsonpath_modifier(data_dict, apijsonpath.jsonpath, apijsonpath.mapping)
4095 except HTTPException:
4096 raise
4097 except Exception as e:
4098 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
4101@tool_router.put("/{tool_id}", response_model=ToolRead)
4102@require_permission("tools.update")
4103async def update_tool(
4104 tool_id: str,
4105 tool: ToolUpdate,
4106 request: Request,
4107 db: Session = Depends(get_db),
4108 user=Depends(get_current_user_with_permissions),
4109) -> ToolRead:
4110 """
4111 Updates an existing tool with new data.
4113 Args:
4114 tool_id (str): The ID of the tool to update.
4115 tool (ToolUpdate): The updated tool information.
4116 request (Request): The FastAPI request object for metadata extraction.
4117 db (Session): The database session dependency.
4118 user (str): The authenticated user making the request.
4120 Returns:
4121 ToolRead: The updated tool data.
4123 Raises:
4124 HTTPException: If an error occurs during the update.
4125 """
4126 try:
4127 # Get current tool to extract current version
4128 current_tool = db.get(DbTool, tool_id)
4129 current_version = getattr(current_tool, "version", 0) if current_tool else 0
4131 # Extract modification metadata
4132 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, current_version)
4134 logger.debug(f"User {user} is updating tool with ID {tool_id}")
4135 user_email = user.get("email") if isinstance(user, dict) else str(user)
4136 result = await tool_service.update_tool(
4137 db,
4138 tool_id,
4139 tool,
4140 modified_by=mod_metadata["modified_by"],
4141 modified_from_ip=mod_metadata["modified_from_ip"],
4142 modified_via=mod_metadata["modified_via"],
4143 modified_user_agent=mod_metadata["modified_user_agent"],
4144 user_email=user_email,
4145 )
4146 db.commit()
4147 db.close()
4148 return result
4149 except Exception as ex:
4150 if isinstance(ex, PermissionError):
4151 raise HTTPException(status_code=403, detail=str(ex))
4152 if isinstance(ex, ToolNotFoundError):
4153 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ex))
4154 if isinstance(ex, ValidationError):
4155 logger.error(f"Validation error while updating tool: {ex}")
4156 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(ex))
4157 if isinstance(ex, IntegrityError):
4158 logger.error(f"Integrity error while updating tool: {ex}")
4159 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(ex))
4160 if isinstance(ex, ToolError):
4161 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex))
4162 logger.error(f"Unexpected error while updating tool: {ex}")
4163 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the tool")
4166@tool_router.delete("/{tool_id}")
4167@require_permission("tools.delete")
4168async def delete_tool(
4169 tool_id: str,
4170 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this tool"),
4171 db: Session = Depends(get_db),
4172 user=Depends(get_current_user_with_permissions),
4173) -> Dict[str, str]:
4174 """
4175 Permanently deletes a tool by ID.
4177 Args:
4178 tool_id (str): The ID of the tool to delete.
4179 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this tool.
4180 db (Session): The database session dependency.
4181 user (str): The authenticated user making the request.
4183 Returns:
4184 Dict[str, str]: A confirmation message upon successful deletion.
4186 Raises:
4187 HTTPException: If an error occurs during deletion.
4188 """
4189 try:
4190 logger.debug(f"User {user} is deleting tool with ID {tool_id}")
4191 user_email = user.get("email") if isinstance(user, dict) else str(user)
4192 await tool_service.delete_tool(db, tool_id, user_email=user_email, purge_metrics=purge_metrics)
4193 db.commit()
4194 db.close()
4195 return {"status": "success", "message": f"Tool {tool_id} permanently deleted"}
4196 except PermissionError as e:
4197 raise HTTPException(status_code=403, detail=str(e))
4198 except ToolNotFoundError as e:
4199 raise HTTPException(status_code=404, detail=str(e))
4200 except Exception as e:
4201 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
4204@tool_router.post("/{tool_id}/state")
4205@require_permission("tools.update")
4206async def set_tool_state(
4207 tool_id: str,
4208 activate: bool = True,
4209 db: Session = Depends(get_db),
4210 user=Depends(get_current_user_with_permissions),
4211) -> Dict[str, Any]:
4212 """
4213 Activates or deactivates a tool.
4215 Args:
4216 tool_id (str): The ID of the tool to update.
4217 activate (bool): Whether to activate (`True`) or deactivate (`False`) the tool.
4218 db (Session): The database session dependency.
4219 user (str): The authenticated user making the request.
4221 Returns:
4222 Dict[str, Any]: The status, message, and updated tool data.
4224 Raises:
4225 HTTPException: If an error occurs during state change.
4226 """
4227 try:
4228 logger.debug(f"User {user} is setting tool state for ID {tool_id} to {'active' if activate else 'inactive'}")
4229 user_email = user.get("email") if isinstance(user, dict) else str(user)
4230 tool = await tool_service.set_tool_state(db, tool_id, activate, reachable=activate, user_email=user_email)
4231 return {
4232 "status": "success",
4233 "message": f"Tool {tool_id} {'activated' if activate else 'deactivated'}",
4234 "tool": tool.model_dump(),
4235 }
4236 except PermissionError as e:
4237 raise HTTPException(status_code=403, detail=str(e))
4238 except ToolNotFoundError as e:
4239 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
4240 except ToolLockConflictError as e:
4241 raise HTTPException(status_code=409, detail=str(e))
4242 except Exception as e:
4243 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
4246@tool_router.post("/{tool_id}/toggle", deprecated=True)
4247@require_permission("tools.update")
4248async def toggle_tool_status(
4249 tool_id: str,
4250 activate: bool = True,
4251 db: Session = Depends(get_db),
4252 user=Depends(get_current_user_with_permissions),
4253) -> Dict[str, Any]:
4254 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
4256 Activates or deactivates a tool.
4258 Args:
4259 tool_id: The tool ID.
4260 activate: Whether to activate (True) or deactivate (False) the tool.
4261 db: Database session.
4262 user: Authenticated user context.
4264 Returns:
4265 Status message with tool state.
4266 """
4268 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
4269 return await set_tool_state(tool_id, activate, db, user)
4272#################
4273# Resource APIs #
4274#################
4275# --- Resource templates endpoint - MUST come before variable paths ---
4276@resource_router.get("/templates/list", response_model=ListResourceTemplatesResult)
4277@require_permission("resources.read")
4278async def list_resource_templates(
4279 request: Request,
4280 db: Session = Depends(get_db),
4281 include_inactive: bool = False,
4282 tags: Optional[str] = None,
4283 visibility: Optional[str] = None,
4284 user=Depends(get_current_user_with_permissions),
4285) -> ListResourceTemplatesResult:
4286 """
4287 List all available resource templates.
4289 Args:
4290 request (Request): The FastAPI request object for team_id retrieval.
4291 db (Session): Database session.
4292 user (str): Authenticated user.
4293 include_inactive (bool): Whether to include inactive resources.
4294 tags (Optional[str]): Comma-separated list of tags to filter by.
4295 visibility (Optional[str]): Filter by visibility (private, team, public).
4297 Returns:
4298 ListResourceTemplatesResult: A paginated list of resource templates.
4299 """
4300 logger.info(f"User {user} requested resource templates")
4302 # Parse tags parameter if provided
4303 tags_list = None
4304 if tags:
4305 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
4307 # Get filtering context from token (respects token scope)
4308 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4310 # Admin bypass - only when token has NO team restrictions
4311 if is_admin and token_teams is None:
4312 token_teams = None # Admin unrestricted
4313 elif token_teams is None:
4314 token_teams = [] # Non-admin without teams = public-only
4316 resource_templates = await resource_service.list_resource_templates(
4317 db,
4318 user_email=user_email,
4319 token_teams=token_teams,
4320 include_inactive=include_inactive,
4321 tags=tags_list,
4322 visibility=visibility,
4323 )
4324 # For simplicity, we're not implementing real pagination here
4325 return ListResourceTemplatesResult(_meta={}, resource_templates=resource_templates, next_cursor=None) # No pagination for now
4328@resource_router.post("/{resource_id}/state")
4329@require_permission("resources.update")
4330async def set_resource_state(
4331 resource_id: str,
4332 activate: bool = True,
4333 db: Session = Depends(get_db),
4334 user=Depends(get_current_user_with_permissions),
4335) -> Dict[str, Any]:
4336 """
4337 Activate or deactivate a resource by its ID.
4339 Args:
4340 resource_id (str): The ID of the resource.
4341 activate (bool): True to activate, False to deactivate.
4342 db (Session): Database session.
4343 user (str): Authenticated user.
4345 Returns:
4346 Dict[str, Any]: Status message and updated resource data.
4348 Raises:
4349 HTTPException: If toggling fails.
4350 """
4351 logger.debug(f"User {user} is toggling resource with ID {resource_id} to {'active' if activate else 'inactive'}")
4352 try:
4353 user_email = user.get("email") if isinstance(user, dict) else str(user)
4354 resource = await resource_service.set_resource_state(db, resource_id, activate, user_email=user_email)
4355 return {
4356 "status": "success",
4357 "message": f"Resource {resource_id} {'activated' if activate else 'deactivated'}",
4358 "resource": resource.model_dump(),
4359 }
4360 except PermissionError as e:
4361 raise HTTPException(status_code=403, detail=str(e))
4362 except ResourceNotFoundError as e:
4363 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
4364 except ResourceLockConflictError as e:
4365 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
4366 except Exception as e:
4367 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
4370@resource_router.post("/{resource_id}/toggle", deprecated=True)
4371@require_permission("resources.update")
4372async def toggle_resource_status(
4373 resource_id: str,
4374 activate: bool = True,
4375 db: Session = Depends(get_db),
4376 user=Depends(get_current_user_with_permissions),
4377) -> Dict[str, Any]:
4378 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
4380 Activate or deactivate a resource by its ID.
4382 Args:
4383 resource_id: The resource ID.
4384 activate: Whether to activate (True) or deactivate (False) the resource.
4385 db: Database session.
4386 user: Authenticated user context.
4388 Returns:
4389 Status message with resource state.
4390 """
4392 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
4393 return await set_resource_state(resource_id, activate, db, user)
4396@resource_router.get("", response_model=Union[List[ResourceRead], CursorPaginatedResourcesResponse])
4397@resource_router.get("/", response_model=Union[List[ResourceRead], CursorPaginatedResourcesResponse])
4398@require_permission("resources.read")
4399async def list_resources(
4400 request: Request,
4401 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
4402 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
4403 limit: Optional[int] = Query(None, ge=0, description="Maximum number of resources to return"),
4404 include_inactive: bool = False,
4405 tags: Optional[str] = None,
4406 team_id: Optional[str] = None,
4407 visibility: Optional[str] = None,
4408 db: Session = Depends(get_db),
4409 user=Depends(get_current_user_with_permissions),
4410) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
4411 """
4412 Retrieve a list of resources accessible to the user, with team filtering and cursor pagination support.
4414 Args:
4415 request (Request): The FastAPI request object for team_id retrieval
4416 cursor (Optional[str]): Cursor for pagination.
4417 include_pagination (bool): Include cursor pagination metadata in response.
4418 limit (Optional[int]): Maximum number of resources to return.
4419 include_inactive (bool): Whether to include inactive resources.
4420 tags (Optional[str]): Comma-separated list of tags to filter by.
4421 team_id (Optional[str]): Filter by specific team ID.
4422 visibility (Optional[str]): Filter by visibility (private, team, public).
4423 db (Session): Database session.
4424 user (str): Authenticated user.
4426 Returns:
4427 Union[List[ResourceRead], Dict[str, Any]]: List of resources or paginated response with nextCursor.
4428 """
4429 # Parse tags parameter if provided
4430 tags_list = None
4431 if tags:
4432 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
4434 # Get filtering context from token (respects token scope)
4435 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4437 # Admin bypass - only when token has NO team restrictions (token_teams is None)
4438 # If token has explicit team scope (even for admins), respect it for least-privilege
4439 if is_admin and token_teams is None:
4440 user_email = None
4441 token_teams = None # Admin unrestricted
4442 elif token_teams is None:
4443 token_teams = [] # Non-admin without teams = public-only (secure default)
4445 # Check team_id from request.state (set during auth)
4446 token_team_id = getattr(request.state, "team_id", None)
4448 # Check for team ID mismatch (only applies when both are specified and token has teams)
4449 if team_id is not None and token_team_id is not None and team_id != token_team_id:
4450 return ORJSONResponse(
4451 content={"message": "Access issue: This API token does not have the required permissions for this team."},
4452 status_code=status.HTTP_403_FORBIDDEN,
4453 )
4455 # For listing, only narrow by team_id when explicitly requested via query param.
4456 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
4458 # Use unified list_resources() with token-based team filtering
4459 # Always apply visibility filtering based on token scope
4460 logger.debug(f"User {user_email} requested resource list with cursor {cursor}, include_inactive={include_inactive}, tags={tags_list}, team_id={team_id}, visibility={visibility}")
4461 data, next_cursor = await resource_service.list_resources(
4462 db=db,
4463 cursor=cursor,
4464 limit=limit,
4465 include_inactive=include_inactive,
4466 tags=tags_list,
4467 user_email=user_email,
4468 team_id=team_id,
4469 visibility=visibility,
4470 token_teams=token_teams,
4471 )
4472 # Release transaction before response serialization
4473 db.commit()
4474 db.close()
4476 if include_pagination:
4477 return CursorPaginatedResourcesResponse.model_construct(resources=data, next_cursor=next_cursor)
4478 return data
4481@resource_router.post("", response_model=ResourceRead)
4482@resource_router.post("/", response_model=ResourceRead)
4483@require_permission("resources.create")
4484async def create_resource(
4485 resource: ResourceCreate,
4486 request: Request,
4487 team_id: Optional[str] = Body(None, description="Team ID to assign resource to"),
4488 visibility: Optional[str] = Body("public", description="Resource visibility: private, team, public"),
4489 db: Session = Depends(get_db),
4490 user=Depends(get_current_user_with_permissions),
4491) -> ResourceRead:
4492 """
4493 Create a new resource.
4495 Args:
4496 resource (ResourceCreate): Data for the new resource.
4497 request (Request): FastAPI request object for metadata extraction.
4498 team_id (Optional[str]): Team ID to assign the resource to.
4499 visibility (str): Resource visibility level (private, team, public).
4500 db (Session): Database session.
4501 user (str): Authenticated user.
4503 Returns:
4504 ResourceRead: The created resource.
4506 Raises:
4507 HTTPException: On conflict or validation errors or IntegrityError.
4508 """
4509 try:
4510 # Extract metadata from request
4511 metadata = MetadataCapture.extract_creation_metadata(request, user)
4513 # Get user email and handle team assignment
4514 user_email = get_user_email(user)
4516 token_team_id = getattr(request.state, "team_id", None)
4517 token_teams = getattr(request.state, "token_teams", None)
4519 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
4520 is_public_only_token = token_teams is not None and len(token_teams) == 0
4521 if is_public_only_token and visibility in ("team", "private"):
4522 return ORJSONResponse(
4523 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
4524 status_code=status.HTTP_403_FORBIDDEN,
4525 )
4527 # Check for team ID mismatch (only for non-public-only tokens)
4528 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
4529 return ORJSONResponse(
4530 content={"message": "Access issue: This API token does not have the required permissions for this team."},
4531 status_code=status.HTTP_403_FORBIDDEN,
4532 )
4534 # Determine final team ID (public-only tokens get no team)
4535 if is_public_only_token:
4536 team_id = None
4537 else:
4538 team_id = team_id or token_team_id
4540 logger.debug(f"User {user_email} is creating a new resource for team {team_id}")
4541 result = await resource_service.register_resource(
4542 db,
4543 resource,
4544 created_by=metadata["created_by"],
4545 created_from_ip=metadata["created_from_ip"],
4546 created_via=metadata["created_via"],
4547 created_user_agent=metadata["created_user_agent"],
4548 import_batch_id=metadata["import_batch_id"],
4549 federation_source=metadata["federation_source"],
4550 team_id=team_id,
4551 owner_email=user_email,
4552 visibility=visibility,
4553 )
4554 db.commit()
4555 db.close()
4556 return result
4557 except ResourceURIConflictError as e:
4558 raise HTTPException(status_code=409, detail=str(e))
4559 except ResourceError as e:
4560 raise HTTPException(status_code=400, detail=str(e))
4561 except ValidationError as e:
4562 # Handle validation errors from Pydantic
4563 logger.error(f"Validation error while creating resource: {e}")
4564 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
4565 except IntegrityError as e:
4566 logger.error(f"Integrity error while creating resource: {e}")
4567 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
4570@resource_router.get("/{resource_id}")
4571@require_permission("resources.read")
4572async def read_resource(resource_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Any:
4573 """
4574 Read a resource by its ID with plugin support.
4576 Args:
4577 resource_id (str): ID of the resource.
4578 request (Request): FastAPI request object for context.
4579 db (Session): Database session.
4580 user (str): Authenticated user.
4582 Returns:
4583 Any: The content of the resource.
4585 Raises:
4586 HTTPException: If the resource cannot be found or read.
4587 """
4588 # Get request ID from headers or generate one
4589 request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
4590 server_id = request.headers.get("X-Server-ID")
4592 logger.debug(f"User {user} requested resource with ID {resource_id} (request_id: {request_id})")
4594 # NOTE: Removed endpoint-level cache to prevent authorization bypass
4595 # The cache was checked before access control, allowing unauthorized users
4596 # to access cached private resources. Service layer handles caching safely.
4598 # Get plugin contexts from request.state for cross-hook sharing
4599 plugin_context_table = getattr(request.state, "plugin_context_table", None)
4600 plugin_global_context = getattr(request.state, "plugin_global_context", None)
4602 try:
4603 # Extract user email and admin status for authorization
4604 user_email = get_user_email(user)
4605 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False
4607 # Admin bypass: pass user=None to trigger unrestricted access
4608 # Non-admin: pass user_email and let service look up teams
4609 auth_user_email = None if is_admin else user_email
4611 # Call service with context for plugin support
4612 content = await resource_service.read_resource(
4613 db,
4614 resource_id=resource_id,
4615 request_id=request_id,
4616 user=auth_user_email,
4617 server_id=server_id,
4618 token_teams=None, # Admin: bypass; Non-admin: lookup teams
4619 plugin_context_table=plugin_context_table,
4620 plugin_global_context=plugin_global_context,
4621 )
4622 _enforce_scoped_resource_access(request, db, user, f"/resources/{resource_id}")
4623 # Release transaction before response serialization
4624 db.commit()
4625 db.close()
4626 except (ResourceNotFoundError, ResourceError) as exc:
4627 # Translate to FastAPI HTTP error
4628 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
4630 # NOTE: Removed cache.set() - see cache removal comment above
4631 # Ensure a plain JSON-serializable structure
4632 try:
4633 # First-Party
4634 from mcpgateway.common.models import ResourceContent, TextContent # pylint: disable=import-outside-toplevel
4636 # If already a ResourceContent, serialize directly
4637 if isinstance(content, ResourceContent):
4638 return content.model_dump()
4640 # If TextContent, wrap into resource envelope with text
4641 if isinstance(content, TextContent):
4642 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": content.text}
4643 except Exception:
4644 pass # nosec B110 - Intentionally continue with fallback resource content handling
4646 if isinstance(content, bytes):
4647 return {"type": "resource", "id": resource_id, "uri": content.uri, "blob": content.decode("utf-8", errors="ignore")}
4648 if isinstance(content, str):
4649 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": content}
4651 # Objects with a 'text' attribute (e.g., mocks) – best-effort mapping
4652 if hasattr(content, "text"):
4653 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": getattr(content, "text")}
4655 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": str(content)}
4658@resource_router.get("/{resource_id}/info", response_model=ResourceRead)
4659@require_permission("resources.read")
4660async def get_resource_info(
4661 resource_id: str,
4662 request: Request,
4663 include_inactive: bool = Query(False, description="Include inactive resources"),
4664 db: Session = Depends(get_db),
4665 user=Depends(get_current_user_with_permissions),
4666) -> ResourceRead:
4667 """
4668 Get resource metadata by ID.
4670 Returns the resource metadata including the enabled status. This endpoint
4671 is different from GET /resources/{resource_id} which returns the resource content.
4673 Args:
4674 resource_id (str): ID of the resource.
4675 request (Request): Incoming request context used for scope enforcement.
4676 include_inactive (bool): Whether to include inactive resources.
4677 db (Session): Database session.
4678 user (str): Authenticated user.
4680 Returns:
4681 ResourceRead: The resource metadata including enabled status.
4683 Raises:
4684 HTTPException: If the resource is not found.
4685 """
4686 try:
4687 logger.debug(f"User {user} requested resource info for ID {resource_id}")
4688 result = await resource_service.get_resource_by_id(db, resource_id, include_inactive=include_inactive)
4689 _enforce_scoped_resource_access(request, db, user, f"/resources/{resource_id}")
4690 return result
4691 except ResourceNotFoundError as e:
4692 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
4695@resource_router.put("/{resource_id}", response_model=ResourceRead)
4696@require_permission("resources.update")
4697async def update_resource(
4698 resource_id: str,
4699 resource: ResourceUpdate,
4700 request: Request,
4701 db: Session = Depends(get_db),
4702 user=Depends(get_current_user_with_permissions),
4703) -> ResourceRead:
4704 """
4705 Update a resource identified by its ID.
4707 Args:
4708 resource_id (str): ID of the resource.
4709 resource (ResourceUpdate): New resource data.
4710 request (Request): The FastAPI request object for metadata extraction.
4711 db (Session): Database session.
4712 user (str): Authenticated user.
4714 Returns:
4715 ResourceRead: The updated resource.
4717 Raises:
4718 HTTPException: If the resource is not found or update fails.
4719 """
4720 try:
4721 logger.debug(f"User {user} is updating resource with ID {resource_id}")
4722 # Extract modification metadata
4723 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
4725 user_email = user.get("email") if isinstance(user, dict) else str(user)
4726 result = await resource_service.update_resource(
4727 db,
4728 resource_id,
4729 resource,
4730 modified_by=mod_metadata["modified_by"],
4731 modified_from_ip=mod_metadata["modified_from_ip"],
4732 modified_via=mod_metadata["modified_via"],
4733 modified_user_agent=mod_metadata["modified_user_agent"],
4734 user_email=user_email,
4735 )
4736 except PermissionError as e:
4737 raise HTTPException(status_code=403, detail=str(e))
4738 except ResourceNotFoundError as e:
4739 raise HTTPException(status_code=404, detail=str(e))
4740 except ValidationError as e:
4741 logger.error(f"Validation error while updating resource {resource_id}: {e}")
4742 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
4743 except IntegrityError as e:
4744 logger.error(f"Integrity error while updating resource {resource_id}: {e}")
4745 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
4746 except ResourceURIConflictError as e:
4747 raise HTTPException(status_code=409, detail=str(e))
4748 db.commit()
4749 db.close()
4750 await invalidate_resource_cache(resource_id)
4751 return result
4754@resource_router.delete("/{resource_id}")
4755@require_permission("resources.delete")
4756async def delete_resource(
4757 resource_id: str,
4758 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this resource"),
4759 db: Session = Depends(get_db),
4760 user=Depends(get_current_user_with_permissions),
4761) -> Dict[str, str]:
4762 """
4763 Delete a resource by its ID.
4765 Args:
4766 resource_id (str): ID of the resource to delete.
4767 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this resource.
4768 db (Session): Database session.
4769 user (str): Authenticated user.
4771 Returns:
4772 Dict[str, str]: Status message indicating deletion success.
4774 Raises:
4775 HTTPException: If the resource is not found or deletion fails.
4776 """
4777 try:
4778 logger.debug(f"User {user} is deleting resource with id {resource_id}")
4779 user_email = user.get("email") if isinstance(user, dict) else str(user)
4780 await resource_service.delete_resource(db, resource_id, user_email=user_email, purge_metrics=purge_metrics)
4781 db.commit()
4782 db.close()
4783 await invalidate_resource_cache(resource_id)
4784 return {"status": "success", "message": f"Resource {resource_id} deleted"}
4785 except PermissionError as e:
4786 raise HTTPException(status_code=403, detail=str(e))
4787 except ResourceNotFoundError as e:
4788 raise HTTPException(status_code=404, detail=str(e))
4789 except ResourceError as e:
4790 raise HTTPException(status_code=400, detail=str(e))
4793@resource_router.post("/subscribe")
4794@require_permission("resources.read")
4795async def subscribe_resource(request: Request, user=Depends(get_current_user_with_permissions)) -> StreamingResponse:
4796 """
4797 Subscribe to server-sent events (SSE) for a specific resource.
4799 Args:
4800 request (Request): Incoming HTTP request.
4801 user (str): Authenticated user.
4803 Returns:
4804 StreamingResponse: A streaming response with event updates.
4805 """
4806 logger.debug(f"User {user} is subscribing to resource")
4807 user_email, token_teams = _get_scoped_resource_access_context(request, user)
4808 return StreamingResponse(resource_service.subscribe_events(user_email=user_email, token_teams=token_teams), media_type="text/event-stream")
4811###############
4812# Prompt APIs #
4813###############
4814@prompt_router.post("/{prompt_id}/state")
4815@require_permission("prompts.update")
4816async def set_prompt_state(
4817 prompt_id: str,
4818 activate: bool = True,
4819 db: Session = Depends(get_db),
4820 user=Depends(get_current_user_with_permissions),
4821) -> Dict[str, Any]:
4822 """
4823 Set the activation status of a prompt.
4825 Args:
4826 prompt_id: ID of the prompt to update.
4827 activate: True to activate, False to deactivate.
4828 db: Database session.
4829 user: Authenticated user.
4831 Returns:
4832 Status message and updated prompt details.
4834 Raises:
4835 HTTPException: If the state change fails (e.g., prompt not found or database error); emitted with *400 Bad Request* status and an error message.
4836 """
4837 logger.debug(f"User: {user} requested state change for prompt {prompt_id}, activate={activate}")
4838 try:
4839 user_email = user.get("email") if isinstance(user, dict) else str(user)
4840 prompt = await prompt_service.set_prompt_state(db, prompt_id, activate, user_email=user_email)
4841 return {
4842 "status": "success",
4843 "message": f"Prompt {prompt_id} {'activated' if activate else 'deactivated'}",
4844 "prompt": prompt.model_dump(),
4845 }
4846 except PermissionError as e:
4847 raise HTTPException(status_code=403, detail=str(e))
4848 except PromptNotFoundError as e:
4849 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
4850 except PromptLockConflictError as e:
4851 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
4852 except Exception as e:
4853 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
4856@prompt_router.post("/{prompt_id}/toggle", deprecated=True)
4857@require_permission("prompts.update")
4858async def toggle_prompt_status(
4859 prompt_id: str,
4860 activate: bool = True,
4861 db: Session = Depends(get_db),
4862 user=Depends(get_current_user_with_permissions),
4863) -> Dict[str, Any]:
4864 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
4866 Set the activation status of a prompt.
4868 Args:
4869 prompt_id: The prompt ID.
4870 activate: Whether to activate (True) or deactivate (False) the prompt.
4871 db: Database session.
4872 user: Authenticated user context.
4874 Returns:
4875 Status message with prompt state.
4876 """
4878 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
4879 return await set_prompt_state(prompt_id, activate, db, user)
4882@prompt_router.get("", response_model=Union[List[PromptRead], CursorPaginatedPromptsResponse])
4883@prompt_router.get("/", response_model=Union[List[PromptRead], CursorPaginatedPromptsResponse])
4884@require_permission("prompts.read")
4885async def list_prompts(
4886 request: Request,
4887 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
4888 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
4889 limit: Optional[int] = Query(None, ge=0, description="Maximum number of prompts to return"),
4890 include_inactive: bool = False,
4891 tags: Optional[str] = None,
4892 team_id: Optional[str] = None,
4893 visibility: Optional[str] = None,
4894 db: Session = Depends(get_db),
4895 user=Depends(get_current_user_with_permissions),
4896) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
4897 """
4898 List prompts accessible to the user, with team filtering and cursor pagination support.
4900 Args:
4901 request (Request): The FastAPI request object for team_id retrieval
4902 cursor (Optional[str]): Cursor for pagination.
4903 include_pagination (bool): Include cursor pagination metadata in response.
4904 limit (Optional[int]): Maximum number of prompts to return.
4905 include_inactive: Include inactive prompts.
4906 tags: Comma-separated list of tags to filter by.
4907 team_id: Filter by specific team ID.
4908 visibility: Filter by visibility (private, team, public).
4909 db: Database session.
4910 user: Authenticated user.
4912 Returns:
4913 Union[List[Dict[str, Any]], Dict[str, Any]]: List of prompt records or paginated response with nextCursor.
4914 """
4915 # Parse tags parameter if provided
4916 tags_list = None
4917 if tags:
4918 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
4920 # Get filtering context from token (respects token scope)
4921 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4923 # Admin bypass - only when token has NO team restrictions (token_teams is None)
4924 # If token has explicit team scope (even for admins), respect it for least-privilege
4925 if is_admin and token_teams is None:
4926 user_email = None
4927 token_teams = None # Admin unrestricted
4928 elif token_teams is None:
4929 token_teams = [] # Non-admin without teams = public-only (secure default)
4931 # Check team_id from request.state (set during auth)
4932 token_team_id = getattr(request.state, "team_id", None)
4934 # Check for team ID mismatch (only applies when both are specified and token has teams)
4935 if team_id is not None and token_team_id is not None and team_id != token_team_id:
4936 return ORJSONResponse(
4937 content={"message": "Access issue: This API token does not have the required permissions for this team."},
4938 status_code=status.HTTP_403_FORBIDDEN,
4939 )
4941 # For listing, only narrow by team_id when explicitly requested via query param.
4942 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
4944 # Use consolidated prompt listing with token-based team filtering
4945 # Always apply visibility filtering based on token scope
4946 logger.debug(f"User: {user_email} requested prompt list with include_inactive={include_inactive}, cursor={cursor}, tags={tags_list}, team_id={team_id}, visibility={visibility}")
4947 data, next_cursor = await prompt_service.list_prompts(
4948 db=db,
4949 cursor=cursor,
4950 limit=limit,
4951 include_inactive=include_inactive,
4952 tags=tags_list,
4953 user_email=user_email,
4954 team_id=team_id,
4955 visibility=visibility,
4956 token_teams=token_teams,
4957 )
4958 # Release transaction before response serialization
4959 db.commit()
4960 db.close()
4962 if include_pagination:
4963 return CursorPaginatedPromptsResponse.model_construct(prompts=data, next_cursor=next_cursor)
4964 return data
4967@prompt_router.post("", response_model=PromptRead)
4968@prompt_router.post("/", response_model=PromptRead)
4969@require_permission("prompts.create")
4970async def create_prompt(
4971 prompt: PromptCreate,
4972 request: Request,
4973 team_id: Optional[str] = Body(None, description="Team ID to assign prompt to"),
4974 visibility: Optional[str] = Body("public", description="Prompt visibility: private, team, public"),
4975 db: Session = Depends(get_db),
4976 user=Depends(get_current_user_with_permissions),
4977) -> PromptRead:
4978 """
4979 Create a new prompt.
4981 Args:
4982 prompt (PromptCreate): Payload describing the prompt to create.
4983 request (Request): The FastAPI request object for metadata extraction.
4984 team_id (Optional[str]): Team ID to assign the prompt to.
4985 visibility (str): Prompt visibility level (private, team, public).
4986 db (Session): Active SQLAlchemy session.
4987 user (str): Authenticated username.
4989 Returns:
4990 PromptRead: The newly-created prompt.
4992 Raises:
4993 HTTPException: * **409 Conflict** - another prompt with the same name already exists.
4994 * **400 Bad Request** - validation or persistence error raised
4995 by :pyclass:`~mcpgateway.services.prompt_service.PromptService`.
4996 """
4997 try:
4998 # Extract metadata from request
4999 metadata = MetadataCapture.extract_creation_metadata(request, user)
5001 # Get user email and handle team assignment
5002 user_email = get_user_email(user)
5004 token_team_id = getattr(request.state, "team_id", None)
5005 token_teams = getattr(request.state, "token_teams", None)
5007 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
5008 is_public_only_token = token_teams is not None and len(token_teams) == 0
5009 if is_public_only_token and visibility in ("team", "private"):
5010 return ORJSONResponse(
5011 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
5012 status_code=status.HTTP_403_FORBIDDEN,
5013 )
5015 # Check for team ID mismatch (only for non-public-only tokens)
5016 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
5017 return ORJSONResponse(
5018 content={"message": "Access issue: This API token does not have the required permissions for this team."},
5019 status_code=status.HTTP_403_FORBIDDEN,
5020 )
5022 # Determine final team ID (public-only tokens get no team)
5023 if is_public_only_token:
5024 team_id = None
5025 else:
5026 team_id = team_id or token_team_id
5028 logger.debug(f"User {user_email} is creating a new prompt for team {team_id}")
5029 result = await prompt_service.register_prompt(
5030 db,
5031 prompt,
5032 created_by=metadata["created_by"],
5033 created_from_ip=metadata["created_from_ip"],
5034 created_via=metadata["created_via"],
5035 created_user_agent=metadata["created_user_agent"],
5036 import_batch_id=metadata["import_batch_id"],
5037 federation_source=metadata["federation_source"],
5038 team_id=team_id,
5039 owner_email=user_email,
5040 visibility=visibility,
5041 )
5042 db.commit()
5043 db.close()
5044 return result
5045 except Exception as e:
5046 if isinstance(e, PromptNameConflictError):
5047 # If the prompt name already exists, return a 409 Conflict error
5048 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
5049 if isinstance(e, PromptError):
5050 # If there is a general prompt error, return a 400 Bad Request error
5051 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5052 if isinstance(e, ValidationError):
5053 # If there is a validation error, return a 422 Unprocessable Entity error
5054 logger.error(f"Validation error while creating prompt: {e}")
5055 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(e))
5056 if isinstance(e, IntegrityError):
5057 # If there is an integrity error, return a 409 Conflict error
5058 logger.error(f"Integrity error while creating prompt: {e}")
5059 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(e))
5060 # For any other unexpected errors, return a 500 Internal Server Error
5061 logger.error(f"Unexpected error while creating prompt: {e}")
5062 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while creating the prompt")
5065@prompt_router.post("/{prompt_id}")
5066@require_permission("prompts.read")
5067async def get_prompt(
5068 request: Request,
5069 prompt_id: str,
5070 args: Dict[str, str] = Body({}),
5071 db: Session = Depends(get_db),
5072 user=Depends(get_current_user_with_permissions),
5073) -> Any:
5074 """Get a prompt by prompt_id with arguments.
5076 This implements the prompts/get functionality from the MCP spec,
5077 which requires a POST request with arguments in the body.
5080 Args:
5081 request: FastAPI request object.
5082 prompt_id: ID of the prompt.
5083 args: Template arguments.
5084 db: Database session.
5085 user: Authenticated user.
5087 Returns:
5088 Rendered prompt or metadata.
5090 Raises:
5091 Exception: Re-raised if not a handled exception type.
5092 """
5093 logger.debug(f"User: {user} requested prompt: {prompt_id} with args={args}")
5095 # Get plugin contexts from request.state for cross-hook sharing
5096 plugin_context_table = getattr(request.state, "plugin_context_table", None)
5097 plugin_global_context = getattr(request.state, "plugin_global_context", None)
5099 # Extract user email, admin status, and server_id for authorization
5100 user_email = get_user_email(user)
5101 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False
5102 server_id = request.headers.get("X-Server-ID")
5104 # Admin bypass: pass user=None to trigger unrestricted access
5105 # Non-admin: pass user_email and let service look up teams
5106 auth_user_email = None if is_admin else user_email
5108 try:
5109 PromptExecuteArgs(args=args)
5110 result = await prompt_service.get_prompt(
5111 db,
5112 prompt_id,
5113 args,
5114 user=auth_user_email,
5115 server_id=server_id,
5116 token_teams=None, # Admin: bypass; Non-admin: lookup teams
5117 plugin_context_table=plugin_context_table,
5118 plugin_global_context=plugin_global_context,
5119 )
5120 logger.debug(f"Prompt execution successful for '{prompt_id}'")
5121 except Exception as ex:
5122 logger.error(f"Could not retrieve prompt {prompt_id}: {ex}")
5123 if isinstance(ex, PluginViolationError):
5124 # Return the actual plugin violation message
5125 return ORJSONResponse(content={"message": ex.message, "details": str(ex.violation) if hasattr(ex, "violation") else None}, status_code=422)
5126 if isinstance(ex, (ValueError, PromptError)):
5127 # Return the actual error message
5128 return ORJSONResponse(content={"message": str(ex)}, status_code=422)
5129 raise
5131 return result
5134@prompt_router.get("/{prompt_id}")
5135@require_permission("prompts.read")
5136async def get_prompt_no_args(
5137 request: Request,
5138 prompt_id: str,
5139 db: Session = Depends(get_db),
5140 user=Depends(get_current_user_with_permissions),
5141) -> Any:
5142 """Get a prompt by ID without arguments.
5144 This endpoint is for convenience when no arguments are needed.
5146 Args:
5147 request: FastAPI request object.
5148 prompt_id: The ID of the prompt to retrieve
5149 db: Database session
5150 user: Authenticated user
5152 Returns:
5153 The prompt template information
5155 Raises:
5156 HTTPException: 404 if prompt not found, 403 if permission denied.
5157 """
5158 logger.debug(f"User: {user} requested prompt: {prompt_id} with no arguments")
5160 # Get plugin contexts from request.state for cross-hook sharing
5161 plugin_context_table = getattr(request.state, "plugin_context_table", None)
5162 plugin_global_context = getattr(request.state, "plugin_global_context", None)
5164 # Extract user email, admin status, and server_id for authorization
5165 user_email = get_user_email(user)
5166 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False
5167 server_id = request.headers.get("X-Server-ID")
5169 # Admin bypass: pass user=None to trigger unrestricted access
5170 # Non-admin: pass user_email and let service look up teams
5171 auth_user_email = None if is_admin else user_email
5173 try:
5174 return await prompt_service.get_prompt(
5175 db,
5176 prompt_id,
5177 {},
5178 user=auth_user_email,
5179 server_id=server_id,
5180 token_teams=None, # Admin: bypass; Non-admin: lookup teams
5181 plugin_context_table=plugin_context_table,
5182 plugin_global_context=plugin_global_context,
5183 )
5184 except PromptNotFoundError as e:
5185 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5186 except PermissionError as e:
5187 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
5190@prompt_router.put("/{prompt_id}", response_model=PromptRead)
5191@require_permission("prompts.update")
5192async def update_prompt(
5193 prompt_id: str,
5194 prompt: PromptUpdate,
5195 request: Request,
5196 db: Session = Depends(get_db),
5197 user=Depends(get_current_user_with_permissions),
5198) -> PromptRead:
5199 """
5200 Update (overwrite) an existing prompt definition.
5202 Args:
5203 prompt_id (str): Identifier of the prompt to update.
5204 prompt (PromptUpdate): New prompt content and metadata.
5205 request (Request): The FastAPI request object for metadata extraction.
5206 db (Session): Active SQLAlchemy session.
5207 user (str): Authenticated username.
5209 Returns:
5210 PromptRead: The updated prompt object.
5212 Raises:
5213 HTTPException: * **409 Conflict** - a different prompt with the same *name* already exists and is still active.
5214 * **400 Bad Request** - validation or persistence error raised by :pyclass:`~mcpgateway.services.prompt_service.PromptService`.
5215 """
5216 logger.debug(f"User: {user} requested to update prompt: {prompt_id} with data={prompt}")
5217 try:
5218 # Extract modification metadata
5219 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
5221 user_email = user.get("email") if isinstance(user, dict) else str(user)
5222 result = await prompt_service.update_prompt(
5223 db,
5224 prompt_id,
5225 prompt,
5226 modified_by=mod_metadata["modified_by"],
5227 modified_from_ip=mod_metadata["modified_from_ip"],
5228 modified_via=mod_metadata["modified_via"],
5229 modified_user_agent=mod_metadata["modified_user_agent"],
5230 user_email=user_email,
5231 )
5232 db.commit()
5233 db.close()
5234 return result
5235 except Exception as e:
5236 if isinstance(e, PermissionError):
5237 raise HTTPException(status_code=403, detail=str(e))
5238 if isinstance(e, PromptNotFoundError):
5239 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5240 if isinstance(e, ValidationError):
5241 logger.error(f"Validation error while updating prompt: {e}")
5242 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(e))
5243 if isinstance(e, IntegrityError):
5244 logger.error(f"Integrity error while updating prompt: {e}")
5245 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(e))
5246 if isinstance(e, PromptNameConflictError):
5247 # If the prompt name already exists, return a 409 Conflict error
5248 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
5249 if isinstance(e, PromptError):
5250 # If there is a general prompt error, return a 400 Bad Request error
5251 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5252 # For any other unexpected errors, return a 500 Internal Server Error
5253 logger.error(f"Unexpected error while updating prompt: {e}")
5254 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the prompt")
5257@prompt_router.delete("/{prompt_id}")
5258@require_permission("prompts.delete")
5259async def delete_prompt(
5260 prompt_id: str,
5261 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this prompt"),
5262 db: Session = Depends(get_db),
5263 user=Depends(get_current_user_with_permissions),
5264) -> Dict[str, str]:
5265 """
5266 Delete a prompt by ID.
5268 Args:
5269 prompt_id: ID of the prompt.
5270 purge_metrics: Whether to delete raw + hourly rollup metrics for this prompt.
5271 db: Database session.
5272 user: Authenticated user.
5274 Returns:
5275 Status message.
5277 Raises:
5278 HTTPException: If the prompt is not found, a prompt error occurs, or an unexpected error occurs during deletion.
5279 """
5280 logger.debug(f"User: {user} requested deletion of prompt {prompt_id}")
5281 try:
5282 user_email = user.get("email") if isinstance(user, dict) else str(user)
5283 await prompt_service.delete_prompt(db, prompt_id, user_email=user_email, purge_metrics=purge_metrics)
5284 db.commit()
5285 db.close()
5286 return {"status": "success", "message": f"Prompt {prompt_id} deleted"}
5287 except Exception as e:
5288 if isinstance(e, PermissionError):
5289 raise HTTPException(status_code=403, detail=str(e))
5290 if isinstance(e, PromptNotFoundError):
5291 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5292 if isinstance(e, PromptError):
5293 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5294 logger.error(f"Unexpected error while deleting prompt {prompt_id}: {e}")
5295 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while deleting the prompt")
5297 # except PromptNotFoundError as e:
5298 # return {"status": "error", "message": str(e)}
5299 # except PromptError as e:
5300 # return {"status": "error", "message": str(e)}
5303################
5304# Gateway APIs #
5305################
5306@gateway_router.post("/{gateway_id}/state")
5307@require_permission("gateways.update")
5308async def set_gateway_state(
5309 gateway_id: str,
5310 activate: bool = True,
5311 db: Session = Depends(get_db),
5312 user=Depends(get_current_user_with_permissions),
5313) -> Dict[str, Any]:
5314 """
5315 Set the activation status of a gateway.
5317 Args:
5318 gateway_id (str): String ID of the gateway to update.
5319 activate (bool): ``True`` to activate, ``False`` to deactivate.
5320 db (Session): Active SQLAlchemy session.
5321 user (str): Authenticated username.
5323 Returns:
5324 Dict[str, Any]: A dict containing the operation status, a message, and the updated gateway object.
5326 Raises:
5327 HTTPException: Returned with **400 Bad Request** if the state change fails (e.g., the gateway does not exist or the database raises an unexpected error).
5328 """
5329 logger.debug(f"User '{user}' requested state change for gateway {gateway_id}, activate={activate}")
5330 try:
5331 user_email = user.get("email") if isinstance(user, dict) else str(user)
5332 gateway = await gateway_service.set_gateway_state(
5333 db,
5334 gateway_id,
5335 activate,
5336 user_email=user_email,
5337 )
5338 return {
5339 "status": "success",
5340 "message": f"Gateway {gateway_id} {'activated' if activate else 'deactivated'}",
5341 "gateway": gateway.model_dump(),
5342 }
5343 except PermissionError as e:
5344 raise HTTPException(status_code=403, detail=str(e))
5345 except GatewayNotFoundError as e:
5346 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5347 except Exception as e:
5348 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5351@gateway_router.post("/{gateway_id}/toggle", deprecated=True)
5352@require_permission("gateways.update")
5353async def toggle_gateway_status(
5354 gateway_id: str,
5355 activate: bool = True,
5356 db: Session = Depends(get_db),
5357 user=Depends(get_current_user_with_permissions),
5358) -> Dict[str, Any]:
5359 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
5361 Set the activation status of a gateway.
5363 Args:
5364 gateway_id: The gateway ID.
5365 activate: Whether to activate (True) or deactivate (False) the gateway.
5366 db: Database session.
5367 user: Authenticated user context.
5369 Returns:
5370 Status message with gateway state.
5371 """
5373 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
5374 return await set_gateway_state(gateway_id, activate, db, user)
5377@gateway_router.get("", response_model=Union[List[GatewayRead], CursorPaginatedGatewaysResponse])
5378@gateway_router.get("/", response_model=Union[List[GatewayRead], CursorPaginatedGatewaysResponse])
5379@require_permission("gateways.read")
5380async def list_gateways(
5381 request: Request,
5382 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
5383 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
5384 limit: Optional[int] = Query(None, ge=0, description="Maximum number of gateways to return"),
5385 include_inactive: bool = False,
5386 team_id: Optional[str] = Query(None, description="Filter by team ID"),
5387 visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, public"),
5388 db: Session = Depends(get_db),
5389 user=Depends(get_current_user_with_permissions),
5390) -> Union[List[GatewayRead], Dict[str, Any]]:
5391 """
5392 List all gateways with cursor pagination support.
5394 Args:
5395 request (Request): The FastAPI request object for team_id retrieval
5396 cursor (Optional[str]): Cursor for pagination.
5397 include_pagination (bool): Include cursor pagination metadata in response.
5398 limit (Optional[int]): Maximum number of gateways to return.
5399 include_inactive: Include inactive gateways.
5400 team_id (Optional): Filter by specific team ID.
5401 visibility (Optional): Filter by visibility (private, team, public).
5402 db: Database session.
5403 user: Authenticated user.
5405 Returns:
5406 Union[List[GatewayRead], Dict[str, Any]]: List of gateway records or paginated response with nextCursor.
5407 """
5408 logger.debug(f"User '{user}' requested list of gateways with include_inactive={include_inactive}")
5410 user_email = get_user_email(user)
5412 # Check team_id from token
5413 token_team_id = getattr(request.state, "team_id", None)
5414 token_teams = getattr(request.state, "token_teams", None)
5416 # Check for team ID mismatch
5417 if team_id is not None and token_team_id is not None and team_id != token_team_id:
5418 return ORJSONResponse(
5419 content={"message": "Access issue: This API token does not have the required permissions for this team."},
5420 status_code=status.HTTP_403_FORBIDDEN,
5421 )
5423 # For listing, only narrow by team_id when explicitly requested via query param.
5424 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
5426 # SECURITY: token_teams is normalized in auth.py:
5427 # - None: admin bypass (is_admin=true with explicit null teams) - sees ALL resources
5428 # - []: public-only (missing teams or explicit empty) - sees only public
5429 # - [...]: team-scoped - sees public + teams + user's private
5430 is_admin_bypass = token_teams is None
5431 is_public_only_token = token_teams is not None and len(token_teams) == 0
5433 # Use consolidated gateway listing with optional team filtering
5434 # For admin bypass: pass user_email=None and token_teams=None to skip all filtering
5435 logger.debug(f"User: {user_email} requested gateway list with include_inactive={include_inactive}, team_id={team_id}, visibility={visibility}")
5436 data, next_cursor = await gateway_service.list_gateways(
5437 db=db,
5438 cursor=cursor,
5439 limit=limit,
5440 include_inactive=include_inactive,
5441 user_email=None if is_admin_bypass else user_email, # Admin bypass: no user filtering
5442 team_id=team_id,
5443 visibility="public" if is_public_only_token and not visibility else visibility,
5444 token_teams=token_teams, # None = admin bypass, [] = public-only, [...] = team-scoped
5445 )
5446 # Release transaction before response serialization
5447 db.commit()
5448 db.close()
5450 if include_pagination:
5451 return CursorPaginatedGatewaysResponse.model_construct(gateways=data, next_cursor=next_cursor)
5452 return data
5455@gateway_router.post("", response_model=GatewayRead)
5456@gateway_router.post("/", response_model=GatewayRead)
5457@require_permission("gateways.create")
5458async def register_gateway(
5459 gateway: GatewayCreate,
5460 request: Request,
5461 db: Session = Depends(get_db),
5462 user=Depends(get_current_user_with_permissions),
5463) -> Union[GatewayRead, JSONResponse]:
5464 """
5465 Register a new gateway.
5467 Args:
5468 gateway: Gateway creation data.
5469 request: The FastAPI request object for metadata extraction.
5470 db: Database session.
5471 user: Authenticated user.
5473 Returns:
5474 Created gateway.
5475 """
5476 logger.debug(f"User '{user}' requested to register gateway: {gateway}")
5477 try:
5478 # Extract metadata from request
5479 metadata = MetadataCapture.extract_creation_metadata(request, user)
5481 # Get user email and handle team assignment
5482 user_email = get_user_email(user)
5484 token_team_id = getattr(request.state, "team_id", None)
5485 token_teams = getattr(request.state, "token_teams", None)
5486 gateway_team_id = gateway.team_id
5487 visibility = gateway.visibility
5489 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
5490 is_public_only_token = token_teams is not None and len(token_teams) == 0
5491 if is_public_only_token and visibility in ("team", "private"):
5492 return ORJSONResponse(
5493 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
5494 status_code=status.HTTP_403_FORBIDDEN,
5495 )
5497 # Check for team ID mismatch (only for non-public-only tokens)
5498 if not is_public_only_token and gateway_team_id is not None and token_team_id is not None and gateway_team_id != token_team_id:
5499 return ORJSONResponse(
5500 content={"message": "Access issue: This API token does not have the required permissions for this team."},
5501 status_code=status.HTTP_403_FORBIDDEN,
5502 )
5504 # Determine final team ID (public-only tokens get no team)
5505 if is_public_only_token:
5506 team_id = None
5507 else:
5508 team_id = gateway_team_id or token_team_id
5510 logger.debug(f"User {user_email} is creating a new gateway for team {team_id}")
5512 return await gateway_service.register_gateway(
5513 db,
5514 gateway,
5515 created_by=metadata["created_by"],
5516 created_from_ip=metadata["created_from_ip"],
5517 created_via=metadata["created_via"],
5518 created_user_agent=metadata["created_user_agent"],
5519 team_id=team_id,
5520 owner_email=user_email,
5521 visibility=visibility,
5522 )
5523 except Exception as ex:
5524 if isinstance(ex, GatewayConnectionError):
5525 return ORJSONResponse(content={"message": str(ex)}, status_code=status.HTTP_502_BAD_GATEWAY)
5526 if isinstance(ex, ValueError):
5527 return ORJSONResponse(content={"message": "Unable to process input"}, status_code=status.HTTP_400_BAD_REQUEST)
5528 if isinstance(ex, GatewayNameConflictError):
5529 return ORJSONResponse(content={"message": "Gateway name already exists"}, status_code=status.HTTP_409_CONFLICT)
5530 if isinstance(ex, GatewayDuplicateConflictError):
5531 return ORJSONResponse(content={"message": "Gateway already exists"}, status_code=status.HTTP_409_CONFLICT)
5532 if isinstance(ex, RuntimeError):
5533 return ORJSONResponse(content={"message": "Error during execution"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
5534 if isinstance(ex, ValidationError):
5535 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
5536 if isinstance(ex, IntegrityError):
5537 return ORJSONResponse(status_code=status.HTTP_409_CONFLICT, content=ErrorFormatter.format_database_error(ex))
5538 return ORJSONResponse(content={"message": "Unexpected error"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
5541@gateway_router.get("/{gateway_id}", response_model=GatewayRead)
5542@require_permission("gateways.read")
5543async def get_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Union[GatewayRead, JSONResponse]:
5544 """
5545 Retrieve a gateway by ID.
5547 Args:
5548 gateway_id: ID of the gateway.
5549 request: Incoming request used for scoped access validation.
5550 db: Database session.
5551 user: Authenticated user.
5553 Returns:
5554 Gateway data.
5556 Raises:
5557 HTTPException: 404 if gateway not found.
5558 """
5559 logger.debug(f"User '{user}' requested gateway {gateway_id}")
5560 try:
5561 gateway = await gateway_service.get_gateway(db, gateway_id)
5562 _enforce_scoped_resource_access(request, db, user, f"/gateways/{gateway_id}")
5563 return gateway
5564 except GatewayNotFoundError as e:
5565 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5568@gateway_router.put("/{gateway_id}", response_model=GatewayRead)
5569@require_permission("gateways.update")
5570async def update_gateway(
5571 gateway_id: str,
5572 gateway: GatewayUpdate,
5573 request: Request,
5574 db: Session = Depends(get_db),
5575 user=Depends(get_current_user_with_permissions),
5576) -> Union[GatewayRead, JSONResponse]:
5577 """
5578 Update a gateway.
5580 Args:
5581 gateway_id: Gateway ID.
5582 gateway: Gateway update data.
5583 request (Request): The FastAPI request object for metadata extraction.
5584 db: Database session.
5585 user: Authenticated user.
5587 Returns:
5588 Updated gateway.
5589 """
5590 logger.debug(f"User '{user}' requested update on gateway {gateway_id} with data={gateway}")
5591 try:
5592 # Extract modification metadata
5593 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
5595 user_email = user.get("email") if isinstance(user, dict) else str(user)
5596 result = await gateway_service.update_gateway(
5597 db,
5598 gateway_id,
5599 gateway,
5600 modified_by=mod_metadata["modified_by"],
5601 modified_from_ip=mod_metadata["modified_from_ip"],
5602 modified_via=mod_metadata["modified_via"],
5603 modified_user_agent=mod_metadata["modified_user_agent"],
5604 user_email=user_email,
5605 )
5606 db.commit()
5607 db.close()
5608 return result
5609 except Exception as ex:
5610 if isinstance(ex, PermissionError):
5611 return ORJSONResponse(content={"message": str(ex)}, status_code=403)
5612 if isinstance(ex, GatewayNotFoundError):
5613 return ORJSONResponse(content={"message": "Gateway not found"}, status_code=status.HTTP_404_NOT_FOUND)
5614 if isinstance(ex, GatewayConnectionError):
5615 return ORJSONResponse(content={"message": str(ex)}, status_code=status.HTTP_502_BAD_GATEWAY)
5616 if isinstance(ex, ValueError):
5617 return ORJSONResponse(content={"message": "Unable to process input"}, status_code=status.HTTP_400_BAD_REQUEST)
5618 if isinstance(ex, GatewayNameConflictError):
5619 return ORJSONResponse(content={"message": "Gateway name already exists"}, status_code=status.HTTP_409_CONFLICT)
5620 if isinstance(ex, GatewayDuplicateConflictError):
5621 return ORJSONResponse(content={"message": "Gateway already exists"}, status_code=status.HTTP_409_CONFLICT)
5622 if isinstance(ex, RuntimeError):
5623 return ORJSONResponse(content={"message": "Error during execution"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
5624 if isinstance(ex, ValidationError):
5625 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
5626 if isinstance(ex, IntegrityError):
5627 return ORJSONResponse(status_code=status.HTTP_409_CONFLICT, content=ErrorFormatter.format_database_error(ex))
5628 return ORJSONResponse(content={"message": "Unexpected error"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
5631@gateway_router.delete("/{gateway_id}")
5632@require_permission("gateways.delete")
5633async def delete_gateway(gateway_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, str]:
5634 """
5635 Delete a gateway by ID.
5637 Args:
5638 gateway_id: ID of the gateway.
5639 db: Database session.
5640 user: Authenticated user.
5642 Returns:
5643 Status message.
5645 Raises:
5646 HTTPException: If permission denied (403), gateway not found (404), or other gateway error (400).
5647 """
5648 logger.debug(f"User '{user}' requested deletion of gateway {gateway_id}")
5649 try:
5650 user_email = user.get("email") if isinstance(user, dict) else str(user)
5651 current = await gateway_service.get_gateway(db, gateway_id)
5652 has_resources = bool(current.capabilities.get("resources"))
5653 await gateway_service.delete_gateway(db, gateway_id, user_email=user_email)
5655 # If the gateway had resources and was successfully deleted, invalidate
5656 # the whole resource cache. This is needed since the cache holds both
5657 # individual resources and the full listing which will also need to be
5658 # invalidated.
5659 if has_resources:
5660 await invalidate_resource_cache()
5662 db.commit()
5663 db.close()
5664 return {"status": "success", "message": f"Gateway {gateway_id} deleted"}
5665 except PermissionError as e:
5666 raise HTTPException(status_code=403, detail=str(e))
5667 except GatewayNotFoundError as e:
5668 raise HTTPException(status_code=404, detail=str(e))
5669 except GatewayError as e:
5670 raise HTTPException(status_code=400, detail=str(e))
5673@gateway_router.post("/{gateway_id}/tools/refresh", response_model=GatewayRefreshResponse)
5674@require_permission("gateways.update")
5675async def refresh_gateway_tools(
5676 gateway_id: str,
5677 request: Request,
5678 include_resources: bool = Query(False, description="Include resources in refresh"),
5679 include_prompts: bool = Query(False, description="Include prompts in refresh"),
5680 db: Session = Depends(get_db),
5681 user=Depends(get_current_user_with_permissions),
5682) -> GatewayRefreshResponse:
5683 """
5684 Manually trigger a refresh of tools/resources/prompts from a gateway's MCP server.
5686 This endpoint forces an immediate re-discovery of tools, resources, and prompts
5687 from the specified gateway. It returns counts of added, updated, and removed items,
5688 along with any validation errors encountered.
5690 Args:
5691 gateway_id: ID of the gateway to refresh.
5692 request: The FastAPI request object.
5693 include_resources: Whether to include resources in the refresh.
5694 include_prompts: Whether to include prompts in the refresh.
5695 db: Database session used to validate gateway access.
5696 user: Authenticated user.
5698 Returns:
5699 GatewayRefreshResponse with counts of changes and any validation errors.
5701 Raises:
5702 HTTPException: 404 if gateway not found, 409 if refresh already in progress.
5703 """
5704 logger.info(f"User '{user}' requested manual refresh for gateway {gateway_id}")
5705 try:
5706 await gateway_service.get_gateway(db, gateway_id)
5707 _enforce_scoped_resource_access(request, db, user, f"/gateways/{gateway_id}")
5709 user_email = user.get("email") if isinstance(user, dict) else str(user)
5710 result = await gateway_service.refresh_gateway_manually(
5711 gateway_id=gateway_id,
5712 include_resources=include_resources,
5713 include_prompts=include_prompts,
5714 user_email=user_email,
5715 request_headers=dict(request.headers),
5716 )
5717 return GatewayRefreshResponse(gateway_id=gateway_id, **result)
5718 except GatewayNotFoundError as e:
5719 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5720 except GatewayError as e:
5721 # 409 Conflict for concurrent refresh attempts
5722 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
5725##############
5726# Root APIs #
5727##############
5728@root_router.get("", response_model=List[Root])
5729@root_router.get("/", response_model=List[Root])
5730@require_permission("admin.system_config")
5731async def list_roots(
5732 user=Depends(get_current_user_with_permissions),
5733) -> List[Root]:
5734 """
5735 Retrieve a list of all registered roots.
5737 Args:
5738 user: Authenticated user.
5740 Returns:
5741 List of Root objects.
5742 """
5743 logger.debug(f"User '{user}' requested list of roots")
5744 return await root_service.list_roots()
5747@root_router.get("/export", response_model=Dict[str, Any])
5748@require_permission("admin.system_config")
5749async def export_root(
5750 uri: str,
5751 user=Depends(get_current_user_with_permissions),
5752) -> Dict[str, Any]:
5753 """
5754 Export a single root configuration to JSON format.
5756 Args:
5757 uri: Root URI to export (query parameter)
5758 user: Authenticated user
5760 Returns:
5761 Export data containing root information
5763 Raises:
5764 HTTPException: If root not found or export fails
5765 """
5766 try:
5767 logger.info(f"User {user} requested root export for URI: {uri}")
5769 # Extract username from user
5770 username: Optional[str] = None
5771 if hasattr(user, "email"):
5772 username = getattr(user, "email", None)
5773 elif isinstance(user, dict):
5774 username = user.get("email", None)
5775 else:
5776 username = None
5778 # Get the root by URI
5779 root = await root_service.get_root_by_uri(uri)
5781 # Create export data
5782 export_data = {
5783 "exported_at": datetime.now().isoformat(),
5784 "exported_by": username or "unknown",
5785 "export_type": "root",
5786 "version": "1.0",
5787 "root": {
5788 "uri": str(root.uri),
5789 "name": root.name,
5790 },
5791 }
5793 return export_data
5795 except RootServiceNotFoundError as e:
5796 logger.error(f"Root not found for export by user {user}: {str(e)}")
5797 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5798 except Exception as e:
5799 logger.error(f"Unexpected root export error for user {user}: {str(e)}")
5800 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Root export failed: {str(e)}")
5803@root_router.get("/changes")
5804@require_permission("admin.system_config")
5805async def subscribe_roots_changes(
5806 user=Depends(get_current_user_with_permissions),
5807) -> StreamingResponse:
5808 """
5809 Subscribe to real-time changes in root list via Server-Sent Events (SSE).
5811 Args:
5812 user: Authenticated user.
5814 Returns:
5815 StreamingResponse with event-stream media type.
5816 """
5817 logger.debug(f"User '{user}' subscribed to root changes stream")
5819 async def generate_events():
5820 """Generate SSE-formatted events from root service changes.
5822 Yields:
5823 str: SSE-formatted event data.
5824 """
5825 async for event in root_service.subscribe_changes():
5826 yield f"data: {orjson.dumps(event).decode()}\n\n"
5828 return StreamingResponse(generate_events(), media_type="text/event-stream")
5831@root_router.get("/{root_uri:path}", response_model=Root)
5832@require_permission("admin.system_config")
5833async def get_root_by_uri(
5834 root_uri: str,
5835 user=Depends(get_current_user_with_permissions),
5836) -> Root:
5837 """
5838 Retrieve a specific root by its URI.
5840 Args:
5841 root_uri: URI of the root to retrieve.
5842 user: Authenticated user.
5844 Returns:
5845 Root object.
5847 Raises:
5848 HTTPException: If the root is not found.
5849 Exception: For any other unexpected errors.
5850 """
5851 logger.debug(f"User '{user}' requested root with URI: {root_uri}")
5852 try:
5853 root = await root_service.get_root_by_uri(root_uri)
5854 return root
5855 except RootServiceNotFoundError as e:
5856 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5857 except Exception as e:
5858 logger.error(f"Error getting root {root_uri}: {e}")
5859 raise e
5862@root_router.post("", response_model=Root)
5863@root_router.post("/", response_model=Root)
5864@require_permission("admin.system_config")
5865async def add_root(
5866 root: Root, # Accept JSON body using the Root model from models.py
5867 user=Depends(get_current_user_with_permissions),
5868) -> Root:
5869 """
5870 Add a new root.
5872 Args:
5873 root: Root object containing URI and name.
5874 user: Authenticated user.
5876 Returns:
5877 The added Root object.
5878 """
5879 logger.debug(f"User '{user}' requested to add root: {root}")
5880 return await root_service.add_root(str(root.uri), root.name)
5883@root_router.put("/{root_uri:path}", response_model=Root)
5884@require_permission("admin.system_config")
5885async def update_root(
5886 root_uri: str,
5887 root: Root,
5888 user=Depends(get_current_user_with_permissions),
5889) -> Root:
5890 """
5891 Update a root by URI.
5893 Args:
5894 root_uri: URI of the root to update.
5895 root: Root object with updated information.
5896 user: Authenticated user.
5898 Returns:
5899 Updated Root object.
5901 Raises:
5902 HTTPException: If the root is not found.
5903 Exception: For any other unexpected errors.
5904 """
5905 logger.debug(f"User '{user}' requested to update root with URI: {root_uri}")
5906 try:
5907 root = await root_service.update_root(root_uri, root.name)
5908 return root
5909 except RootServiceNotFoundError as e:
5910 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5911 except Exception as e:
5912 logger.error(f"Error updating root {root_uri}: {e}")
5913 raise e
5916@root_router.delete("/{uri:path}")
5917@require_permission("admin.system_config")
5918async def remove_root(
5919 uri: str,
5920 user=Depends(get_current_user_with_permissions),
5921) -> Dict[str, str]:
5922 """
5923 Remove a registered root by URI.
5925 Args:
5926 uri: URI of the root to remove.
5927 user: Authenticated user.
5929 Returns:
5930 Status message indicating result.
5931 """
5932 logger.debug(f"User '{user}' requested to remove root with URI: {uri}")
5933 await root_service.remove_root(uri)
5934 return {"status": "success", "message": f"Root {uri} removed"}
5937##################
5938# Utility Routes #
5939##################
5940@utility_router.post("/rpc/")
5941@utility_router.post("/rpc")
5942async def handle_rpc(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
5943 """Handle RPC requests.
5945 Args:
5946 request (Request): The incoming FastAPI request.
5947 db (Session): Database session.
5948 user: The authenticated user (dict with RBAC context).
5950 Returns:
5951 Response with the RPC result or error.
5953 Raises:
5954 PluginError: If encounters issue with plugin
5955 PluginViolationError: If plugin violated the request. Example - In case of OPA plugin, if the request is denied by policy.
5956 """
5957 req_id = None
5958 try:
5959 # Extract user identifier from either RBAC user object or JWT payload
5960 if hasattr(user, "email"):
5961 user_id = getattr(user, "email", None) # RBAC user object
5962 elif isinstance(user, dict):
5963 user_id = user.get("sub") or user.get("email") or user.get("username", "unknown") # JWT payload
5964 else:
5965 user_id = str(user) # String username from basic auth
5967 logger.debug(f"User {user_id} made an RPC request")
5968 try:
5969 body = orjson.loads(await request.body())
5970 except orjson.JSONDecodeError:
5971 return ORJSONResponse(
5972 status_code=400,
5973 content={
5974 "jsonrpc": "2.0",
5975 "error": {"code": -32700, "message": "Parse error"},
5976 "id": None,
5977 },
5978 )
5979 method = body["method"]
5980 req_id = body.get("id")
5981 if req_id is None:
5982 req_id = str(uuid.uuid4())
5983 params = body.get("params", {})
5984 server_id = params.get("server_id", None)
5985 cursor = params.get("cursor") # Extract cursor parameter
5987 # RBAC: Enforce server_id scoping for server-scoped tokens.
5988 # Extract token scopes once, then:
5989 # 1. If request supplies server_id, validate it matches the token scope.
5990 # 2. If request omits server_id but token is server-scoped, auto-inject the
5991 # token's server_id so list operations stay properly scoped (parity with
5992 # the REST middleware which denies /tools for server-scoped tokens).
5993 _cached = getattr(request.state, "_jwt_verified_payload", None)
5994 _jwt_payload = _cached[1] if (isinstance(_cached, tuple) and len(_cached) == 2 and isinstance(_cached[1], dict)) else None
5995 _token_scopes = _jwt_payload.get("scopes", {}) if _jwt_payload else {}
5996 _token_server_id = _token_scopes.get("server_id") if _token_scopes else None
5998 if server_id:
5999 if not validate_server_access(_token_scopes, server_id):
6000 return ORJSONResponse(
6001 status_code=403,
6002 content={
6003 "jsonrpc": "2.0",
6004 "error": {"code": -32003, "message": f"Token not authorized for server: {server_id}"},
6005 "id": req_id,
6006 },
6007 )
6008 elif _token_server_id is not None:
6009 server_id = _token_server_id
6011 RPCRequest(jsonrpc="2.0", method=method, params=params) # Validate the request body against the RPCRequest model
6013 # Multi-worker session affinity: check if we should forward to another worker
6014 # This applies to ALL methods (except initialize which creates new sessions)
6015 # The x-forwarded-internally header marks requests that have already been forwarded
6016 # to prevent infinite forwarding loops
6017 headers = {k.lower(): v for k, v in request.headers.items()}
6018 # Session ID can come from two sources:
6019 # 1. MCP-Session-Id (mcp-session-id) - MCP protocol header from Streamable HTTP clients
6020 # 2. x-mcp-session-id - our internal header from SSE session_registry calls
6021 mcp_session_id = headers.get("mcp-session-id") or headers.get("x-mcp-session-id")
6022 # Only trust x-forwarded-internally from loopback to prevent external spoofing
6023 _rpc_client_host = request.client.host if request.client else None
6024 _rpc_from_loopback = _rpc_client_host in ("127.0.0.1", "::1") if _rpc_client_host else False
6025 is_internally_forwarded = _rpc_from_loopback and headers.get("x-forwarded-internally") == "true"
6027 if settings.mcpgateway_session_affinity_enabled and mcp_session_id and method != "initialize" and not is_internally_forwarded:
6028 # First-Party
6029 from mcpgateway.services.mcp_session_pool import MCPSessionPool, WORKER_ID # pylint: disable=import-outside-toplevel
6031 if not MCPSessionPool.is_valid_mcp_session_id(mcp_session_id):
6032 logger.debug("Invalid MCP session id for affinity forwarding, executing locally")
6033 else:
6034 session_short = mcp_session_id[:8] if len(mcp_session_id) >= 8 else mcp_session_id
6035 logger.debug(f"[AFFINITY] Worker {WORKER_ID} | Session {session_short}... | Method: {method} | RPC request received, checking affinity")
6036 try:
6037 # First-Party
6038 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool # pylint: disable=import-outside-toplevel
6040 pool = get_mcp_session_pool()
6041 forwarded_response = await pool.forward_request_to_owner(
6042 mcp_session_id,
6043 {"method": method, "params": params, "headers": dict(headers), "req_id": req_id},
6044 )
6045 if forwarded_response is not None:
6046 # Request was handled by another worker
6047 logger.info(f"[AFFINITY] Worker {WORKER_ID} | Session {session_short}... | Method: {method} | Forwarded response received")
6048 if "error" in forwarded_response:
6049 raise JSONRPCError(
6050 forwarded_response["error"].get("code", -32603),
6051 forwarded_response["error"].get("message", "Forwarded request failed"),
6052 )
6053 result = forwarded_response.get("result", {})
6054 return {"jsonrpc": "2.0", "result": result, "id": req_id}
6055 except RuntimeError:
6056 # Pool not initialized - execute locally
6057 logger.debug(f"[AFFINITY] Worker {WORKER_ID} | Session {session_short}... | Method: {method} | Pool not initialized, executing locally")
6058 elif is_internally_forwarded and mcp_session_id:
6059 # First-Party
6060 from mcpgateway.services.mcp_session_pool import WORKER_ID # pylint: disable=import-outside-toplevel
6062 session_short = mcp_session_id[:8] if len(mcp_session_id) >= 8 else mcp_session_id
6063 logger.debug(f"[AFFINITY] Worker {WORKER_ID} | Session {session_short}... | Method: {method} | Internally forwarded request, executing locally")
6065 if method == "initialize":
6066 # Extract session_id from params or query string (for capability tracking)
6067 init_session_id = params.get("session_id") or params.get("sessionId") or request.query_params.get("session_id")
6068 requester_email, requester_is_admin = _get_request_identity(request, user)
6070 if init_session_id:
6071 effective_owner = await session_registry.claim_session_owner(init_session_id, requester_email)
6072 if effective_owner is None:
6073 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method})
6075 if effective_owner and not requester_is_admin and requester_email != effective_owner:
6076 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method})
6078 # Pass server_id to advertise OAuth capability if configured per RFC 9728
6079 result = await session_registry.handle_initialize_logic(body.get("params", {}), session_id=init_session_id, server_id=server_id)
6080 if hasattr(result, "model_dump"):
6081 result = result.model_dump(by_alias=True, exclude_none=True)
6083 # Register session ownership in Redis for multi-worker affinity
6084 # This must happen AFTER initialize succeeds so subsequent requests route to this worker
6085 if settings.mcpgateway_session_affinity_enabled and mcp_session_id and mcp_session_id != "not-provided":
6086 try:
6087 # First-Party
6088 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool, WORKER_ID # pylint: disable=import-outside-toplevel
6090 pool = get_mcp_session_pool()
6091 # Claim-or-refresh ownership for this session (does not steal).
6092 await pool.register_pool_session_owner(mcp_session_id)
6093 logger.debug(f"[AFFINITY_INIT] Worker {WORKER_ID} | Session {mcp_session_id[:8]}... | Registered ownership after initialize")
6094 except Exception as e:
6095 logger.warning(f"[AFFINITY_INIT] Failed to register session ownership: {e}")
6096 elif method == "tools/list":
6097 await _ensure_rpc_permission(user, db, "tools.read", method, request=request)
6098 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
6099 _req_email, _req_is_admin = user_email, is_admin
6100 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
6101 # Admin bypass - only when token has NO team restrictions
6102 if is_admin and token_teams is None:
6103 user_email = None
6104 token_teams = None # Admin unrestricted
6105 elif token_teams is None:
6106 token_teams = [] # Non-admin without teams = public-only (secure default)
6107 if server_id:
6108 tools = await tool_service.list_server_tools(
6109 db,
6110 server_id,
6111 cursor=cursor,
6112 user_email=user_email,
6113 token_teams=token_teams,
6114 requesting_user_email=_req_email,
6115 requesting_user_is_admin=_req_is_admin,
6116 requesting_user_team_roles=_req_team_roles,
6117 )
6118 # Release DB connection early to prevent idle-in-transaction under load
6119 db.commit()
6120 db.close()
6121 result = {"tools": [t.model_dump(by_alias=True, exclude_none=True) for t in tools]}
6122 else:
6123 tools, next_cursor = await tool_service.list_tools(
6124 db,
6125 cursor=cursor,
6126 limit=0,
6127 user_email=user_email,
6128 token_teams=token_teams,
6129 requesting_user_email=_req_email,
6130 requesting_user_is_admin=_req_is_admin,
6131 requesting_user_team_roles=_req_team_roles,
6132 )
6133 # Release DB connection early to prevent idle-in-transaction under load
6134 db.commit()
6135 db.close()
6136 result = {"tools": [t.model_dump(by_alias=True, exclude_none=True) for t in tools]}
6137 if next_cursor:
6138 result["nextCursor"] = next_cursor
6139 elif method == "list_tools": # Legacy endpoint
6140 await _ensure_rpc_permission(user, db, "tools.read", method, request=request)
6141 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
6142 _req_email, _req_is_admin = user_email, is_admin
6143 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
6144 # Admin bypass - only when token has NO team restrictions (token_teams is None)
6145 # If token has explicit team scope (even empty [] for public-only), respect it
6146 if is_admin and token_teams is None:
6147 user_email = None
6148 token_teams = None # Admin unrestricted
6149 elif token_teams is None:
6150 token_teams = [] # Non-admin without teams = public-only (secure default)
6151 if server_id:
6152 tools = await tool_service.list_server_tools(
6153 db,
6154 server_id,
6155 cursor=cursor,
6156 user_email=user_email,
6157 token_teams=token_teams,
6158 requesting_user_email=_req_email,
6159 requesting_user_is_admin=_req_is_admin,
6160 requesting_user_team_roles=_req_team_roles,
6161 )
6162 db.commit()
6163 db.close()
6164 result = {"tools": [t.model_dump(by_alias=True, exclude_none=True) for t in tools]}
6165 else:
6166 tools, next_cursor = await tool_service.list_tools(
6167 db,
6168 cursor=cursor,
6169 limit=0,
6170 user_email=user_email,
6171 token_teams=token_teams,
6172 requesting_user_email=_req_email,
6173 requesting_user_is_admin=_req_is_admin,
6174 requesting_user_team_roles=_req_team_roles,
6175 )
6176 db.commit()
6177 db.close()
6178 result = {"tools": [t.model_dump(by_alias=True, exclude_none=True) for t in tools]}
6179 if next_cursor:
6180 result["nextCursor"] = next_cursor
6181 elif method == "list_gateways":
6182 await _ensure_rpc_permission(user, db, "gateways.read", method, request=request)
6183 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
6184 # Admin bypass - only when token has NO team restrictions
6185 if is_admin and token_teams is None:
6186 user_email = None
6187 token_teams = None # Admin unrestricted
6188 elif token_teams is None:
6189 token_teams = [] # Non-admin without teams = public-only (secure default)
6190 gateways, next_cursor = await gateway_service.list_gateways(db, include_inactive=False, user_email=user_email, token_teams=token_teams)
6191 db.commit()
6192 db.close()
6193 result = {"gateways": [g.model_dump(by_alias=True, exclude_none=True) for g in gateways]}
6194 if next_cursor:
6195 result["nextCursor"] = next_cursor
6196 elif method == "list_roots":
6197 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request)
6198 roots = await root_service.list_roots()
6199 result = {"roots": [r.model_dump(by_alias=True, exclude_none=True) for r in roots]}
6200 elif method == "resources/list":
6201 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
6202 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
6203 # Admin bypass - only when token has NO team restrictions
6204 if is_admin and token_teams is None:
6205 user_email = None
6206 token_teams = None # Admin unrestricted
6207 elif token_teams is None:
6208 token_teams = [] # Non-admin without teams = public-only (secure default)
6209 if server_id:
6210 resources = await resource_service.list_server_resources(db, server_id, user_email=user_email, token_teams=token_teams)
6211 db.commit()
6212 db.close()
6213 result = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]}
6214 else:
6215 resources, next_cursor = await resource_service.list_resources(db, cursor=cursor, limit=0, user_email=user_email, token_teams=token_teams)
6216 db.commit()
6217 db.close()
6218 result = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]}
6219 if next_cursor:
6220 result["nextCursor"] = next_cursor
6221 elif method == "resources/read":
6222 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
6223 uri = params.get("uri")
6224 request_id = params.get("requestId", None)
6225 meta_data = params.get("_meta", None)
6226 if not uri:
6227 raise JSONRPCError(-32602, "Missing resource URI in parameters", params)
6229 # Get authorization context (same as resources/list)
6230 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
6231 if auth_is_admin and auth_token_teams is None:
6232 auth_user_email = None
6233 # auth_token_teams stays None (unrestricted)
6234 elif auth_token_teams is None:
6235 auth_token_teams = [] # Non-admin without teams = public-only
6237 # Get user email for OAuth token selection
6238 oauth_user_email = get_user_email(user)
6239 # Get plugin contexts from request.state for cross-hook sharing
6240 plugin_context_table = getattr(request.state, "plugin_context_table", None)
6241 plugin_global_context = getattr(request.state, "plugin_global_context", None)
6242 try:
6243 result = await resource_service.read_resource(
6244 db,
6245 resource_uri=uri,
6246 request_id=request_id,
6247 user=auth_user_email,
6248 server_id=server_id,
6249 token_teams=auth_token_teams,
6250 plugin_context_table=plugin_context_table,
6251 plugin_global_context=plugin_global_context,
6252 meta_data=meta_data,
6253 )
6254 if hasattr(result, "model_dump"):
6255 result = {"contents": [result.model_dump(by_alias=True, exclude_none=True)]}
6256 else:
6257 result = {"contents": [result]}
6258 except ValueError:
6259 # Resource not found in the gateway
6260 logger.error(f"Resource not found: {uri}")
6261 raise JSONRPCError(-32002, f"Resource not found: {uri}", {"uri": uri})
6262 # Release transaction after resources/read completes
6263 db.commit()
6264 db.close()
6265 elif method == "resources/subscribe":
6266 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
6267 # MCP spec-compliant resource subscription endpoint
6268 uri = params.get("uri")
6269 if not uri:
6270 raise JSONRPCError(-32602, "Missing resource URI in parameters", params)
6271 access_user_email, access_token_teams = _get_scoped_resource_access_context(request, user)
6272 # Get user email for subscriber ID
6273 user_email = get_user_email(user)
6274 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email)
6275 try:
6276 await resource_service.subscribe_resource(db, subscription, user_email=access_user_email, token_teams=access_token_teams)
6277 except PermissionError:
6278 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method})
6279 db.commit()
6280 db.close()
6281 result = {}
6282 elif method == "resources/unsubscribe":
6283 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
6284 # MCP spec-compliant resource unsubscription endpoint
6285 uri = params.get("uri")
6286 if not uri:
6287 raise JSONRPCError(-32602, "Missing resource URI in parameters", params)
6288 # Get user email for subscriber ID
6289 user_email = get_user_email(user)
6290 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email)
6291 await resource_service.unsubscribe_resource(db, subscription)
6292 db.commit()
6293 db.close()
6294 result = {}
6295 elif method == "prompts/list":
6296 await _ensure_rpc_permission(user, db, "prompts.read", method, request=request)
6297 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
6298 # Admin bypass - only when token has NO team restrictions
6299 if is_admin and token_teams is None:
6300 user_email = None
6301 token_teams = None # Admin unrestricted
6302 elif token_teams is None:
6303 token_teams = [] # Non-admin without teams = public-only (secure default)
6304 if server_id:
6305 prompts = await prompt_service.list_server_prompts(db, server_id, cursor=cursor, user_email=user_email, token_teams=token_teams)
6306 db.commit()
6307 db.close()
6308 result = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]}
6309 else:
6310 prompts, next_cursor = await prompt_service.list_prompts(db, cursor=cursor, limit=0, user_email=user_email, token_teams=token_teams)
6311 db.commit()
6312 db.close()
6313 result = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]}
6314 if next_cursor:
6315 result["nextCursor"] = next_cursor
6316 elif method == "prompts/get":
6317 await _ensure_rpc_permission(user, db, "prompts.read", method, request=request)
6318 name = params.get("name")
6319 arguments = params.get("arguments", {})
6320 meta_data = params.get("_meta", None)
6321 if not name:
6322 raise JSONRPCError(-32602, "Missing prompt name in parameters", params)
6324 # Get authorization context (same as prompts/list)
6325 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
6326 if auth_is_admin and auth_token_teams is None:
6327 auth_user_email = None
6328 # auth_token_teams stays None (unrestricted)
6329 elif auth_token_teams is None:
6330 auth_token_teams = [] # Non-admin without teams = public-only
6332 # Get plugin contexts from request.state for cross-hook sharing
6333 plugin_context_table = getattr(request.state, "plugin_context_table", None)
6334 plugin_global_context = getattr(request.state, "plugin_global_context", None)
6335 result = await prompt_service.get_prompt(
6336 db,
6337 name,
6338 arguments,
6339 user=auth_user_email,
6340 server_id=server_id,
6341 token_teams=auth_token_teams,
6342 plugin_context_table=plugin_context_table,
6343 plugin_global_context=plugin_global_context,
6344 _meta_data=meta_data,
6345 )
6346 if hasattr(result, "model_dump"):
6347 result = result.model_dump(by_alias=True, exclude_none=True)
6348 # Release transaction after prompts/get completes
6349 db.commit()
6350 db.close()
6351 elif method == "ping":
6352 # Per the MCP spec, a ping returns an empty result.
6353 result = {}
6354 elif method == "tools/call": # pylint: disable=too-many-nested-blocks
6355 await _ensure_rpc_permission(user, db, "tools.execute", method, request=request)
6356 # Note: Multi-worker session affinity forwarding is handled earlier
6357 # (before method routing) to apply to ALL methods, not just tools/call
6358 name = params.get("name")
6359 arguments = params.get("arguments", {})
6360 meta_data = params.get("_meta", None)
6361 if not name:
6362 raise JSONRPCError(-32602, "Missing tool name in parameters", params)
6364 # Get authorization context (same as tools/list)
6365 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
6366 run_owner_email = auth_user_email
6367 run_owner_team_ids = [] if auth_token_teams is None else list(auth_token_teams)
6368 if auth_is_admin and auth_token_teams is None:
6369 auth_user_email = None
6370 # auth_token_teams stays None (unrestricted)
6371 elif auth_token_teams is None:
6372 auth_token_teams = [] # Non-admin without teams = public-only
6374 # Get user email for OAuth token selection
6375 oauth_user_email = get_user_email(user)
6376 # Get plugin contexts from request.state for cross-hook sharing
6377 plugin_context_table = getattr(request.state, "plugin_context_table", None)
6378 plugin_global_context = getattr(request.state, "plugin_global_context", None)
6380 # Register the tool execution for cancellation tracking with task reference (if enabled)
6381 # Note: req_id can be 0 which is falsy but valid per JSON-RPC spec, so use 'is not None'
6382 run_id = str(req_id) if req_id is not None else None
6383 tool_task: Optional[asyncio.Task] = None
6385 async def cancel_tool_task(reason: Optional[str] = None):
6386 """Cancel callback that actually cancels the asyncio task.
6388 Args:
6389 reason: Optional reason for cancellation.
6390 """
6391 if tool_task and not tool_task.done():
6392 logger.info(f"Cancelling tool task for run_id={run_id}, reason={reason}")
6393 tool_task.cancel()
6395 if settings.mcpgateway_tool_cancellation_enabled and run_id:
6396 await cancellation_service.register_run(
6397 run_id,
6398 name=f"tool:{name}",
6399 cancel_callback=cancel_tool_task,
6400 owner_email=run_owner_email,
6401 owner_team_ids=run_owner_team_ids,
6402 )
6404 try:
6405 # Check if cancelled before execution (only if feature enabled)
6406 if settings.mcpgateway_tool_cancellation_enabled and run_id:
6407 run_status = await cancellation_service.get_status(run_id)
6408 if run_status and run_status.get("cancelled"):
6409 raise JSONRPCError(-32800, f"Tool execution cancelled: {name}", {"requestId": run_id})
6411 # Create task for tool execution to enable real cancellation
6412 async def execute_tool():
6413 """Execute tool invocation with fallback to gateway forwarding.
6415 Returns:
6416 The tool invocation result or gateway forwarding result.
6418 Raises:
6419 JSONRPCError: If the tool is not found.
6420 """
6421 try:
6422 return await tool_service.invoke_tool(
6423 db=db,
6424 name=name,
6425 arguments=arguments,
6426 request_headers=headers,
6427 app_user_email=oauth_user_email,
6428 user_email=auth_user_email,
6429 token_teams=auth_token_teams,
6430 server_id=server_id,
6431 plugin_context_table=plugin_context_table,
6432 plugin_global_context=plugin_global_context,
6433 meta_data=meta_data,
6434 )
6435 except ValueError:
6436 # Tool not found log error and raise JSONRPCError
6437 logger.error(f"Tool not found: {name}")
6438 raise JSONRPCError(-32601, f"Tool not found: {name}", None)
6440 tool_task = asyncio.create_task(execute_tool())
6442 # Re-check cancellation after task creation to handle race condition
6443 # where cancel arrived between pre-check and task creation (callback saw tool_task=None)
6444 if settings.mcpgateway_tool_cancellation_enabled and run_id:
6445 run_status = await cancellation_service.get_status(run_id)
6446 if run_status and run_status.get("cancelled"):
6447 tool_task.cancel()
6449 try:
6450 result = await tool_task
6451 if hasattr(result, "model_dump"):
6452 result = result.model_dump(by_alias=True, exclude_none=True)
6453 except asyncio.CancelledError:
6454 # Task was cancelled - return partial result or error
6455 logger.info(f"Tool execution cancelled for run_id={run_id}, tool={name}")
6456 raise JSONRPCError(-32800, f"Tool execution cancelled: {name}", {"requestId": run_id, "partial": False})
6457 finally:
6458 # Unregister the run when done (only if feature enabled)
6459 if settings.mcpgateway_tool_cancellation_enabled and run_id:
6460 await cancellation_service.unregister_run(run_id)
6461 # Release transaction after tools/call completes
6462 db.commit()
6463 db.close()
6464 # TODO: Implement methods # pylint: disable=fixme
6465 elif method == "resources/templates/list":
6466 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
6467 # MCP spec-compliant resource templates list endpoint
6468 # Use _get_rpc_filter_context - same pattern as tools/list
6469 user_email_rpc, token_teams_rpc, is_admin_rpc = _get_rpc_filter_context(request, user)
6471 # Admin bypass - only when token has NO team restrictions
6472 if is_admin_rpc and token_teams_rpc is None:
6473 token_teams_rpc = None # Admin unrestricted
6474 elif token_teams_rpc is None:
6475 token_teams_rpc = [] # Non-admin without teams = public-only
6477 resource_templates = await resource_service.list_resource_templates(
6478 db,
6479 user_email=user_email_rpc,
6480 token_teams=token_teams_rpc,
6481 server_id=server_id,
6482 )
6483 db.commit()
6484 db.close()
6485 result = {"resourceTemplates": [rt.model_dump(by_alias=True, exclude_none=True) for rt in resource_templates]}
6486 elif method == "roots/list":
6487 # MCP spec-compliant method name
6488 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request)
6489 roots = await root_service.list_roots()
6490 result = {"roots": [r.model_dump(by_alias=True, exclude_none=True) for r in roots]}
6491 elif method.startswith("roots/"):
6492 # Catch-all for other roots/* methods (currently unsupported)
6493 result = {}
6494 elif method == "notifications/initialized":
6495 # MCP spec-compliant notification: client initialized
6496 logger.info("Client initialized")
6497 await logging_service.notify("Client initialized", LogLevel.INFO)
6498 result = {}
6499 elif method == "notifications/cancelled":
6500 # MCP spec-compliant notification: request cancelled
6501 # Note: requestId can be 0 (valid per JSON-RPC), so use 'is not None' and normalize to string
6502 raw_request_id = params.get("requestId")
6503 request_id = str(raw_request_id) if raw_request_id is not None else None
6504 reason = params.get("reason")
6505 logger.info(f"Request cancelled: {request_id}, reason: {reason}")
6506 # Attempt local cancellation per MCP spec
6507 if request_id is not None:
6508 await _authorize_run_cancellation(request, user, request_id, as_jsonrpc_error=True)
6509 await cancellation_service.cancel_run(request_id, reason=reason)
6510 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO)
6511 result = {}
6512 elif method == "notifications/message":
6513 # MCP spec-compliant notification: log message
6514 await logging_service.notify(
6515 params.get("data"),
6516 LogLevel(params.get("level", "info")),
6517 params.get("logger"),
6518 )
6519 result = {}
6520 elif method.startswith("notifications/"):
6521 # Catch-all for other notifications/* methods (currently unsupported)
6522 result = {}
6523 elif method == "sampling/createMessage":
6524 # MCP spec-compliant sampling endpoint
6525 result = await sampling_handler.create_message(db, params)
6526 elif method.startswith("sampling/"):
6527 # Catch-all for other sampling/* methods (currently unsupported)
6528 result = {}
6529 elif method == "elicitation/create":
6530 # MCP spec 2025-06-18: Elicitation support (server-to-client requests)
6531 # Elicitation allows servers to request structured user input through clients
6533 # Check if elicitation is enabled
6534 if not settings.mcpgateway_elicitation_enabled:
6535 raise JSONRPCError(-32601, "Elicitation feature is disabled", {"feature": "elicitation", "config": "MCPGATEWAY_ELICITATION_ENABLED=false"})
6537 # Validate params
6538 # First-Party
6539 from mcpgateway.common.models import ElicitRequestParams # pylint: disable=import-outside-toplevel
6540 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel
6542 try:
6543 elicit_params = ElicitRequestParams(**params)
6544 except Exception as e:
6545 raise JSONRPCError(-32602, f"Invalid elicitation params: {e}", params)
6547 # Get target session (from params or find elicitation-capable session)
6548 target_session_id = params.get("session_id") or params.get("sessionId")
6549 if not target_session_id:
6550 # Find an elicitation-capable session
6551 capable_sessions = await session_registry.get_elicitation_capable_sessions()
6552 if not capable_sessions:
6553 raise JSONRPCError(-32000, "No elicitation-capable clients available", {"message": elicit_params.message})
6554 target_session_id = capable_sessions[0]
6555 logger.debug(f"Selected session {target_session_id} for elicitation")
6557 # Verify session has elicitation capability
6558 if not await session_registry.has_elicitation_capability(target_session_id):
6559 raise JSONRPCError(-32000, f"Session {target_session_id} does not support elicitation", {"session_id": target_session_id})
6561 # Get elicitation service and create request
6562 elicitation_service = get_elicitation_service()
6564 # Extract timeout from params or use default
6565 timeout = params.get("timeout", settings.mcpgateway_elicitation_timeout)
6567 try:
6568 # Create elicitation request - this stores it and waits for response
6569 # For now, use dummy upstream_session_id - in full bidirectional proxy,
6570 # this would be the session that initiated the request
6571 upstream_session_id = "gateway"
6573 # Start the elicitation (creates pending request and future)
6574 elicitation_task = asyncio.create_task(
6575 elicitation_service.create_elicitation(
6576 upstream_session_id=upstream_session_id, downstream_session_id=target_session_id, message=elicit_params.message, requested_schema=elicit_params.requestedSchema, timeout=timeout
6577 )
6578 )
6580 # Get the pending elicitation to extract request_id
6581 # Wait a moment for it to be created
6582 await asyncio.sleep(0.01)
6583 pending_elicitations = [e for e in elicitation_service._pending.values() if e.downstream_session_id == target_session_id] # pylint: disable=protected-access
6584 if not pending_elicitations:
6585 raise JSONRPCError(-32000, "Failed to create elicitation request", {})
6587 pending = pending_elicitations[-1] # Get most recent
6589 # Send elicitation request to client via broadcast
6590 elicitation_request = {
6591 "jsonrpc": "2.0",
6592 "id": pending.request_id,
6593 "method": "elicitation/create",
6594 "params": {"message": elicit_params.message, "requestedSchema": elicit_params.requestedSchema},
6595 }
6597 await session_registry.broadcast(target_session_id, elicitation_request)
6598 logger.debug(f"Sent elicitation request {pending.request_id} to session {target_session_id}")
6600 # Wait for response
6601 elicit_result = await elicitation_task
6603 # Return result
6604 result = elicit_result.model_dump(by_alias=True, exclude_none=True)
6606 except asyncio.TimeoutError:
6607 raise JSONRPCError(-32000, f"Elicitation timed out after {timeout}s", {"message": elicit_params.message, "timeout": timeout})
6608 except ValueError as e:
6609 raise JSONRPCError(-32000, str(e), {"message": elicit_params.message})
6610 elif method.startswith("elicitation/"):
6611 # Catch-all for other elicitation/* methods
6612 result = {}
6613 elif method == "completion/complete":
6614 await _ensure_rpc_permission(user, db, "tools.read", method, request=request)
6615 # MCP spec-compliant completion endpoint
6616 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
6617 if is_admin and token_teams is None:
6618 user_email = None
6619 elif token_teams is None:
6620 token_teams = []
6621 result = await completion_service.handle_completion(db, params, user_email=user_email, token_teams=token_teams)
6622 elif method.startswith("completion/"):
6623 # Catch-all for other completion/* methods (currently unsupported)
6624 result = {}
6625 elif method == "logging/setLevel":
6626 # MCP spec-compliant logging endpoint
6627 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request)
6628 level = LogLevel(params.get("level"))
6629 await logging_service.set_level(level)
6630 result = {}
6631 elif method.startswith("logging/"):
6632 # Catch-all for other logging/* methods (currently unsupported)
6633 result = {}
6634 else:
6635 # Backward compatibility: Try to invoke as a tool directly
6636 # This allows both old format (method=tool_name) and new format (method=tools/call)
6637 await _ensure_rpc_permission(user, db, "tools.execute", method, request=request)
6638 # Standard
6639 headers = {k.lower(): v for k, v in request.headers.items()}
6641 # Get authorization context (same as tools/call)
6642 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
6643 if auth_is_admin and auth_token_teams is None:
6644 auth_user_email = None
6645 # auth_token_teams stays None (unrestricted)
6646 elif auth_token_teams is None:
6647 auth_token_teams = [] # Non-admin without teams = public-only
6649 # Get user email for OAuth token selection
6650 oauth_user_email = get_user_email(user)
6651 # Get server_id from params if provided
6652 server_id = params.get("server_id")
6653 # Get plugin contexts from request.state for cross-hook sharing
6654 plugin_context_table = getattr(request.state, "plugin_context_table", None)
6655 plugin_global_context = getattr(request.state, "plugin_global_context", None)
6657 meta_data = params.get("_meta", None)
6659 try:
6660 result = await tool_service.invoke_tool(
6661 db=db,
6662 name=method,
6663 arguments=params,
6664 request_headers=headers,
6665 app_user_email=oauth_user_email,
6666 user_email=auth_user_email,
6667 token_teams=auth_token_teams,
6668 server_id=server_id,
6669 plugin_context_table=plugin_context_table,
6670 plugin_global_context=plugin_global_context,
6671 meta_data=meta_data,
6672 )
6673 if hasattr(result, "model_dump"):
6674 result = result.model_dump(by_alias=True, exclude_none=True)
6675 except (PluginError, PluginViolationError):
6676 raise
6677 except Exception:
6678 # Log error and return invalid method
6679 logger.error(f"Method not found: {method}")
6680 raise JSONRPCError(-32000, "Invalid method", params)
6682 return {"jsonrpc": "2.0", "result": result, "id": req_id}
6684 except (PluginError, PluginViolationError):
6685 raise
6686 except JSONRPCError as e:
6687 error = e.to_dict()
6688 return {"jsonrpc": "2.0", "error": error["error"], "id": req_id}
6689 except Exception as e:
6690 if isinstance(e, ValueError):
6691 return ORJSONResponse(content={"message": "Method invalid"}, status_code=422)
6692 logger.error(f"RPC error: {str(e)}")
6693 return {
6694 "jsonrpc": "2.0",
6695 "error": {"code": -32000, "message": "Internal error", "data": str(e)},
6696 "id": req_id,
6697 }
6700_WS_RELAY_REQUIRED_PERMISSIONS = [
6701 "tools.read",
6702 "tools.execute",
6703 "resources.read",
6704 "prompts.read",
6705 "servers.use",
6706 "a2a.read",
6707]
6710def _get_websocket_bearer_token(websocket: WebSocket) -> Optional[str]:
6711 """Extract bearer token from WebSocket Authorization headers.
6713 Args:
6714 websocket: Incoming WebSocket connection.
6716 Returns:
6717 Bearer token value when present, otherwise None.
6718 """
6719 return extract_websocket_bearer_token(
6720 getattr(websocket, "query_params", {}),
6721 getattr(websocket, "headers", {}),
6722 query_param_warning="WebSocket authentication token passed via query parameter",
6723 )
6726async def _authenticate_websocket_user(websocket: WebSocket) -> tuple[Optional[str], Optional[str]]:
6727 """Authenticate and authorize a WebSocket relay connection.
6729 Args:
6730 websocket: Incoming WebSocket connection.
6732 Returns:
6733 A tuple of `(auth_token, proxy_user)` where each value may be None.
6735 Raises:
6736 HTTPException: If authentication fails or required permissions are missing.
6737 """
6738 auth_required = settings.mcp_client_auth_enabled or settings.auth_required
6739 auth_token = _get_websocket_bearer_token(websocket)
6740 proxy_user: Optional[str] = None
6741 user_context: Optional[dict[str, Any]] = None
6743 # JWT authentication path
6744 if auth_token:
6745 credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=auth_token)
6746 try:
6747 user = await get_current_user(credentials, request=websocket)
6748 except HTTPException:
6749 raise
6750 except Exception as exc:
6751 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication failed") from exc
6752 user_context = {
6753 "email": user.email,
6754 "full_name": user.full_name,
6755 "is_admin": user.is_admin,
6756 "ip_address": websocket.client.host if websocket.client else None,
6757 "user_agent": websocket.headers.get("user-agent"),
6758 "team_id": getattr(websocket.state, "team_id", None),
6759 "token_teams": getattr(websocket.state, "token_teams", None),
6760 "token_use": getattr(websocket.state, "token_use", None),
6761 }
6762 # Proxy authentication path (only valid when MCP client auth is disabled)
6763 elif is_proxy_auth_trust_active(settings):
6764 proxy_user = websocket.headers.get(settings.proxy_user_header)
6765 if proxy_user:
6766 user_context = {
6767 "email": proxy_user,
6768 "full_name": proxy_user,
6769 "is_admin": False,
6770 "ip_address": websocket.client.host if websocket.client else None,
6771 "user_agent": websocket.headers.get("user-agent"),
6772 }
6773 elif auth_required:
6774 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
6775 elif auth_required:
6776 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
6778 # RBAC gate: require at least one MCP interaction permission before allowing WS relay access
6779 if user_context:
6780 checker = PermissionChecker(user_context)
6781 if not await checker.has_any_permission(_WS_RELAY_REQUIRED_PERMISSIONS):
6782 logger.warning("WebSocket relay permission denied: user=%s", user_context.get("email"))
6783 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_ACCESS_DENIED_MSG)
6785 return auth_token, proxy_user
6788@utility_router.websocket("/ws")
6789async def websocket_endpoint(websocket: WebSocket):
6790 """
6791 Handle WebSocket connection to relay JSON-RPC requests to the internal RPC endpoint.
6793 Accepts incoming text messages, parses them as JSON-RPC requests, sends them to /rpc,
6794 and returns the result to the client over the same WebSocket.
6796 Args:
6797 websocket: The WebSocket connection instance.
6798 """
6799 try:
6800 if not settings.mcpgateway_ws_relay_enabled:
6801 await websocket.close(code=1008, reason="WebSocket relay is disabled")
6802 return
6804 try:
6805 auth_token, proxy_user = await _authenticate_websocket_user(websocket)
6806 except HTTPException as e:
6807 await websocket.close(code=1008, reason=str(e.detail))
6808 return
6810 await websocket.accept()
6811 while True:
6812 try:
6813 data = await websocket.receive_text()
6814 client_args = {"timeout": settings.federation_timeout, "verify": not settings.skip_ssl_verify}
6816 # Build headers for /rpc request - forward auth credentials
6817 rpc_headers: Dict[str, str] = {"Content-Type": "application/json"}
6818 if auth_token:
6819 rpc_headers["Authorization"] = f"Bearer {auth_token}"
6820 if proxy_user:
6821 rpc_headers[settings.proxy_user_header] = proxy_user
6823 async with ResilientHttpClient(client_args=client_args) as client:
6824 response = await client.post(
6825 f"http://localhost:{settings.port}{settings.app_root_path}/rpc",
6826 json=orjson.loads(data),
6827 headers=rpc_headers,
6828 )
6829 await websocket.send_text(response.text)
6830 except JSONRPCError as e:
6831 await websocket.send_text(orjson.dumps(e.to_dict()).decode())
6832 except orjson.JSONDecodeError:
6833 await websocket.send_text(
6834 orjson.dumps(
6835 {
6836 "jsonrpc": "2.0",
6837 "error": {"code": -32700, "message": "Parse error"},
6838 "id": None,
6839 }
6840 ).decode()
6841 )
6842 except Exception as e:
6843 logger.error(f"WebSocket error: {str(e)}")
6844 await websocket.close(code=1011)
6845 break
6846 except WebSocketDisconnect:
6847 logger.info("WebSocket disconnected")
6848 except Exception as e:
6849 logger.error(f"WebSocket connection error: {str(e)}")
6850 try:
6851 await websocket.close(code=1011)
6852 except Exception as er:
6853 logger.error(f"Error while closing WebSocket: {er}")
6856@utility_router.get("/sse")
6857@require_permission("servers.use")
6858async def utility_sse_endpoint(request: Request, user=Depends(get_current_user_with_permissions)):
6859 """
6860 Establish a Server-Sent Events (SSE) connection for real-time updates.
6862 Args:
6863 request (Request): The incoming HTTP request.
6864 user (str): Authenticated username.
6866 Returns:
6867 StreamingResponse: A streaming response that keeps the connection
6868 open and pushes events to the client.
6870 Raises:
6871 HTTPException: Returned with **500 Internal Server Error** if the SSE connection cannot be established or an unexpected error occurs while creating the transport.
6872 asyncio.CancelledError: If the request is cancelled during SSE setup.
6873 """
6874 try:
6875 logger.debug("User %s requested SSE connection", user)
6876 base_url = update_url_protocol(request)
6878 # SSE transport generates its own session_id - server-initiated, not client-provided
6879 transport = SSETransport(base_url=base_url)
6880 await transport.connect()
6881 await session_registry.add_session(transport.session_id, transport)
6882 await session_registry.set_session_owner(transport.session_id, get_user_email(user))
6884 # Defensive cleanup callback - runs immediately on client disconnect
6885 async def on_disconnect_cleanup() -> None:
6886 """Clean up session when SSE client disconnects."""
6887 try:
6888 await session_registry.remove_session(transport.session_id)
6889 logger.debug("Defensive session cleanup completed: %s", transport.session_id)
6890 except Exception as e:
6891 logger.warning("Defensive session cleanup failed for %s: %s", transport.session_id, e)
6893 # Extract auth token from request (header OR cookie, like get_current_user_with_permissions)
6894 auth_token = None
6895 auth_header = request.headers.get("authorization", "")
6896 if auth_header.lower().startswith("bearer "):
6897 auth_token = auth_header[7:]
6898 elif hasattr(request, "cookies") and request.cookies:
6899 # Cookie auth (admin UI sessions)
6900 auth_token = request.cookies.get("jwt_token") or request.cookies.get("access_token")
6902 # Extract and normalize token teams
6903 # Returns None if no JWT payload (non-JWT auth), or list if JWT exists
6904 # SECURITY: Preserve None vs [] distinction for admin bypass:
6905 # - None: unrestricted (admin keeps bypass, non-admin gets their accessible resources)
6906 # - []: public-only (admin bypass disabled)
6907 # - [...]: team-scoped access
6908 token_teams = _get_token_teams_from_request(request)
6910 # Preserve is_admin from user object (for cookie-authenticated admins)
6911 is_admin = False
6912 if hasattr(user, "is_admin"):
6913 is_admin = getattr(user, "is_admin", False)
6914 elif isinstance(user, dict):
6915 is_admin = user.get("is_admin", False) or user.get("user", {}).get("is_admin", False)
6917 # Create enriched user dict
6918 user_with_token = dict(user) if isinstance(user, dict) else {"email": getattr(user, "email", str(user))}
6919 user_with_token["auth_token"] = auth_token
6920 user_with_token["token_teams"] = token_teams # None for unrestricted, [] for public-only, [...] for team-scoped
6921 user_with_token["is_admin"] = is_admin # Preserve admin status for fallback token
6923 # Create respond task and register for cancellation on disconnect
6924 respond_task = asyncio.create_task(session_registry.respond(None, user_with_token, session_id=transport.session_id))
6925 session_registry.register_respond_task(transport.session_id, respond_task)
6927 try:
6928 response = await transport.create_sse_response(request, on_disconnect_callback=on_disconnect_cleanup)
6929 except asyncio.CancelledError:
6930 # Request cancelled - still need to clean up to prevent orphaned tasks
6931 logger.debug("SSE request cancelled for %s, cleaning up", transport.session_id)
6932 try:
6933 await session_registry.remove_session(transport.session_id)
6934 except Exception as cleanup_error:
6935 logger.warning("Cleanup after SSE cancellation failed: %s", cleanup_error)
6936 raise # Re-raise CancelledError
6937 except Exception as sse_error:
6938 # CRITICAL: Cleanup on failure - respond task and session would be orphaned otherwise
6939 logger.error("create_sse_response failed for %s: %s", transport.session_id, sse_error)
6940 try:
6941 await session_registry.remove_session(transport.session_id)
6942 except Exception as cleanup_error:
6943 logger.warning("Cleanup after SSE failure also failed: %s", cleanup_error)
6944 raise
6946 tasks = BackgroundTasks()
6947 tasks.add_task(session_registry.remove_session, transport.session_id)
6948 response.background = tasks
6949 logger.info("SSE connection established: %s", transport.session_id)
6950 return response
6951 except Exception as e:
6952 logger.error("SSE connection error: %s", e)
6953 raise HTTPException(status_code=500, detail="SSE connection failed")
6956@utility_router.post("/message")
6957@require_permission("tools.execute")
6958async def utility_message_endpoint(request: Request, user=Depends(get_current_user_with_permissions)):
6959 """
6960 Handle a JSON-RPC message directed to a specific SSE session.
6962 Args:
6963 request (Request): Incoming request containing the JSON-RPC payload.
6964 user (str): Authenticated user.
6966 Returns:
6967 JSONResponse: ``{"status": "success"}`` with HTTP 202 on success.
6969 Raises:
6970 HTTPException: * **400 Bad Request** - ``session_id`` query parameter is missing or the payload cannot be parsed as JSON.
6971 * **500 Internal Server Error** - An unexpected error occurs while broadcasting the message.
6972 """
6973 try:
6974 logger.debug("User %s sent a message to SSE session", user)
6976 session_id = request.query_params.get("session_id")
6977 if not session_id:
6978 logger.error("Missing session_id in message request")
6979 raise HTTPException(status_code=400, detail="Missing session_id")
6981 await _assert_session_owner_or_admin(request, user, session_id)
6983 message = await _read_request_json(request)
6985 await session_registry.broadcast(
6986 session_id=session_id,
6987 message=message,
6988 )
6990 return ORJSONResponse(content={"status": "success"}, status_code=202)
6992 except ValueError as e:
6993 logger.error("Invalid message format: %s", e)
6994 raise HTTPException(status_code=400, detail=str(e))
6995 except HTTPException:
6996 raise
6997 except Exception as exc:
6998 logger.error("Message handling error: %s", exc)
6999 raise HTTPException(status_code=500, detail="Failed to process message")
7002@utility_router.post("/logging/setLevel")
7003@require_permission("admin.system_config")
7004async def set_log_level(request: Request, user=Depends(get_current_user_with_permissions)) -> None:
7005 """
7006 Update the server's log level at runtime.
7008 Args:
7009 request: HTTP request with log level JSON body.
7010 user: Authenticated user.
7011 """
7012 logger.debug(f"User {user} requested to set log level")
7013 body = await _read_request_json(request)
7014 level = LogLevel(body["level"])
7015 await logging_service.set_level(level)
7018####################
7019# Metrics #
7020####################
7021@metrics_router.get("", response_model=MetricsResponse)
7022@require_permission("admin.metrics")
7023async def get_metrics(db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> MetricsResponse:
7024 """
7025 Retrieve aggregated metrics for all entity types (Tools, Resources, Servers, Prompts, A2A Agents).
7027 Args:
7028 db: Database session
7029 user: Authenticated user
7031 Returns:
7032 A MetricsResponse with keys for each entity type and their aggregated metrics.
7033 """
7034 logger.debug(f"User {user} requested aggregated metrics")
7035 tool_metrics = await tool_service.aggregate_metrics(db)
7036 resource_metrics = await resource_service.aggregate_metrics(db)
7037 server_metrics = await server_service.aggregate_metrics(db)
7038 prompt_metrics = await prompt_service.aggregate_metrics(db)
7040 kwargs = {
7041 "tools": tool_metrics,
7042 "resources": resource_metrics,
7043 "servers": server_metrics,
7044 "prompts": prompt_metrics,
7045 }
7047 if a2a_service and settings.mcpgateway_a2a_metrics_enabled:
7048 kwargs["a2a_agents"] = await a2a_service.aggregate_metrics(db)
7050 return MetricsResponse(**kwargs)
7053@metrics_router.post("/reset", response_model=dict)
7054@require_permission("admin.metrics")
7055async def reset_metrics(entity: Optional[str] = None, entity_id: Optional[int] = None, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> dict:
7056 """
7057 Reset metrics for a specific entity type and optionally a specific entity ID,
7058 or perform a global reset if no entity is specified.
7060 Args:
7061 entity: One of "tool", "resource", "server", "prompt", "a2a_agent", or None for global reset.
7062 entity_id: Specific entity ID to reset metrics for (optional).
7063 db: Database session
7064 user: Authenticated user
7066 Returns:
7067 A success message in a dictionary.
7069 Raises:
7070 HTTPException: If an invalid entity type is specified.
7071 """
7072 logger.debug(f"User {user} requested metrics reset for entity: {entity}, id: {entity_id}")
7073 if entity is None:
7074 # Global reset
7075 await tool_service.reset_metrics(db)
7076 await resource_service.reset_metrics(db)
7077 await server_service.reset_metrics(db)
7078 await prompt_service.reset_metrics(db)
7079 if a2a_service and settings.mcpgateway_a2a_metrics_enabled:
7080 await a2a_service.reset_metrics(db)
7081 elif entity.lower() == "tool":
7082 await tool_service.reset_metrics(db, entity_id)
7083 elif entity.lower() == "resource":
7084 await resource_service.reset_metrics(db)
7085 elif entity.lower() == "server":
7086 await server_service.reset_metrics(db)
7087 elif entity.lower() == "prompt":
7088 await prompt_service.reset_metrics(db)
7089 elif entity.lower() in ("a2a_agent", "a2a"):
7090 if a2a_service and settings.mcpgateway_a2a_metrics_enabled:
7091 await a2a_service.reset_metrics(db, str(entity_id) if entity_id is not None else None)
7092 else:
7093 raise HTTPException(status_code=400, detail="A2A features are disabled")
7094 else:
7095 raise HTTPException(status_code=400, detail="Invalid entity type for metrics reset")
7096 return {"status": "success", "message": f"Metrics reset for {entity if entity else 'all entities'}"}
7099####################
7100# Healthcheck #
7101####################
7102@app.get("/health")
7103def healthcheck():
7104 """
7105 Perform a basic health check to verify database connectivity.
7107 Sync function so FastAPI runs it in a threadpool, avoiding event loop blocking.
7108 Uses a dedicated session to avoid cross-thread issues and double-commit
7109 from get_db dependency. All DB operations happen in the same thread.
7111 Returns:
7112 A dictionary with the health status and optional error message.
7113 """
7114 db = SessionLocal()
7115 try:
7116 db.execute(text("SELECT 1"))
7117 # Explicitly commit to release PgBouncer backend connection in transaction mode.
7118 db.commit()
7119 return {"status": "healthy"}
7120 except Exception as e:
7121 # Rollback, then invalidate if rollback fails (mirrors get_db cleanup).
7122 try:
7123 db.rollback()
7124 except Exception:
7125 try:
7126 db.invalidate()
7127 except Exception:
7128 pass # nosec B110 - Best effort cleanup on connection failure
7129 error_message = f"Database connection error: {str(e)}"
7130 logger.error(error_message)
7131 return {"status": "unhealthy", "error": error_message}
7132 finally:
7133 db.close()
7136@app.get("/ready")
7137async def readiness_check():
7138 """
7139 Perform a readiness check to verify if the application is ready to receive traffic.
7141 Creates and manages its own session inside the worker thread to ensure all DB
7142 operations (create, execute, commit, rollback, close) happen in the same thread.
7143 This avoids cross-thread session issues and double-commit from get_db.
7145 Returns:
7146 JSONResponse with status 200 if ready, 503 if not.
7147 """
7149 def _check_db() -> str | None:
7150 """Check database connectivity by executing a simple query.
7152 Returns:
7153 None if successful, error message string if failed.
7154 """
7155 # Create session in this thread - all DB operations stay in the same thread.
7156 db = SessionLocal()
7157 try:
7158 db.execute(text("SELECT 1"))
7159 # Explicitly commit to release PgBouncer backend connection.
7160 db.commit()
7161 return None # Success
7162 except Exception as e:
7163 # Rollback, then invalidate if rollback fails (mirrors get_db cleanup).
7164 try:
7165 db.rollback()
7166 except Exception:
7167 try:
7168 db.invalidate()
7169 except Exception:
7170 pass # nosec B110 - Best effort cleanup on connection failure
7171 return str(e)
7172 finally:
7173 db.close()
7175 # Run the blocking DB check in a thread to avoid blocking the event loop.
7176 error = await asyncio.to_thread(_check_db)
7177 if error:
7178 error_message = f"Readiness check failed: {error}"
7179 logger.error(error_message)
7180 return ORJSONResponse(content={"status": "not ready", "error": error_message}, status_code=503)
7181 return ORJSONResponse(content={"status": "ready"}, status_code=200)
7184@app.get("/health/security", tags=["health"])
7185async def security_health(request: Request, _user=Depends(require_admin_auth)): # pylint: disable=unused-argument
7186 """
7187 Get the security configuration health status (admin only).
7189 Args:
7190 request (Request): The incoming HTTP request.
7191 _user: Authenticated admin user (injected by require_admin_auth).
7193 Returns:
7194 dict: A dictionary containing the overall security health status, score,
7195 individual checks, warning count, and timestamp.
7196 """
7197 security_status = settings.get_security_status()
7199 # Determine overall health
7200 score = security_status["security_score"]
7201 is_healthy = score >= 60 # Minimum acceptable score
7203 # Build response
7204 response = {
7205 "status": "healthy" if is_healthy else "unhealthy",
7206 "score": score,
7207 "checks": {
7208 "authentication": security_status["auth_enabled"],
7209 "secure_secrets": security_status["secure_secrets"],
7210 "ssl_verification": security_status["ssl_verification"],
7211 "debug_disabled": security_status["debug_disabled"],
7212 "cors_restricted": security_status["cors_restricted"],
7213 "ui_protected": security_status["ui_protected"],
7214 },
7215 "warning_count": len(security_status["warnings"]),
7216 "timestamp": datetime.now(timezone.utc).isoformat(),
7217 }
7219 # Include warnings for admin users
7220 if security_status["warnings"]:
7221 response["warnings"] = security_status["warnings"]
7223 return response
7226####################
7227# Tag Endpoints #
7228####################
7231@tag_router.get("", response_model=List[TagInfo])
7232@tag_router.get("/", response_model=List[TagInfo])
7233@require_permission("tags.read")
7234async def list_tags(
7235 request: Request,
7236 entity_types: Optional[str] = None,
7237 include_entities: bool = False,
7238 db: Session = Depends(get_db),
7239 user=Depends(get_current_user_with_permissions),
7240) -> List[TagInfo]:
7241 """
7242 Retrieve all unique tags across specified entity types.
7244 Args:
7245 request: FastAPI request object used to derive token/team visibility scope
7246 entity_types: Comma-separated list of entity types to filter by
7247 (e.g., "tools,resources,prompts,servers,gateways").
7248 If not provided, returns tags from all entity types.
7249 include_entities: Whether to include the list of entities that have each tag
7250 db: Database session
7251 user: Authenticated user
7253 Returns:
7254 List of TagInfo objects containing tag names, statistics, and optionally entities
7256 Raises:
7257 HTTPException: If tag retrieval fails
7258 """
7259 # Parse entity types parameter if provided
7260 entity_types_list = None
7261 if entity_types:
7262 entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()]
7264 logger.debug(f"User {user} is retrieving tags for entity types: {entity_types_list}, include_entities: {include_entities}")
7266 try:
7267 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
7268 if is_admin and token_teams is None:
7269 user_email = None
7270 elif token_teams is None:
7271 token_teams = []
7273 tags = await tag_service.get_all_tags(
7274 db,
7275 entity_types=entity_types_list,
7276 include_entities=include_entities,
7277 user_email=user_email,
7278 token_teams=token_teams,
7279 )
7280 return tags
7281 except Exception as e:
7282 logger.error(f"Failed to retrieve tags: {str(e)}")
7283 raise HTTPException(status_code=500, detail=f"Failed to retrieve tags: {str(e)}")
7286@tag_router.get("/{tag_name}/entities", response_model=List[TaggedEntity])
7287@require_permission("tags.read")
7288async def get_entities_by_tag(
7289 request: Request,
7290 tag_name: str,
7291 entity_types: Optional[str] = None,
7292 db: Session = Depends(get_db),
7293 user=Depends(get_current_user_with_permissions),
7294) -> List[TaggedEntity]:
7295 """
7296 Get all entities that have a specific tag.
7298 Args:
7299 request: FastAPI request object used to derive token/team visibility scope
7300 tag_name: The tag to search for
7301 entity_types: Comma-separated list of entity types to filter by
7302 (e.g., "tools,resources,prompts,servers,gateways").
7303 If not provided, returns entities from all types.
7304 db: Database session
7305 user: Authenticated user
7307 Returns:
7308 List of TaggedEntity objects
7310 Raises:
7311 HTTPException: If entity retrieval fails
7312 """
7313 # Parse entity types parameter if provided
7314 entity_types_list = None
7315 if entity_types:
7316 entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()]
7318 logger.debug(f"User {user} is retrieving entities for tag '{tag_name}' with entity types: {entity_types_list}")
7320 try:
7321 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
7322 if is_admin and token_teams is None:
7323 user_email = None
7324 elif token_teams is None:
7325 token_teams = []
7327 entities = await tag_service.get_entities_by_tag(
7328 db,
7329 tag_name=tag_name,
7330 entity_types=entity_types_list,
7331 user_email=user_email,
7332 token_teams=token_teams,
7333 )
7334 return entities
7335 except Exception as e:
7336 logger.error(f"Failed to retrieve entities for tag '{tag_name}': {str(e)}")
7337 raise HTTPException(status_code=500, detail=f"Failed to retrieve entities: {str(e)}")
7340####################
7341# Export/Import #
7342####################
7345@export_import_router.get("/export", response_model=Dict[str, Any])
7346@require_permission("admin.export")
7347async def export_configuration(
7348 request: Request, # pylint: disable=unused-argument
7349 export_format: str = "json", # pylint: disable=unused-argument
7350 types: Optional[str] = None,
7351 exclude_types: Optional[str] = None,
7352 tags: Optional[str] = None,
7353 include_inactive: bool = False,
7354 include_dependencies: bool = True,
7355 db: Session = Depends(get_db),
7356 user=Depends(get_current_user_with_permissions),
7357) -> Dict[str, Any]:
7358 """
7359 Export gateway configuration to JSON format.
7361 Args:
7362 request: FastAPI request object for extracting root path
7363 export_format: Export format (currently only 'json' supported)
7364 types: Comma-separated list of entity types to include (tools,gateways,servers,prompts,resources,roots)
7365 exclude_types: Comma-separated list of entity types to exclude
7366 tags: Comma-separated list of tags to filter by
7367 include_inactive: Whether to include inactive entities
7368 include_dependencies: Whether to include dependent entities
7369 db: Database session
7370 user: Authenticated user
7372 Returns:
7373 Export data in the specified format
7375 Raises:
7376 HTTPException: If export fails
7377 """
7378 try:
7379 logger.info(f"User {user} requested configuration export")
7380 username: Optional[str] = None
7381 # Parse parameters
7382 include_types = None
7383 if types:
7384 include_types = [t.strip() for t in types.split(",") if t.strip()]
7386 exclude_types_list = None
7387 if exclude_types:
7388 exclude_types_list = [t.strip() for t in exclude_types.split(",") if t.strip()]
7390 tags_list = None
7391 if tags:
7392 tags_list = [t.strip() for t in tags.split(",") if t.strip()]
7394 # Extract username from user (which is now an EmailUser object)
7395 if hasattr(user, "email"):
7396 username = getattr(user, "email", None)
7397 elif isinstance(user, dict):
7398 username = user.get("email", None)
7399 else:
7400 username = None
7402 # Get root path for URL construction - prefer configured APP_ROOT_PATH
7403 root_path = settings.app_root_path
7405 # Derive team-scoped visibility from the requesting user's token
7406 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user)
7408 # Perform export
7409 export_data = await export_service.export_configuration(
7410 db=db,
7411 include_types=include_types,
7412 exclude_types=exclude_types_list,
7413 tags=tags_list,
7414 include_inactive=include_inactive,
7415 include_dependencies=include_dependencies,
7416 exported_by=username or "unknown",
7417 root_path=root_path,
7418 user_email=scoped_user_email,
7419 token_teams=scoped_token_teams,
7420 )
7422 return export_data
7424 except ExportError as e:
7425 logger.error(f"Export failed for user {user}: {str(e)}")
7426 raise HTTPException(status_code=400, detail=str(e))
7427 except Exception as e:
7428 logger.error(f"Unexpected export error for user {user}: {str(e)}")
7429 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
7432@export_import_router.post("/export/selective", response_model=Dict[str, Any])
7433@require_permission("admin.export")
7434async def export_selective_configuration(
7435 request: Request, entity_selections: Dict[str, List[str]] = Body(...), include_dependencies: bool = True, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)
7436) -> Dict[str, Any]:
7437 """
7438 Export specific entities by their IDs/names.
7440 Args:
7441 request: FastAPI request object for token scope context
7442 entity_selections: Dict mapping entity types to lists of IDs/names to export
7443 include_dependencies: Whether to include dependent entities
7444 db: Database session
7445 user: Authenticated user
7447 Returns:
7448 Selective export data
7450 Raises:
7451 HTTPException: If export fails
7453 Example request body:
7454 {
7455 "tools": ["tool1", "tool2"],
7456 "servers": ["server1"],
7457 "prompts": ["prompt1"]
7458 }
7459 """
7460 try:
7461 logger.info(f"User {user} requested selective configuration export")
7463 username: Optional[str] = None
7464 # Extract username from user (which is now an EmailUser object)
7465 if hasattr(user, "email"):
7466 username = getattr(user, "email", None)
7467 elif isinstance(user, dict):
7468 username = user.get("email")
7470 # Get root path for URL construction - prefer configured APP_ROOT_PATH
7471 root_path = settings.app_root_path
7473 # Derive team-scoped visibility from the requesting user's token
7474 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user)
7476 export_data = await export_service.export_selective(
7477 db=db,
7478 entity_selections=entity_selections,
7479 include_dependencies=include_dependencies,
7480 exported_by=username or "unknown",
7481 root_path=root_path,
7482 user_email=scoped_user_email,
7483 token_teams=scoped_token_teams,
7484 )
7486 return export_data
7488 except ExportError as e:
7489 logger.error(f"Selective export failed for user {user}: {str(e)}")
7490 raise HTTPException(status_code=400, detail=str(e))
7491 except Exception as e:
7492 logger.error(f"Unexpected selective export error for user {user}: {str(e)}")
7493 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
7496@export_import_router.post("/import", response_model=Dict[str, Any])
7497@require_permission("admin.import")
7498async def import_configuration(
7499 import_data: Dict[str, Any] = Body(...),
7500 conflict_strategy: str = "update",
7501 dry_run: bool = False,
7502 rekey_secret: Optional[str] = None,
7503 selected_entities: Optional[Dict[str, List[str]]] = None,
7504 db: Session = Depends(get_db),
7505 user=Depends(get_current_user_with_permissions),
7506) -> Dict[str, Any]:
7507 """
7508 Import configuration data with conflict resolution.
7510 Args:
7511 import_data: The configuration data to import
7512 conflict_strategy: How to handle conflicts: skip, update, rename, fail
7513 dry_run: If true, validate but don't make changes
7514 rekey_secret: New encryption secret for cross-environment imports
7515 selected_entities: Dict of entity types to specific entity names/ids to import
7516 db: Database session
7517 user: Authenticated user
7519 Returns:
7520 Import status and results
7522 Raises:
7523 HTTPException: If import fails or validation errors occur
7524 """
7525 try:
7526 logger.info(f"User {user} requested configuration import (dry_run={dry_run})")
7528 # Validate conflict strategy
7529 try:
7530 strategy = ConflictStrategy(conflict_strategy.lower())
7531 except ValueError:
7532 raise HTTPException(status_code=400, detail=f"Invalid conflict strategy. Must be one of: {[s.value for s in list(ConflictStrategy)]}")
7534 # Extract username from user (which is now an EmailUser object)
7535 if hasattr(user, "email"):
7536 username = getattr(user, "email", None)
7537 elif isinstance(user, dict):
7538 username = user.get("email", None)
7539 else:
7540 username = None
7542 # Perform import
7543 import_status = await import_service.import_configuration(
7544 db=db, import_data=import_data, conflict_strategy=strategy, dry_run=dry_run, rekey_secret=rekey_secret, imported_by=username or "unknown", selected_entities=selected_entities
7545 )
7547 return import_status.to_dict()
7549 except ImportValidationError as e:
7550 logger.error(f"Import validation failed for user {user}: {str(e)}")
7551 raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
7552 except ImportConflictError as e:
7553 logger.error(f"Import conflict for user {user}: {str(e)}")
7554 raise HTTPException(status_code=409, detail=f"Conflict error: {str(e)}")
7555 except ImportServiceError as e:
7556 logger.error(f"Import failed for user {user}: {str(e)}")
7557 raise HTTPException(status_code=400, detail=str(e))
7558 except Exception as e:
7559 logger.error(f"Unexpected import error for user {user}: {str(e)}")
7560 raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
7563@export_import_router.get("/import/status/{import_id}", response_model=Dict[str, Any])
7564@require_permission("admin.import")
7565async def get_import_status(import_id: str, user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
7566 """
7567 Get the status of an import operation.
7569 Args:
7570 import_id: The import operation ID
7571 user: Authenticated user
7573 Returns:
7574 Import status information
7576 Raises:
7577 HTTPException: If import not found
7578 """
7579 logger.debug(f"User {user} requested import status for {import_id}")
7581 import_status = import_service.get_import_status(import_id)
7582 if not import_status:
7583 raise HTTPException(status_code=404, detail=f"Import {import_id} not found")
7585 return import_status.to_dict()
7588@export_import_router.get("/import/status", response_model=List[Dict[str, Any]])
7589@require_permission("admin.import")
7590async def list_import_statuses(user=Depends(get_current_user_with_permissions)) -> List[Dict[str, Any]]:
7591 """
7592 List all import operation statuses.
7594 Args:
7595 user: Authenticated user
7597 Returns:
7598 List of import status information
7599 """
7600 logger.debug(f"User {user} requested all import statuses")
7602 statuses = import_service.list_import_statuses()
7603 return [status.to_dict() for status in statuses]
7606@export_import_router.post("/import/cleanup", response_model=Dict[str, Any])
7607@require_permission("admin.import")
7608async def cleanup_import_statuses(max_age_hours: int = 24, user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
7609 """
7610 Clean up completed import statuses older than specified age.
7612 Args:
7613 max_age_hours: Maximum age in hours for keeping completed imports
7614 user: Authenticated user
7616 Returns:
7617 Cleanup results
7618 """
7619 logger.info(f"User {user} requested import status cleanup (max_age_hours={max_age_hours})")
7621 removed_count = import_service.cleanup_completed_imports(max_age_hours)
7622 return {"status": "success", "message": f"Cleaned up {removed_count} completed import statuses", "removed_count": removed_count}
7625# Mount static files
7626# app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static")
7628# Include routers
7629app.include_router(version_router)
7630app.include_router(protocol_router)
7631app.include_router(tool_router)
7632app.include_router(resource_router)
7633app.include_router(prompt_router)
7634app.include_router(gateway_router)
7635app.include_router(root_router)
7636app.include_router(utility_router)
7637app.include_router(server_router)
7638app.include_router(server_well_known_router, prefix="/servers")
7639app.include_router(metrics_router)
7640app.include_router(tag_router)
7641app.include_router(export_import_router)
7643# Include log search router if structured logging is enabled
7644if getattr(settings, "structured_logging_enabled", True):
7645 try:
7646 # First-Party
7647 from mcpgateway.routers.log_search import router as log_search_router
7649 app.include_router(log_search_router)
7650 logger.info("Log search router included - structured logging enabled")
7651 except ImportError as e:
7652 logger.warning(f"Failed to import log search router: {e}")
7653else:
7654 logger.info("Log search router not included - structured logging disabled")
7656# Conditionally include observability router if enabled
7657if settings.observability_enabled:
7658 # First-Party
7659 from mcpgateway.routers.observability import router as observability_router
7661 app.include_router(observability_router)
7662 logger.info("Observability router included - observability API endpoints enabled")
7663else:
7664 logger.info("Observability router not included - observability disabled")
7666# Conditionally include metrics maintenance router if cleanup or rollup is enabled
7667if settings.metrics_cleanup_enabled or settings.metrics_rollup_enabled:
7668 # First-Party
7669 from mcpgateway.routers.metrics_maintenance import router as metrics_maintenance_router
7671 app.include_router(metrics_maintenance_router)
7672 logger.info("Metrics maintenance router included - cleanup/rollup API endpoints enabled")
7674# Conditionally include A2A router if A2A features are enabled
7675if settings.mcpgateway_a2a_enabled:
7676 app.include_router(a2a_router)
7677 logger.info("A2A router included - A2A features enabled")
7678else:
7679 logger.info("A2A router not included - A2A features disabled")
7681app.include_router(well_known_router)
7683# Include Email Authentication router if enabled
7684if settings.email_auth_enabled:
7685 try:
7686 # First-Party
7687 from mcpgateway.routers.auth import auth_router
7688 from mcpgateway.routers.email_auth import email_auth_router
7690 app.include_router(email_auth_router, prefix="/auth/email", tags=["Email Authentication"])
7691 app.include_router(auth_router, tags=["Main Authentication"])
7692 logger.info("Authentication routers included - Auth enabled")
7694 # Include SSO router if enabled
7695 if settings.sso_enabled:
7696 try:
7697 # First-Party
7698 from mcpgateway.routers.sso import sso_router
7700 app.include_router(sso_router, tags=["SSO Authentication"])
7701 logger.info("SSO router included - SSO authentication enabled")
7702 except ImportError as e:
7703 logger.error(f"SSO router not available: {e}")
7704 else:
7705 logger.info("SSO router not included - SSO authentication disabled")
7706 except ImportError as e:
7707 logger.error(f"Authentication routers not available: {e}")
7708else:
7709 logger.info("Email authentication router not included - Email auth disabled")
7711# Include Team Management router if email auth is enabled
7712if settings.email_auth_enabled:
7713 try:
7714 # First-Party
7715 from mcpgateway.routers.teams import teams_router
7717 app.include_router(teams_router, prefix="/teams", tags=["Teams"])
7718 logger.info("Team management router included - Teams enabled with email auth")
7719 except ImportError as e:
7720 logger.error(f"Team management router not available: {e}")
7721else:
7722 logger.info("Team management router not included - Email auth disabled")
7724# Include JWT Token Catalog router if email auth is enabled
7725if settings.email_auth_enabled:
7726 try:
7727 # First-Party
7728 from mcpgateway.routers.tokens import router as tokens_router
7730 app.include_router(tokens_router, tags=["JWT Token Catalog"])
7731 logger.info("JWT Token Catalog router included - Token management enabled with email auth")
7732 except ImportError as e:
7733 logger.error(f"JWT Token Catalog router not available: {e}")
7734else:
7735 logger.info("JWT Token Catalog router not included - Email auth disabled")
7737# Include RBAC router if email auth is enabled
7738if settings.email_auth_enabled:
7739 try:
7740 # First-Party
7741 from mcpgateway.routers.rbac import router as rbac_router
7743 app.include_router(rbac_router, tags=["RBAC"])
7744 logger.info("RBAC router included - Role-based access control enabled")
7745 except ImportError as e:
7746 logger.error(f"RBAC router not available: {e}")
7747else:
7748 logger.info("RBAC router not included - Email auth disabled")
7750# Include OAuth router
7751try:
7752 # First-Party
7753 from mcpgateway.routers.oauth_router import oauth_router
7755 app.include_router(oauth_router)
7756 logger.info("OAuth router included")
7757except ImportError:
7758 logger.debug("OAuth router not available")
7760# Include reverse proxy router if enabled
7761if settings.mcpgateway_reverse_proxy_enabled:
7762 try:
7763 # First-Party
7764 from mcpgateway.routers.reverse_proxy import router as reverse_proxy_router
7766 app.include_router(reverse_proxy_router)
7767 logger.info("Reverse proxy router included")
7768 except ImportError:
7769 logger.debug("Reverse proxy router not available")
7770else:
7771 logger.info("Reverse proxy router not included - feature disabled")
7773# Include LLMChat router
7774if settings.llmchat_enabled:
7775 try:
7776 # First-Party
7777 from mcpgateway.routers.llmchat_router import llmchat_router
7779 app.include_router(llmchat_router)
7780 logger.info("LLM Chat router included")
7781 except ImportError:
7782 logger.debug("LLM Chat router not available")
7784 # Include LLM configuration and proxy routers (internal API)
7785 try:
7786 # First-Party
7787 from mcpgateway.routers.llm_admin_router import llm_admin_router
7788 from mcpgateway.routers.llm_config_router import llm_config_router
7789 from mcpgateway.routers.llm_proxy_router import llm_proxy_router
7791 app.include_router(llm_config_router, prefix="/llm", tags=["LLM Configuration"])
7792 app.include_router(llm_proxy_router, prefix=settings.llm_api_prefix, tags=["LLM Proxy"])
7793 app.include_router(llm_admin_router, prefix="/admin/llm", tags=["LLM Admin"])
7794 logger.info("LLM configuration, proxy, and admin routers included")
7795 except ImportError as e:
7796 logger.debug(f"LLM routers not available: {e}")
7798# Include Toolops router
7799if settings.toolops_enabled:
7800 try:
7801 # First-Party
7802 from mcpgateway.routers.toolops_router import toolops_router
7804 app.include_router(toolops_router)
7805 logger.info("Toolops router included")
7806 except ImportError:
7807 logger.debug("Toolops router not available")
7809# Cancellation router (tool cancellation endpoints)
7810if settings.mcpgateway_tool_cancellation_enabled:
7811 try:
7812 # First-Party
7813 from mcpgateway.routers.cancellation_router import router as cancellation_router
7815 app.include_router(cancellation_router)
7816 logger.info("Cancellation router included (tool cancellation enabled)")
7817 except ImportError:
7818 logger.debug("Orchestrate router not available")
7819else:
7820 logger.info("Tool cancellation feature disabled - cancellation endpoints not available")
7822# Feature flags for admin UI and API
7823UI_ENABLED = settings.mcpgateway_ui_enabled
7824ADMIN_API_ENABLED = settings.mcpgateway_admin_api_enabled
7825logger.info(f"Admin UI enabled: {UI_ENABLED}")
7826logger.info(f"Admin API enabled: {ADMIN_API_ENABLED}")
7828# Conditional UI and admin API handling
7829if ADMIN_API_ENABLED:
7830 logger.info("Including admin_router - Admin API enabled")
7831 app.include_router(admin_router) # Admin routes imported from admin.py
7832else:
7833 logger.warning("Admin API routes not mounted - Admin API disabled via MCPGATEWAY_ADMIN_API_ENABLED=False")
7835# Streamable http Mount
7836app.mount("/mcp", app=streamable_http_session.handle_streamable_http)
7838# Conditional static files mounting and root redirect
7839if UI_ENABLED:
7840 # Mount static files for UI
7841 logger.info("Mounting static files - UI enabled")
7842 try:
7843 # Create a sub-application for static files that will respect root_path
7844 static_app = StaticFiles(directory=str(settings.static_dir))
7845 STATIC_PATH = "/static"
7847 app.mount(
7848 STATIC_PATH,
7849 static_app,
7850 name="static",
7851 )
7852 logger.info("Static assets served from %s at %s", settings.static_dir, STATIC_PATH)
7853 except RuntimeError as exc:
7854 logger.warning(
7855 "Static dir %s not found - Admin UI disabled (%s)",
7856 settings.static_dir,
7857 exc,
7858 )
7860 # Redirect root path to admin UI
7861 @app.get("/")
7862 async def root_redirect():
7863 """
7864 Redirects the root path ("/") to "/admin/".
7866 Logs a debug message before redirecting.
7868 Returns:
7869 RedirectResponse: Redirects to /admin/.
7871 Raises:
7872 HTTPException: If there is an error during redirection.
7873 """
7874 logger.debug("Redirecting root path to /admin/")
7875 root_path = settings.app_root_path
7876 return RedirectResponse(f"{root_path}/admin/", status_code=303)
7877 # return RedirectResponse(request.url_for("admin_home"))
7879 # Redirect /favicon.ico to /static/favicon.ico for browser compatibility
7880 @app.get("/favicon.ico", include_in_schema=False)
7881 async def favicon_redirect() -> RedirectResponse:
7882 """Redirect /favicon.ico to /static/favicon.ico for browser compatibility.
7884 Returns:
7885 RedirectResponse: 301 redirect to /static/favicon.ico.
7886 """
7887 root_path = settings.app_root_path
7888 return RedirectResponse(f"{root_path}/static/favicon.ico", status_code=301)
7890else:
7891 # If UI is disabled, provide API info at root
7892 logger.warning("Static files not mounted - UI disabled via MCPGATEWAY_UI_ENABLED=False")
7894 @app.get("/")
7895 async def root_info():
7896 """
7897 Returns basic API information at the root path.
7899 Logs an info message indicating UI is disabled and provides details
7900 about the app, including its name, version, and whether the UI and
7901 admin API are enabled.
7903 Returns:
7904 dict: API info with app name, version, and UI/admin API status.
7905 """
7906 logger.info("UI disabled, serving API info at root path")
7907 return {"name": settings.app_name, "description": f"{settings.app_name} API"}
7910# Expose some endpoints at the root level as well
7911app.post("/initialize")(initialize)
7912app.post("/notifications")(handle_notification)