Coverage for mcpgateway / main.py: 99%

3058 statements  

« 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 

7 

8ContextForge AI Gateway - Main FastAPI Application. 

9 

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. 

12 

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. 

20 

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""" 

28 

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 

41 

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 

64 

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 

156 

157# Import the admin routes from the new module 

158from mcpgateway.version import router as version_router 

159 

160# Initialize logging service first 

161logging_service = LoggingService() 

162logger = logging_service.get_logger("mcpgateway") 

163 

164# Share the logging service with admin module 

165set_logging_service(logging_service) 

166 

167# Note: Logging configuration is handled by LoggingService during startup 

168# Don't use basicConfig here as it conflicts with our dual logging setup 

169 

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 

172 

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()) 

180 

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 

187 

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 

191 

192 

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 

201 

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 

210 

211# Initialize session manager for Streamable HTTP transport 

212streamable_http_session = SessionManagerWrapper() 

213 

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) 

217 

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) 

227 

228 

229# Helper function for authentication compatibility 

230def get_user_email(user): 

231 """Extract email from user object, handling both string and dict formats. 

232 

233 Args: 

234 user: User object, can be either a dict (new RBAC format) or string (legacy format) 

235 

236 Returns: 

237 str: User email address or 'unknown' if not available 

238 

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' 

245 

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' 

250 

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' 

255 

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' 

260 

261 Test with string user (legacy format): 

262 >>> user_string = 'charlie@company.com' 

263 >>> main.get_user_email(user_string) 

264 'charlie@company.com' 

265 

266 Test with None user: 

267 >>> main.get_user_email(None) 

268 'unknown' 

269 

270 Test with empty dictionary: 

271 >>> main.get_user_email({}) 

272 'unknown' 

273 

274 Test with integer (non-string, non-dict): 

275 >>> main.get_user_email(123) 

276 '123' 

277 

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' 

282 

283 Test with empty string user: 

284 >>> main.get_user_email('') 

285 'unknown' 

286 

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" 

297 

298 

299def _normalize_token_teams(teams: Optional[List]) -> List[str]: 

300 """ 

301 Normalize token teams to list of team IDs. 

302 

303 SSO tokens may contain team dicts like {"id": "...", "name": "..."}. 

304 This normalizes to just IDs for consistent filtering. 

305 

306 Args: 

307 teams: Raw teams from token payload (may be None, list of IDs, or list of dicts) 

308 

309 Returns: 

310 List of team ID strings (empty list if None) 

311 

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 [] 

327 

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 

337 

338 

339def _get_token_teams_from_request(request: Request) -> Optional[List[str]]: 

340 """ 

341 Extract and normalize teams from verified JWT token. 

342 

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 

349 

350 First checks request.state.token_teams (set by auth.py), then falls back 

351 to calling normalize_token_teams on the JWT payload. 

352 

353 Args: 

354 request: FastAPI request object 

355 

356 Returns: 

357 None for admin bypass, [] for public-only, or list of normalized team ID strings. 

358 

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 

378 

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) 

386 

387 # No JWT payload - return [] for public-only (secure default) 

388 return [] 

389 

390 

391def _get_rpc_filter_context(request: Request, user) -> tuple: 

392 """ 

393 Extract user_email, token_teams, and is_admin for RPC filtering. 

394 

395 Args: 

396 request: FastAPI request object 

397 user: User object from auth dependency 

398 

399 Returns: 

400 Tuple of (user_email, token_teams, is_admin) 

401 

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 

424 

425 # Get normalized teams from verified token 

426 token_teams = _get_token_teams_from_request(request) 

427 

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) 

437 

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 

442 

443 return user_email, token_teams, is_admin 

444 

445 

446def _has_verified_jwt_payload(request: Request) -> bool: 

447 """Return whether request has a verified JWT payload cached in request state. 

448 

449 Args: 

450 request: Incoming request context. 

451 

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]) 

457 

458 

459def _get_request_identity(request: Request, user) -> tuple[str, bool]: 

460 """Return requester email and admin state honoring scoped-token semantics. 

461 

462 Args: 

463 request: Incoming request context. 

464 user: Authenticated user context from dependency resolution. 

465 

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) 

471 

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 

476 

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)) 

482 

483 return resolved_email, token_is_admin or fallback_is_admin 

484 

485 

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. 

488 

489 Args: 

490 request: Incoming request context. 

491 user: Authenticated user context from dependency resolution. 

492 

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) 

498 

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 

505 

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 

511 

512 

513def _build_rpc_permission_user(user, db: Session) -> dict[str, Any]: 

514 """Build PermissionChecker user payload for method-level RPC checks. 

515 

516 Args: 

517 user: Authenticated user context. 

518 db: Active database session. 

519 

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 

528 

529 

530def _extract_scoped_permissions(request: Request) -> set[str] | None: 

531 """Extract token scopes.permissions from cached JWT payload. 

532 

533 Args: 

534 request: Incoming request context. 

535 

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) 

553 

554 

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. 

557 

558 Enforces both layers: 

559 1. Token scopes.permissions cap (if explicit permissions present) 

560 2. RBAC role-based permission check 

561 

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. 

568 

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}) 

578 

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}) 

587 

588 

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. 

591 

592 This provides defense-in-depth for ID-based handlers so they continue to 

593 enforce visibility even if middleware coverage regresses. 

594 

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}``). 

600 

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) 

605 

606 # Admin bypass / unrestricted scope 

607 if scoped_token_teams is None: 

608 return 

609 

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) 

618 

619 

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. 

622 

623 Args: 

624 request: Incoming request context. 

625 user: Authenticated user context. 

626 session_id: Target session identifier. 

627 

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") 

637 

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") 

644 

645 

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. 

648 

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``. 

654 

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) 

662 

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 

674 

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") 

679 

680 

681# Initialize cache 

682resource_cache = ResourceCache(max_size=settings.resource_cache_size, ttl=settings.resource_cache_ttl) 

683 

684 

685@lru_cache(maxsize=512) 

686def _parse_jsonpath(jsonpath: str) -> JSONPath: 

687 """Cache parsed JSONPath expression. 

688 

689 Args: 

690 jsonpath: The JSONPath expression string. 

691 

692 Returns: 

693 Parsed JSONPath object. 

694 

695 Raises: 

696 Exception: If the JSONPath expression is invalid. 

697 """ 

698 return parse(jsonpath) 

699 

700 

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. 

705 

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. 

711 

712 Returns: 

713 Union[List, Dict]: A list (or mapped list) or a Dict of extracted data. 

714 

715 Raises: 

716 HTTPException: If there's an error parsing or executing the JSONPath expressions. 

717 

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 = "$[*]" 

730 

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}") 

735 

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}") 

740 

741 results = [match.value for match in main_matches] 

742 

743 if mappings: 

744 results = transform_data_with_mappings(results, mappings) 

745 

746 if len(results) == 1 and isinstance(results[0], dict): 

747 return results[0] 

748 

749 return results 

750 

751 

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. 

756 

757 Args: 

758 data: The set of data to apply mappings to. 

759 mappings: dictionary of mappings where keys are new field names 

760 

761 Returns: 

762 list[Any]: A list (or mapped list) of re-mapped data 

763 

764 Raises: 

765 HTTPException: If there's an error parsing or executing the JSONPath expressions. 

766 

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}") 

778 

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}") 

787 

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) 

795 

796 return mapped_results 

797 

798 

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 

806 

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}") 

811 

812 

813#################### 

814# Startup/Shutdown # 

815#################### 

816@asynccontextmanager 

817async def lifespan(_app: FastAPI) -> AsyncIterator[None]: 

818 """ 

819 Manage the application's startup and shutdown lifecycle. 

820 

821 The function initialises every core service on entry and then 

822 shuts them down in reverse order on exit. 

823 

824 Args: 

825 _app (FastAPI): FastAPI app 

826 

827 Yields: 

828 None 

829 

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 

839 

840 # Initialize logging service FIRST to ensure all logging goes to dual output 

841 await logging_service.initialize() 

842 logger.info("Starting ContextForge services") 

843 

844 # Initialize Redis client early (shared pool for all services) 

845 await get_redis_client() 

846 

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 

850 

851 await SharedHttpClient.get_instance() 

852 

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() 

856 

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 

862 

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 ) 

868 

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") 

888 

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 

892 

893 await init_llmchat_redis() 

894 

895 # Initialize observability (Phoenix tracing) 

896 init_telemetry() 

897 logger.info("Observability initialized") 

898 

899 try: 

900 # Validate security configuration 

901 validate_security_configuration() 

902 

903 if plugin_manager: 

904 await plugin_manager.initialize() 

905 logger.info(f"Plugin manager initialized with {plugin_manager.plugin_count} plugins") 

906 

907 if settings.enable_header_passthrough: 

908 await setup_passthrough_headers() 

909 else: 

910 logger.info("🔒 Header Passthrough: DISABLED") 

911 

912 await tool_service.initialize() 

913 await resource_service.initialize() 

914 await prompt_service.initialize() 

915 await gateway_service.initialize() 

916 

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 

921 

922 await start_pool_notification_service(gateway_service) 

923 

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 

928 

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") 

932 

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() 

943 

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") 

950 

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 

955 

956 elicitation_service = get_elicitation_service() 

957 await elicitation_service.start() 

958 logger.info("Elicitation service initialized") 

959 

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 

964 

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)") 

971 

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 

976 

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) 

980 

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 

985 

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) 

989 

990 refresh_slugs_on_startup() 

991 

992 # Bootstrap SSO providers from environment configuration 

993 if settings.sso_enabled: 

994 await attempt_to_bootstrap_sso_providers() 

995 

996 logger.info("All services initialized successfully") 

997 

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 

1001 

1002 cache_invalidation_subscriber = get_cache_invalidation_subscriber() 

1003 await cache_invalidation_subscriber.start() 

1004 

1005 # Reconfigure uvicorn loggers after startup to capture access logs in dual output 

1006 logging_service.configure_uvicorn_after_startup() 

1007 

1008 if settings.metrics_aggregation_enabled and settings.metrics_aggregation_auto_start: 

1009 aggregation_stop_event = asyncio.Event() 

1010 log_aggregator = get_log_aggregator() 

1011 

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) 

1022 

1023 async def run_log_aggregation_loop() -> None: 

1024 """Run continuous log aggregation at configured intervals. 

1025 

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) 

1040 

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") 

1050 

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.") 

1055 

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 

1064 

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 

1076 

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)}") 

1084 

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 

1089 

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}") 

1094 

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 ] 

1113 

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 

1117 

1118 if a2a_service: 

1119 services_to_shutdown.insert(4, a2a_service) # Insert after export_service 

1120 

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 

1125 

1126 elicitation_service = get_elicitation_service() 

1127 services_to_shutdown.insert(5, elicitation_service) 

1128 

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 

1133 

1134 metrics_buffer_service = get_metrics_buffer_service() 

1135 services_to_shutdown.insert(0, metrics_buffer_service) # Shutdown first to flush metrics 

1136 

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 

1141 

1142 metrics_rollup_service = get_metrics_rollup_service() 

1143 services_to_shutdown.insert(1, metrics_rollup_service) 

1144 

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 

1149 

1150 metrics_cleanup_service = get_metrics_cleanup_service() 

1151 services_to_shutdown.insert(2, metrics_cleanup_service) 

1152 

1153 await shutdown_services(services_to_shutdown) 

1154 

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 

1159 

1160 await close_mcp_session_pool() 

1161 

1162 # Shutdown shared HTTP client (after services, before Redis) 

1163 await SharedHttpClient.shutdown() 

1164 

1165 # Close Redis client last (after all services that use it) 

1166 await close_redis_client() 

1167 

1168 logger.info("Shutdown complete") 

1169 

1170 

1171async def shutdown_services(services_to_shutdown: list[Any]): 

1172 """ 

1173 Awaits shutdown of services provided in a list 

1174 

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)}") 

1183 

1184 

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() 

1201 

1202 

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) 

1212 

1213# Setup metrics instrumentation 

1214setup_metrics(app) 

1215 

1216 

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 

1225 

1226 Args: None 

1227 Raises: Passthrough Errors/Exceptions but doesn't raise any of its own. 

1228 """ 

1229 logger.info("🔒 Validating security configuration...") 

1230 

1231 # Get security status 

1232 security_status: settings.SecurityStatus = settings.get_security_status() 

1233 security_warnings = security_status["warnings"] 

1234 

1235 log_security_warnings(security_warnings) 

1236 

1237 # Critical security checks (fail startup only if REQUIRE_STRONG_SECRETS=true) 

1238 critical_issues = [] 

1239 

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!") 

1242 

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!") 

1245 

1246 log_critical_issues(critical_issues) 

1247 

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.") 

1253 

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) 

1260 

1261 log_security_recommendations(security_status) 

1262 

1263 

1264def log_security_warnings(security_warnings: list[str]): 

1265 """Log warnings from list of security warnings provided. 

1266 

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) 

1277 

1278 

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. 

1283 

1284 Args: 

1285 critical_issues: List 

1286 

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) 

1309 

1310 

1311def log_security_recommendations(security_status: settings.SecurityStatus): 

1312 """ 

1313 Log security recommendations based on configuration settings 

1314 

1315 Args: 

1316 security_status (settings.SecurityStatus): The SecurityStatus object for checking and logging current security settings from MCPGateway. 

1317 

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) 

1324 

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))'") 

1328 

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") 

1331 

1332 if not settings.auth_required: 

1333 logger.info(" • Enable authentication: AUTH_REQUIRED=true") 

1334 

1335 if settings.skip_ssl_verify: 

1336 logger.info(" • Enable SSL verification: SKIP_SSL_VERIFY=false") 

1337 

1338 logger.info("=" * 60) 

1339 

1340 logger.info("✅ Security validation completed") 

1341 

1342 

1343# Global exceptions handlers 

1344@app.exception_handler(ValidationError) 

1345async def validation_exception_handler(_request: Request, exc: ValidationError): 

1346 """Handle Pydantic validation errors globally. 

1347 

1348 Intercepts ValidationError exceptions raised anywhere in the application 

1349 and returns a properly formatted JSON error response with detailed 

1350 validation error information. 

1351 

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. 

1357 

1358 Returns: 

1359 JSONResponse: A 422 Unprocessable Entity response with formatted 

1360 validation error details. 

1361 

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)) 

1381 

1382 

1383@app.exception_handler(RequestValidationError) 

1384async def request_validation_exception_handler(_request: Request, exc: RequestValidationError): 

1385 """Handle FastAPI request validation errors (automatic request parsing). 

1386 

1387 This handles ValidationErrors that occur during FastAPI's automatic request 

1388 parsing before the request reaches your endpoint. 

1389 

1390 Args: 

1391 _request: The FastAPI request object that triggered validation error. 

1392 exc: The RequestValidationError exception containing failure details. 

1393 

1394 Returns: 

1395 JSONResponse: A 422 Unprocessable Entity response with error details. 

1396 """ 

1397 if _request.url.path.startswith("/tools"): 

1398 error_details = [] 

1399 

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) 

1412 

1413 response_content = {"detail": error_details} 

1414 return ORJSONResponse(status_code=422, content=response_content) 

1415 return await fastapi_default_validation_handler(_request, exc) 

1416 

1417 

1418@app.exception_handler(IntegrityError) 

1419async def database_exception_handler(_request: Request, exc: IntegrityError): 

1420 """Handle SQLAlchemy database integrity constraint violations globally. 

1421 

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. 

1426 

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. 

1432 

1433 Returns: 

1434 JSONResponse: A 409 Conflict response with formatted database error details. 

1435 

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)) 

1451 

1452 

1453@app.exception_handler(PluginViolationError) 

1454async def plugin_violation_exception_handler(_request: Request, exc: PluginViolationError): 

1455 """Handle plugins violations globally. 

1456 

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. 

1459 

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. 

1465 

1466 Returns: 

1467 JSONResponse: A 200 response with error details in JSON-RPC format. 

1468 

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()}) 

1510 

1511 

1512@app.exception_handler(PluginError) 

1513async def plugin_exception_handler(_request: Request, exc: PluginError): 

1514 """Handle plugins errors globally. 

1515 

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. 

1518 

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. 

1524 

1525 Returns: 

1526 JSONResponse: A 200 response with error details in JSON-RPC format. 

1527 

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()}) 

1567 

1568 

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. 

1571 

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"``. 

1577 

1578 Args: 

1579 scope_path: The full path from the request scope. 

1580 root_path: The root path prefix to be stripped. 

1581 

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 

1594 

1595 

1596class DocsAuthMiddleware(BaseHTTPMiddleware): 

1597 """ 

1598 Middleware to protect FastAPI's auto-generated documentation routes 

1599 (/docs, /redoc, and /openapi.json) using Bearer token authentication. 

1600 

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. 

1603 

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). 

1607 

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 """ 

1612 

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. 

1617 

1618 Args: 

1619 request (Request): The incoming HTTP request. 

1620 call_next (Callable): The function to call the next middleware or endpoint. 

1621 

1622 Returns: 

1623 Response: Either the standard route response or a 401/403 error response. 

1624 

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"] 

1650 

1651 # Allow OPTIONS requests to pass through for CORS preflight (RFC 7231) 

1652 if request.method == "OPTIONS": 

1653 return await call_next(request) 

1654 

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) 

1659 

1660 is_protected = any(scope_path.startswith(p) for p in protected_paths) 

1661 

1662 if is_protected: 

1663 try: 

1664 token = request.headers.get("Authorization") 

1665 cookie_token = request.cookies.get("jwt_token") 

1666 

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) 

1671 

1672 # Proceed to next middleware or route 

1673 return await call_next(request) 

1674 

1675 

1676class AdminAuthMiddleware(BaseHTTPMiddleware): 

1677 """ 

1678 Middleware to protect Admin UI routes (/admin/*) requiring admin privileges. 

1679 

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 

1686 

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. 

1689 

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 """ 

1694 

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 ] 

1703 

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. 

1707 

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. 

1714 

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}) 

1728 

1729 async def dispatch(self, request: Request, call_next): # pylint: disable=too-many-return-statements 

1730 """ 

1731 Check admin privileges for admin routes. 

1732 

1733 Args: 

1734 request (Request): The incoming HTTP request. 

1735 call_next (Callable): The function to call the next middleware or endpoint. 

1736 

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) 

1744 

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) 

1749 

1750 # Allow OPTIONS requests for CORS preflight (RFC 7231) 

1751 if request.method == "OPTIONS": 

1752 return await call_next(request) 

1753 

1754 # Check if this is an admin route 

1755 is_admin_route = scope_path.startswith("/admin") 

1756 

1757 if not is_admin_route: 

1758 return await call_next(request) 

1759 

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) 

1764 

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") 

1769 

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] 

1776 

1777 username = None 

1778 token_teams = None 

1779 

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") 

1785 

1786 if not username: 

1787 return ORJSONResponse(status_code=401, content={"detail": "Invalid token"}) 

1788 

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 

1800 

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) 

1814 

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}") 

1822 

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. 

1828 

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}") 

1835 

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") 

1839 

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") 

1845 

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) 

1851 

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") 

1865 

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() 

1887 

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"}) 

1893 

1894 # Proceed to next middleware or route 

1895 return await call_next(request) 

1896 

1897 

1898class MCPPathRewriteMiddleware: 

1899 """ 

1900 Middleware that rewrites paths ending with '/mcp' to '/mcp/', after performing authentication. 

1901 

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. 

1908 

1909 Attributes: 

1910 application (Callable): The next ASGI application to process the request. 

1911 """ 

1912 

1913 def __init__(self, application, dispatch=None): 

1914 """ 

1915 Initialize the middleware with the ASGI application. 

1916 

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. 

1920 

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 

1931 

1932 async def __call__(self, scope, receive, send): 

1933 """ 

1934 Intercept and potentially rewrite the incoming HTTP request path. 

1935 

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. 

1940 

1941 Examples: 

1942 >>> import asyncio 

1943 >>> from unittest.mock import AsyncMock, patch 

1944 >>> app_mock = AsyncMock() 

1945 >>> middleware = MCPPathRewriteMiddleware(app_mock) 

1946 

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() 

1956 

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 

1967 

1968 # If a dispatch (request middleware) is provided, adapt it 

1969 if self.dispatch is not None: 

1970 request = starletteRequest(scope, receive=receive) 

1971 

1972 async def call_next(_req: starletteRequest) -> starletteResponse: 

1973 """ 

1974 Handles the next request in the middleware chain by calling a streamable HTTP response. 

1975 

1976 Args: 

1977 _req (starletteRequest): The incoming request to be processed. 

1978 

1979 Returns: 

1980 starletteResponse: A response generated from the streamable HTTP call. 

1981 """ 

1982 return await self._call_streamable_http(scope, receive, send) 

1983 

1984 response = await self.dispatch(request, call_next) 

1985 

1986 if response is None: 

1987 # Either the dispatch handled the response itself, 

1988 # or it blocked the request. Just return. 

1989 return 

1990 

1991 await response(scope, receive, send) 

1992 return 

1993 

1994 # Otherwise, just continue as normal 

1995 await self._call_streamable_http(scope, receive, send) 

1996 

1997 async def _call_streamable_http(self, scope, receive, send): 

1998 """ 

1999 Handles the streamable HTTP request after authentication and path rewriting. 

2000 

2001 If auth succeeds and path ends with /mcp, rewrites to /mcp/ and calls self.application 

2002 (continuing through middleware stack including CORSMiddleware). 

2003 

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. 

2008 

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 

2025 

2026 original_path = scope.get("path", "") 

2027 scope["modified_path"] = original_path 

2028 

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) 

2038 

2039 

2040# Configure CORS with environment-aware origins 

2041cors_origins = list(settings.allowed_origins) if settings.allowed_origins else [] 

2042 

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 = [] 

2047 

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) 

2057 

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") 

2080 

2081# Add security headers middleware 

2082app.add_middleware(SecurityHeadersMiddleware) 

2083 

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") 

2090 

2091# Add MCP Protocol Version validation middleware (validates MCP-Protocol-Version header) 

2092app.add_middleware(MCPProtocolVersionMiddleware) 

2093 

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) 

2102 

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") 

2107 

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) 

2122 

2123# Add custom DocsAuthMiddleware 

2124app.add_middleware(DocsAuthMiddleware) 

2125 

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) 

2129 

2130# Trust all proxies (or lock down with a list of host patterns) 

2131app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") 

2132 

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})") 

2138 

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 

2145 

2146 app.add_middleware(AuthContextMiddleware) 

2147 logger.info("🔐 Authentication context middleware enabled - logging security events") 

2148else: 

2149 logger.info("🔐 Security event logging disabled") 

2150 

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 

2157 

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") 

2162 

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 

2173 

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") 

2181 

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 

2187 

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)") 

2192 

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) 

2200 

2201 

2202# Add custom filter to decode HTML entities for backward compatibility with old database records 

2203# that were stored with HTML entities (e.g., &#x27; 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. 

2209 

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. 

2212 

2213 TEMPORARY: Can be removed after c1c2c3c4c5c6 migration has been applied to all deployments. 

2214 

2215 Args: 

2216 value: String that may contain HTML entities 

2217 

2218 Returns: 

2219 String with HTML entities decoded to their original characters 

2220 """ 

2221 if not value: 

2222 return value 

2223 

2224 return html.unescape(value) 

2225 

2226 

2227jinja_env.filters["decode_html"] = decode_html_entities 

2228 

2229 

2230def tojson_attr(value: object) -> str: 

2231 """JSON-encode a value for safe use inside double-quoted HTML attributes. 

2232 

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 ``&quot;``, keeping the enclosing 

2236 ``"``-delimited HTML attribute intact. The browser decodes the entities 

2237 back to ``"`` before passing the value to the JS engine. 

2238 

2239 Use ``|tojson_attr`` for inline event handlers (``onclick``, ``onsubmit``). 

2240 Use the built-in ``|tojson`` for ``<script>`` blocks (where ``Markup`` is fine). 

2241 

2242 Args: 

2243 value: Any JSON-serialisable object. 

2244 

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 

2253 

2254 

2255jinja_env.filters["tojson_attr"] = tojson_attr 

2256 

2257templates = Jinja2Templates(env=jinja_env) 

2258if not settings.templates_auto_reload: 

2259 logger.info("🎨 Template auto-reload disabled (production mode)") 

2260app.state.templates = templates 

2261 

2262# Store plugin manager in app state for access in routes 

2263app.state.plugin_manager = plugin_manager 

2264 

2265# Initialize plugin service with plugin manager 

2266if plugin_manager: 

2267 # First-Party 

2268 from mcpgateway.services.plugin_service import get_plugin_service 

2269 

2270 plugin_service = get_plugin_service() 

2271 plugin_service.set_plugin_manager(plugin_manager) 

2272 

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"]) 

2286 

2287# Basic Auth setup 

2288 

2289 

2290# Database dependency 

2291def get_db(): 

2292 """ 

2293 Dependency function to provide a database session. 

2294 

2295 Commits the transaction on successful completion to avoid implicit rollbacks 

2296 for read-only operations. Rolls back explicitly on exception. 

2297 

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. 

2303 

2304 Yields: 

2305 Session: A SQLAlchemy session object for interacting with the database. 

2306 

2307 Raises: 

2308 Exception: Re-raises any exception after rolling back the transaction. 

2309 

2310 Ensures: 

2311 The database session is closed after the request completes, even in the case of an exception. 

2312 

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() 

2356 

2357 

2358async def _read_request_json(request: Request) -> Any: 

2359 """Read JSON payload using orjson. 

2360 

2361 Args: 

2362 request: Incoming FastAPI request to read JSON from. 

2363 

2364 Returns: 

2365 Parsed JSON payload. 

2366 

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 

2377 

2378 

2379def require_api_key(api_key: str) -> None: 

2380 """Validates the provided API key. 

2381 

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. 

2385 

2386 Args: 

2387 api_key (str): The API key provided by the user or client. 

2388 

2389 Raises: 

2390 HTTPException: If the API key is invalid, a 401 Unauthorized error is raised. 

2391 

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") 

2413 

2414 

2415async def invalidate_resource_cache(uri: Optional[str] = None) -> None: 

2416 """ 

2417 Invalidates the resource cache. 

2418 

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. 

2421 

2422 Args: 

2423 uri (Optional[str]): The URI of the resource to invalidate from the cache. If None, the entire cache is cleared. 

2424 

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() 

2446 

2447 

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) 

2453 

2454 Args: 

2455 request (Request): The FastAPI request object. 

2456 

2457 Returns: 

2458 str: The protocol used for the request, either "http" or "https". 

2459 

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' 

2477 

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' 

2489 

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' 

2501 

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 

2519 

2520 

2521def update_url_protocol(request: Request) -> str: 

2522 """ 

2523 Update the base URL protocol based on the request's scheme or forwarded headers. 

2524 

2525 Args: 

2526 request (Request): The FastAPI request object. 

2527 

2528 Returns: 

2529 str: The base URL with the correct protocol. 

2530 

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 

2548 

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 

2561 

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 

2574 

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("/") 

2586 

2587 

2588# Protocol APIs # 

2589@protocol_router.post("/initialize") 

2590async def initialize(request: Request, user=Depends(get_current_user)) -> InitializeResult: 

2591 """ 

2592 Initialize a protocol. 

2593 

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. 

2597 

2598 Args: 

2599 request (Request): The incoming request object containing the JSON body. 

2600 user (str): The authenticated user (from `require_auth` dependency). 

2601 

2602 Returns: 

2603 InitializeResult: The result of the initialization process. 

2604 

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) 

2610 

2611 logger.debug(f"Authenticated user {user} is initializing the protocol.") 

2612 return await session_registry.handle_initialize_logic(body) 

2613 

2614 except orjson.JSONDecodeError: 

2615 raise HTTPException( 

2616 status_code=status.HTTP_400_BAD_REQUEST, 

2617 detail="Invalid JSON in request body", 

2618 ) 

2619 

2620 

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. 

2625 

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. 

2628 

2629 Args: 

2630 request (Request): The incoming FastAPI request. 

2631 user (str): The authenticated user (dependency injection). 

2632 

2633 Returns: 

2634 JSONResponse: A JSON-RPC response with an empty result or an error response. 

2635 

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) 

2656 

2657 

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). 

2663 

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 ) 

2691 

2692 

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. 

2697 

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. 

2702 

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) 

2714 

2715 

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. 

2720 

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. 

2725 

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) 

2732 

2733 

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. 

2754 

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. 

2766 

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) 

2776 

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) 

2780 

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 ) 

2787 

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. 

2791 

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 

2798 

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 ) 

2813 

2814 if include_pagination: 

2815 return CursorPaginatedServersResponse.model_construct(servers=data, next_cursor=next_cursor) 

2816 return data 

2817 

2818 

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. 

2824 

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. 

2830 

2831 Returns: 

2832 ServerRead: The server object with the specified ID. 

2833 

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)) 

2844 

2845 

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. 

2859 

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. 

2867 

2868 Returns: 

2869 ServerRead: The created server object. 

2870 

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) 

2877 

2878 # Get user email and handle team assignment 

2879 user_email = get_user_email(user) 

2880 

2881 token_team_id = getattr(request.state, "team_id", None) 

2882 token_teams = getattr(request.state, "token_teams", None) 

2883 

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 ) 

2891 

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 ) 

2898 

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 

2904 

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)) 

2930 

2931 

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. 

2943 

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. 

2950 

2951 Returns: 

2952 ServerRead: The updated server object. 

2953 

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 

2961 

2962 user_email: str = get_user_email(user) 

2963 

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)) 

2991 

2992 

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). 

3003 

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. 

3009 

3010 Returns: 

3011 ServerRead: The server object after the status change. 

3012 

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)) 

3028 

3029 

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. 

3039 

3040 Sets the status of a server (activate or deactivate). 

3041 

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. 

3047 

3048 Returns: 

3049 The updated server. 

3050 """ 

3051 

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) 

3054 

3055 

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. 

3066 

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. 

3072 

3073 Returns: 

3074 Dict[str, str]: A success message indicating the server was deleted. 

3075 

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)) 

3096 

3097 

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. 

3103 

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. 

3109 

3110 Returns: 

3111 The SSE response object for the established connection. 

3112 

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") 

3121 

3122 base_url = update_url_protocol(request) 

3123 server_sse_url = f"{base_url}/servers/{server_id}" 

3124 

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)) 

3130 

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") 

3140 

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) 

3148 

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) 

3155 

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 

3161 

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) 

3170 

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) 

3175 

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 

3194 

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") 

3207 

3208 

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. 

3214 

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. 

3219 

3220 Returns: 

3221 JSONResponse: A success status after processing the message. 

3222 

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") 

3232 

3233 await _assert_session_owner_or_admin(request, user, session_id) 

3234 

3235 message = await _read_request_json(request) 

3236 

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 

3249 

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}") 

3258 

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 ) 

3265 

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") 

3275 

3276 

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. 

3289 

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. 

3293 

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. 

3301 

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] 

3328 

3329 

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. 

3341 

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. 

3345 

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. 

3352 

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] 

3367 

3368 

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. 

3380 

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. 

3384 

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. 

3391 

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] 

3406 

3407 

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. 

3428 

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. 

3440 

3441 Returns: 

3442 Union[List[A2AAgentRead], Dict[str, Any]]: A list of A2A agent objects or paginated response with nextCursor. 

3443 

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()] 

3451 

3452 if a2a_service is None: 

3453 raise HTTPException(status_code=503, detail="A2A service not available") 

3454 

3455 # Get filtering context from token (respects token scope) 

3456 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user) 

3457 

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) 

3465 

3466 # Check team_id from request.state (set during auth) 

3467 token_team_id = getattr(request.state, "team_id", None) 

3468 

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 ) 

3475 

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. 

3478 

3479 logger.debug(f"User: {user_email} requested A2A agent list with team_id={team_id}, visibility={visibility}, tags={tags_list}, cursor={cursor}") 

3480 

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 ) 

3493 

3494 if include_pagination: 

3495 return CursorPaginatedA2AAgentsResponse.model_construct(agents=data, next_cursor=next_cursor) 

3496 return data 

3497 

3498 

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. 

3509 

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. 

3515 

3516 Returns: 

3517 A2AAgentRead: The agent object with the specified ID. 

3518 

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") 

3526 

3527 # Get filtering context from token (respects token scope) 

3528 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user) 

3529 

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 

3535 

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)) 

3544 

3545 

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. 

3559 

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. 

3567 

3568 Returns: 

3569 A2AAgentRead: The created agent object. 

3570 

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) 

3577 

3578 # Get user email and handle team assignment 

3579 user_email = get_user_email(user) 

3580 

3581 token_team_id = getattr(request.state, "team_id", None) 

3582 token_teams = getattr(request.state, "token_teams", None) 

3583 

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 ) 

3591 

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 ) 

3598 

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 

3604 

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)) 

3631 

3632 

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. 

3644 

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. 

3651 

3652 Returns: 

3653 A2AAgentRead: The updated agent object. 

3654 

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 

3662 

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)) 

3690 

3691 

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). 

3702 

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. 

3708 

3709 Returns: 

3710 A2AAgentRead: The agent object after the status change. 

3711 

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)) 

3727 

3728 

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. 

3738 

3739 Sets the status of an A2A agent (activate or deactivate). 

3740 

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. 

3746 

3747 Returns: 

3748 The updated A2A agent. 

3749 """ 

3750 

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) 

3753 

3754 

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. 

3765 

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. 

3771 

3772 Returns: 

3773 Dict[str, str]: A success message indicating the agent was deleted. 

3774 

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)) 

3794 

3795 

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. 

3808 

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. 

3816 

3817 Returns: 

3818 Dict[str, Any]: The response from the A2A agent. 

3819 

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") 

3827 

3828 # Get filtering context from token (respects token scope) 

3829 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user) 

3830 

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 

3836 

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) 

3842 

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)) 

3856 

3857 

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. 

3879 

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 

3894 

3895 Returns: 

3896 List of tools or modified result based on jsonpath 

3897 """ 

3898 

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()] 

3903 

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 

3908 

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) 

3916 

3917 # Check team_id from request.state (set during auth) 

3918 token_team_id = getattr(request.state, "team_id", None) 

3919 

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 ) 

3926 

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. 

3929 

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() 

3951 

3952 if apijsonpath is None: 

3953 if include_pagination: 

3954 return CursorPaginatedToolsResponse.model_construct(tools=data, next_cursor=next_cursor) 

3955 return data 

3956 

3957 tools_dict_list = [tool.to_dict(use_alias=True) for tool in data] 

3958 

3959 return jsonpath_modifier(tools_dict_list, apijsonpath.jsonpath, apijsonpath.mapping) 

3960 

3961 

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. 

3974 

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. 

3981 

3982 Returns: 

3983 ToolRead: The created tool data. 

3984 

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) 

3991 

3992 # Get user email and handle team assignment 

3993 user_email = get_user_email(user) 

3994 

3995 token_team_id = getattr(request.state, "team_id", None) 

3996 token_teams = getattr(request.state, "token_teams", None) 

3997 

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 ) 

4005 

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 ) 

4012 

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 

4018 

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") 

4055 

4056 

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. 

4068 

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. 

4075 

4076 Returns: 

4077 The raw ``ToolRead`` model **or** a JSON-transformed ``dict`` if 

4078 a JSONPath filter/mapping was supplied. 

4079 

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 

4091 

4092 data_dict = data.to_dict(use_alias=True) 

4093 

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)) 

4099 

4100 

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. 

4112 

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. 

4119 

4120 Returns: 

4121 ToolRead: The updated tool data. 

4122 

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 

4130 

4131 # Extract modification metadata 

4132 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, current_version) 

4133 

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") 

4164 

4165 

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. 

4176 

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. 

4182 

4183 Returns: 

4184 Dict[str, str]: A confirmation message upon successful deletion. 

4185 

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)) 

4202 

4203 

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. 

4214 

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. 

4220 

4221 Returns: 

4222 Dict[str, Any]: The status, message, and updated tool data. 

4223 

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)) 

4244 

4245 

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. 

4255 

4256 Activates or deactivates a tool. 

4257 

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. 

4263 

4264 Returns: 

4265 Status message with tool state. 

4266 """ 

4267 

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) 

4270 

4271 

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. 

4288 

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). 

4296 

4297 Returns: 

4298 ListResourceTemplatesResult: A paginated list of resource templates. 

4299 """ 

4300 logger.info(f"User {user} requested resource templates") 

4301 

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()] 

4306 

4307 # Get filtering context from token (respects token scope) 

4308 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user) 

4309 

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 

4315 

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 

4326 

4327 

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. 

4338 

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. 

4344 

4345 Returns: 

4346 Dict[str, Any]: Status message and updated resource data. 

4347 

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)) 

4368 

4369 

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. 

4379 

4380 Activate or deactivate a resource by its ID. 

4381 

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. 

4387 

4388 Returns: 

4389 Status message with resource state. 

4390 """ 

4391 

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) 

4394 

4395 

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. 

4413 

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. 

4425 

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()] 

4433 

4434 # Get filtering context from token (respects token scope) 

4435 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user) 

4436 

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) 

4444 

4445 # Check team_id from request.state (set during auth) 

4446 token_team_id = getattr(request.state, "team_id", None) 

4447 

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 ) 

4454 

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. 

4457 

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() 

4475 

4476 if include_pagination: 

4477 return CursorPaginatedResourcesResponse.model_construct(resources=data, next_cursor=next_cursor) 

4478 return data 

4479 

4480 

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. 

4494 

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. 

4502 

4503 Returns: 

4504 ResourceRead: The created resource. 

4505 

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) 

4512 

4513 # Get user email and handle team assignment 

4514 user_email = get_user_email(user) 

4515 

4516 token_team_id = getattr(request.state, "team_id", None) 

4517 token_teams = getattr(request.state, "token_teams", None) 

4518 

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 ) 

4526 

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 ) 

4533 

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 

4539 

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)) 

4568 

4569 

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. 

4575 

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. 

4581 

4582 Returns: 

4583 Any: The content of the resource. 

4584 

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") 

4591 

4592 logger.debug(f"User {user} requested resource with ID {resource_id} (request_id: {request_id})") 

4593 

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. 

4597 

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) 

4601 

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 

4606 

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 

4610 

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 

4629 

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 

4635 

4636 # If already a ResourceContent, serialize directly 

4637 if isinstance(content, ResourceContent): 

4638 return content.model_dump() 

4639 

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 

4645 

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} 

4650 

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")} 

4654 

4655 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": str(content)} 

4656 

4657 

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. 

4669 

4670 Returns the resource metadata including the enabled status. This endpoint 

4671 is different from GET /resources/{resource_id} which returns the resource content. 

4672 

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. 

4679 

4680 Returns: 

4681 ResourceRead: The resource metadata including enabled status. 

4682 

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)) 

4693 

4694 

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. 

4706 

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. 

4713 

4714 Returns: 

4715 ResourceRead: The updated resource. 

4716 

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 

4724 

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 

4752 

4753 

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. 

4764 

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. 

4770 

4771 Returns: 

4772 Dict[str, str]: Status message indicating deletion success. 

4773 

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)) 

4791 

4792 

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. 

4798 

4799 Args: 

4800 request (Request): Incoming HTTP request. 

4801 user (str): Authenticated user. 

4802 

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") 

4809 

4810 

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. 

4824 

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. 

4830 

4831 Returns: 

4832 Status message and updated prompt details. 

4833 

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)) 

4854 

4855 

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. 

4865 

4866 Set the activation status of a prompt. 

4867 

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. 

4873 

4874 Returns: 

4875 Status message with prompt state. 

4876 """ 

4877 

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) 

4880 

4881 

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. 

4899 

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. 

4911 

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()] 

4919 

4920 # Get filtering context from token (respects token scope) 

4921 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user) 

4922 

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) 

4930 

4931 # Check team_id from request.state (set during auth) 

4932 token_team_id = getattr(request.state, "team_id", None) 

4933 

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 ) 

4940 

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. 

4943 

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() 

4961 

4962 if include_pagination: 

4963 return CursorPaginatedPromptsResponse.model_construct(prompts=data, next_cursor=next_cursor) 

4964 return data 

4965 

4966 

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. 

4980 

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. 

4988 

4989 Returns: 

4990 PromptRead: The newly-created prompt. 

4991 

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) 

5000 

5001 # Get user email and handle team assignment 

5002 user_email = get_user_email(user) 

5003 

5004 token_team_id = getattr(request.state, "team_id", None) 

5005 token_teams = getattr(request.state, "token_teams", None) 

5006 

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 ) 

5014 

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 ) 

5021 

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 

5027 

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") 

5063 

5064 

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. 

5075 

5076 This implements the prompts/get functionality from the MCP spec, 

5077 which requires a POST request with arguments in the body. 

5078 

5079 

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. 

5086 

5087 Returns: 

5088 Rendered prompt or metadata. 

5089 

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}") 

5094 

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) 

5098 

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") 

5103 

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 

5107 

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 

5130 

5131 return result 

5132 

5133 

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. 

5143 

5144 This endpoint is for convenience when no arguments are needed. 

5145 

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 

5151 

5152 Returns: 

5153 The prompt template information 

5154 

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") 

5159 

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) 

5163 

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") 

5168 

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 

5172 

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)) 

5188 

5189 

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. 

5201 

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. 

5208 

5209 Returns: 

5210 PromptRead: The updated prompt object. 

5211 

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 

5220 

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") 

5255 

5256 

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. 

5267 

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. 

5273 

5274 Returns: 

5275 Status message. 

5276 

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") 

5296 

5297 # except PromptNotFoundError as e: 

5298 # return {"status": "error", "message": str(e)} 

5299 # except PromptError as e: 

5300 # return {"status": "error", "message": str(e)} 

5301 

5302 

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. 

5316 

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. 

5322 

5323 Returns: 

5324 Dict[str, Any]: A dict containing the operation status, a message, and the updated gateway object. 

5325 

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)) 

5349 

5350 

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. 

5360 

5361 Set the activation status of a gateway. 

5362 

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. 

5368 

5369 Returns: 

5370 Status message with gateway state. 

5371 """ 

5372 

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) 

5375 

5376 

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. 

5393 

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. 

5404 

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}") 

5409 

5410 user_email = get_user_email(user) 

5411 

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) 

5415 

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 ) 

5422 

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. 

5425 

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 

5432 

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() 

5449 

5450 if include_pagination: 

5451 return CursorPaginatedGatewaysResponse.model_construct(gateways=data, next_cursor=next_cursor) 

5452 return data 

5453 

5454 

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. 

5466 

5467 Args: 

5468 gateway: Gateway creation data. 

5469 request: The FastAPI request object for metadata extraction. 

5470 db: Database session. 

5471 user: Authenticated user. 

5472 

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) 

5480 

5481 # Get user email and handle team assignment 

5482 user_email = get_user_email(user) 

5483 

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 

5488 

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 ) 

5496 

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 ) 

5503 

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 

5509 

5510 logger.debug(f"User {user_email} is creating a new gateway for team {team_id}") 

5511 

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) 

5539 

5540 

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. 

5546 

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. 

5552 

5553 Returns: 

5554 Gateway data. 

5555 

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)) 

5566 

5567 

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. 

5579 

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. 

5586 

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 

5594 

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) 

5629 

5630 

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. 

5636 

5637 Args: 

5638 gateway_id: ID of the gateway. 

5639 db: Database session. 

5640 user: Authenticated user. 

5641 

5642 Returns: 

5643 Status message. 

5644 

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) 

5654 

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() 

5661 

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)) 

5671 

5672 

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. 

5685 

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. 

5689 

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. 

5697 

5698 Returns: 

5699 GatewayRefreshResponse with counts of changes and any validation errors. 

5700 

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}") 

5708 

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)) 

5723 

5724 

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. 

5736 

5737 Args: 

5738 user: Authenticated user. 

5739 

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() 

5745 

5746 

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. 

5755 

5756 Args: 

5757 uri: Root URI to export (query parameter) 

5758 user: Authenticated user 

5759 

5760 Returns: 

5761 Export data containing root information 

5762 

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}") 

5768 

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 

5777 

5778 # Get the root by URI 

5779 root = await root_service.get_root_by_uri(uri) 

5780 

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 } 

5792 

5793 return export_data 

5794 

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)}") 

5801 

5802 

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). 

5810 

5811 Args: 

5812 user: Authenticated user. 

5813 

5814 Returns: 

5815 StreamingResponse with event-stream media type. 

5816 """ 

5817 logger.debug(f"User '{user}' subscribed to root changes stream") 

5818 

5819 async def generate_events(): 

5820 """Generate SSE-formatted events from root service changes. 

5821 

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" 

5827 

5828 return StreamingResponse(generate_events(), media_type="text/event-stream") 

5829 

5830 

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. 

5839 

5840 Args: 

5841 root_uri: URI of the root to retrieve. 

5842 user: Authenticated user. 

5843 

5844 Returns: 

5845 Root object. 

5846 

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 

5860 

5861 

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. 

5871 

5872 Args: 

5873 root: Root object containing URI and name. 

5874 user: Authenticated user. 

5875 

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) 

5881 

5882 

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. 

5892 

5893 Args: 

5894 root_uri: URI of the root to update. 

5895 root: Root object with updated information. 

5896 user: Authenticated user. 

5897 

5898 Returns: 

5899 Updated Root object. 

5900 

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 

5914 

5915 

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. 

5924 

5925 Args: 

5926 uri: URI of the root to remove. 

5927 user: Authenticated user. 

5928 

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"} 

5935 

5936 

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. 

5944 

5945 Args: 

5946 request (Request): The incoming FastAPI request. 

5947 db (Session): Database session. 

5948 user: The authenticated user (dict with RBAC context). 

5949 

5950 Returns: 

5951 Response with the RPC result or error. 

5952 

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 

5966 

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 

5986 

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 

5997 

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 

6010 

6011 RPCRequest(jsonrpc="2.0", method=method, params=params) # Validate the request body against the RPCRequest model 

6012 

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" 

6026 

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 

6030 

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 

6039 

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 

6061 

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") 

6064 

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) 

6069 

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}) 

6074 

6075 if effective_owner and not requester_is_admin and requester_email != effective_owner: 

6076 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method}) 

6077 

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) 

6082 

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 

6089 

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) 

6228 

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 

6236 

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) 

6323 

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 

6331 

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) 

6363 

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 

6373 

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) 

6379 

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 

6384 

6385 async def cancel_tool_task(reason: Optional[str] = None): 

6386 """Cancel callback that actually cancels the asyncio task. 

6387 

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() 

6394 

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 ) 

6403 

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}) 

6410 

6411 # Create task for tool execution to enable real cancellation 

6412 async def execute_tool(): 

6413 """Execute tool invocation with fallback to gateway forwarding. 

6414 

6415 Returns: 

6416 The tool invocation result or gateway forwarding result. 

6417 

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) 

6439 

6440 tool_task = asyncio.create_task(execute_tool()) 

6441 

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() 

6448 

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) 

6470 

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 

6476 

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 

6532 

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"}) 

6536 

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 

6541 

6542 try: 

6543 elicit_params = ElicitRequestParams(**params) 

6544 except Exception as e: 

6545 raise JSONRPCError(-32602, f"Invalid elicitation params: {e}", params) 

6546 

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") 

6556 

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}) 

6560 

6561 # Get elicitation service and create request 

6562 elicitation_service = get_elicitation_service() 

6563 

6564 # Extract timeout from params or use default 

6565 timeout = params.get("timeout", settings.mcpgateway_elicitation_timeout) 

6566 

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" 

6572 

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 ) 

6579 

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", {}) 

6586 

6587 pending = pending_elicitations[-1] # Get most recent 

6588 

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 } 

6596 

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}") 

6599 

6600 # Wait for response 

6601 elicit_result = await elicitation_task 

6602 

6603 # Return result 

6604 result = elicit_result.model_dump(by_alias=True, exclude_none=True) 

6605 

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()} 

6640 

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 

6648 

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) 

6656 

6657 meta_data = params.get("_meta", None) 

6658 

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) 

6681 

6682 return {"jsonrpc": "2.0", "result": result, "id": req_id} 

6683 

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 } 

6698 

6699 

6700_WS_RELAY_REQUIRED_PERMISSIONS = [ 

6701 "tools.read", 

6702 "tools.execute", 

6703 "resources.read", 

6704 "prompts.read", 

6705 "servers.use", 

6706 "a2a.read", 

6707] 

6708 

6709 

6710def _get_websocket_bearer_token(websocket: WebSocket) -> Optional[str]: 

6711 """Extract bearer token from WebSocket Authorization headers. 

6712 

6713 Args: 

6714 websocket: Incoming WebSocket connection. 

6715 

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 ) 

6724 

6725 

6726async def _authenticate_websocket_user(websocket: WebSocket) -> tuple[Optional[str], Optional[str]]: 

6727 """Authenticate and authorize a WebSocket relay connection. 

6728 

6729 Args: 

6730 websocket: Incoming WebSocket connection. 

6731 

6732 Returns: 

6733 A tuple of `(auth_token, proxy_user)` where each value may be None. 

6734 

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 

6742 

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") 

6777 

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) 

6784 

6785 return auth_token, proxy_user 

6786 

6787 

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. 

6792 

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. 

6795 

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 

6803 

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 

6809 

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} 

6815 

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 

6822 

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}") 

6854 

6855 

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. 

6861 

6862 Args: 

6863 request (Request): The incoming HTTP request. 

6864 user (str): Authenticated username. 

6865 

6866 Returns: 

6867 StreamingResponse: A streaming response that keeps the connection 

6868 open and pushes events to the client. 

6869 

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) 

6877 

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)) 

6883 

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) 

6892 

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") 

6901 

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) 

6909 

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) 

6916 

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 

6922 

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) 

6926 

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 

6945 

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") 

6954 

6955 

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. 

6961 

6962 Args: 

6963 request (Request): Incoming request containing the JSON-RPC payload. 

6964 user (str): Authenticated user. 

6965 

6966 Returns: 

6967 JSONResponse: ``{"status": "success"}`` with HTTP 202 on success. 

6968 

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) 

6975 

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") 

6980 

6981 await _assert_session_owner_or_admin(request, user, session_id) 

6982 

6983 message = await _read_request_json(request) 

6984 

6985 await session_registry.broadcast( 

6986 session_id=session_id, 

6987 message=message, 

6988 ) 

6989 

6990 return ORJSONResponse(content={"status": "success"}, status_code=202) 

6991 

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") 

7000 

7001 

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. 

7007 

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) 

7016 

7017 

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). 

7026 

7027 Args: 

7028 db: Database session 

7029 user: Authenticated user 

7030 

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) 

7039 

7040 kwargs = { 

7041 "tools": tool_metrics, 

7042 "resources": resource_metrics, 

7043 "servers": server_metrics, 

7044 "prompts": prompt_metrics, 

7045 } 

7046 

7047 if a2a_service and settings.mcpgateway_a2a_metrics_enabled: 

7048 kwargs["a2a_agents"] = await a2a_service.aggregate_metrics(db) 

7049 

7050 return MetricsResponse(**kwargs) 

7051 

7052 

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. 

7059 

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 

7065 

7066 Returns: 

7067 A success message in a dictionary. 

7068 

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'}"} 

7097 

7098 

7099#################### 

7100# Healthcheck # 

7101#################### 

7102@app.get("/health") 

7103def healthcheck(): 

7104 """ 

7105 Perform a basic health check to verify database connectivity. 

7106 

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. 

7110 

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() 

7134 

7135 

7136@app.get("/ready") 

7137async def readiness_check(): 

7138 """ 

7139 Perform a readiness check to verify if the application is ready to receive traffic. 

7140 

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. 

7144 

7145 Returns: 

7146 JSONResponse with status 200 if ready, 503 if not. 

7147 """ 

7148 

7149 def _check_db() -> str | None: 

7150 """Check database connectivity by executing a simple query. 

7151 

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() 

7174 

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) 

7182 

7183 

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). 

7188 

7189 Args: 

7190 request (Request): The incoming HTTP request. 

7191 _user: Authenticated admin user (injected by require_admin_auth). 

7192 

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() 

7198 

7199 # Determine overall health 

7200 score = security_status["security_score"] 

7201 is_healthy = score >= 60 # Minimum acceptable score 

7202 

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 } 

7218 

7219 # Include warnings for admin users 

7220 if security_status["warnings"]: 

7221 response["warnings"] = security_status["warnings"] 

7222 

7223 return response 

7224 

7225 

7226#################### 

7227# Tag Endpoints # 

7228#################### 

7229 

7230 

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. 

7243 

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 

7252 

7253 Returns: 

7254 List of TagInfo objects containing tag names, statistics, and optionally entities 

7255 

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()] 

7263 

7264 logger.debug(f"User {user} is retrieving tags for entity types: {entity_types_list}, include_entities: {include_entities}") 

7265 

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 = [] 

7272 

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)}") 

7284 

7285 

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. 

7297 

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 

7306 

7307 Returns: 

7308 List of TaggedEntity objects 

7309 

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()] 

7317 

7318 logger.debug(f"User {user} is retrieving entities for tag '{tag_name}' with entity types: {entity_types_list}") 

7319 

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 = [] 

7326 

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)}") 

7338 

7339 

7340#################### 

7341# Export/Import # 

7342#################### 

7343 

7344 

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. 

7360 

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 

7371 

7372 Returns: 

7373 Export data in the specified format 

7374 

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()] 

7385 

7386 exclude_types_list = None 

7387 if exclude_types: 

7388 exclude_types_list = [t.strip() for t in exclude_types.split(",") if t.strip()] 

7389 

7390 tags_list = None 

7391 if tags: 

7392 tags_list = [t.strip() for t in tags.split(",") if t.strip()] 

7393 

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 

7401 

7402 # Get root path for URL construction - prefer configured APP_ROOT_PATH 

7403 root_path = settings.app_root_path 

7404 

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) 

7407 

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 ) 

7421 

7422 return export_data 

7423 

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)}") 

7430 

7431 

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. 

7439 

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 

7446 

7447 Returns: 

7448 Selective export data 

7449 

7450 Raises: 

7451 HTTPException: If export fails 

7452 

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") 

7462 

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") 

7469 

7470 # Get root path for URL construction - prefer configured APP_ROOT_PATH 

7471 root_path = settings.app_root_path 

7472 

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) 

7475 

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 ) 

7485 

7486 return export_data 

7487 

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)}") 

7494 

7495 

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. 

7509 

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 

7518 

7519 Returns: 

7520 Import status and results 

7521 

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})") 

7527 

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)]}") 

7533 

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 

7541 

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 ) 

7546 

7547 return import_status.to_dict() 

7548 

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)}") 

7561 

7562 

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. 

7568 

7569 Args: 

7570 import_id: The import operation ID 

7571 user: Authenticated user 

7572 

7573 Returns: 

7574 Import status information 

7575 

7576 Raises: 

7577 HTTPException: If import not found 

7578 """ 

7579 logger.debug(f"User {user} requested import status for {import_id}") 

7580 

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") 

7584 

7585 return import_status.to_dict() 

7586 

7587 

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. 

7593 

7594 Args: 

7595 user: Authenticated user 

7596 

7597 Returns: 

7598 List of import status information 

7599 """ 

7600 logger.debug(f"User {user} requested all import statuses") 

7601 

7602 statuses = import_service.list_import_statuses() 

7603 return [status.to_dict() for status in statuses] 

7604 

7605 

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. 

7611 

7612 Args: 

7613 max_age_hours: Maximum age in hours for keeping completed imports 

7614 user: Authenticated user 

7615 

7616 Returns: 

7617 Cleanup results 

7618 """ 

7619 logger.info(f"User {user} requested import status cleanup (max_age_hours={max_age_hours})") 

7620 

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} 

7623 

7624 

7625# Mount static files 

7626# app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static") 

7627 

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) 

7642 

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 

7648 

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") 

7655 

7656# Conditionally include observability router if enabled 

7657if settings.observability_enabled: 

7658 # First-Party 

7659 from mcpgateway.routers.observability import router as observability_router 

7660 

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") 

7665 

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 

7670 

7671 app.include_router(metrics_maintenance_router) 

7672 logger.info("Metrics maintenance router included - cleanup/rollup API endpoints enabled") 

7673 

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") 

7680 

7681app.include_router(well_known_router) 

7682 

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 

7689 

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") 

7693 

7694 # Include SSO router if enabled 

7695 if settings.sso_enabled: 

7696 try: 

7697 # First-Party 

7698 from mcpgateway.routers.sso import sso_router 

7699 

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") 

7710 

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 

7716 

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") 

7723 

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 

7729 

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") 

7736 

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 

7742 

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") 

7749 

7750# Include OAuth router 

7751try: 

7752 # First-Party 

7753 from mcpgateway.routers.oauth_router import oauth_router 

7754 

7755 app.include_router(oauth_router) 

7756 logger.info("OAuth router included") 

7757except ImportError: 

7758 logger.debug("OAuth router not available") 

7759 

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 

7765 

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") 

7772 

7773# Include LLMChat router 

7774if settings.llmchat_enabled: 

7775 try: 

7776 # First-Party 

7777 from mcpgateway.routers.llmchat_router import llmchat_router 

7778 

7779 app.include_router(llmchat_router) 

7780 logger.info("LLM Chat router included") 

7781 except ImportError: 

7782 logger.debug("LLM Chat router not available") 

7783 

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 

7790 

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}") 

7797 

7798# Include Toolops router 

7799if settings.toolops_enabled: 

7800 try: 

7801 # First-Party 

7802 from mcpgateway.routers.toolops_router import toolops_router 

7803 

7804 app.include_router(toolops_router) 

7805 logger.info("Toolops router included") 

7806 except ImportError: 

7807 logger.debug("Toolops router not available") 

7808 

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 

7814 

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") 

7821 

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}") 

7827 

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") 

7834 

7835# Streamable http Mount 

7836app.mount("/mcp", app=streamable_http_session.handle_streamable_http) 

7837 

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" 

7846 

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 ) 

7859 

7860 # Redirect root path to admin UI 

7861 @app.get("/") 

7862 async def root_redirect(): 

7863 """ 

7864 Redirects the root path ("/") to "/admin/". 

7865 

7866 Logs a debug message before redirecting. 

7867 

7868 Returns: 

7869 RedirectResponse: Redirects to /admin/. 

7870 

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")) 

7878 

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. 

7883 

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) 

7889 

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") 

7893 

7894 @app.get("/") 

7895 async def root_info(): 

7896 """ 

7897 Returns basic API information at the root path. 

7898 

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. 

7902 

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"} 

7908 

7909 

7910# Expose some endpoints at the root level as well 

7911app.post("/initialize")(initialize) 

7912app.post("/notifications")(handle_notification)