Coverage for mcpgateway / main.py: 99%

4551 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 00:56 +0100

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 

31import base64 

32from contextlib import asynccontextmanager, suppress 

33from datetime import datetime, timezone 

34from functools import lru_cache 

35import hashlib 

36import hmac 

37import html 

38import json 

39import logging 

40import re 

41import signal 

42import sys 

43import threading 

44from typing import Any, AsyncIterator, Dict, List, Optional, TypeAlias, Union 

45from urllib.parse import urlparse, urlunparse 

46import uuid 

47import warnings 

48 

49# Third-Party 

50from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Query, Request, status, WebSocket, WebSocketDisconnect 

51from fastapi.background import BackgroundTasks 

52from fastapi.exception_handlers import request_validation_exception_handler as fastapi_default_validation_handler 

53from fastapi.exceptions import RequestValidationError 

54from fastapi.middleware.cors import CORSMiddleware 

55from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse 

56from fastapi.security import HTTPAuthorizationCredentials 

57from fastapi.staticfiles import StaticFiles 

58from fastapi.templating import Jinja2Templates 

59from jinja2 import Environment, FileSystemLoader 

60from jsonpath_ng.ext import parse 

61from jsonpath_ng.jsonpath import JSONPath 

62import orjson 

63from pydantic import ValidationError 

64from sqlalchemy import text 

65from sqlalchemy.exc import IntegrityError 

66from sqlalchemy.orm import Session 

67from starlette.middleware.base import BaseHTTPMiddleware 

68from starlette.requests import Request as starletteRequest 

69from starlette.responses import Response as starletteResponse 

70from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware 

71 

72# First-Party 

73# Import the admin routes from the new module 

74from mcpgateway import __version__ 

75from mcpgateway import version as version_module 

76from mcpgateway.admin import admin_router, set_logging_service 

77from mcpgateway.auth import _check_token_revoked_sync, _lookup_api_token_sync, get_current_user, get_user_team_roles, normalize_token_teams, resolve_session_teams 

78from mcpgateway.bootstrap_db import main as bootstrap_db 

79from mcpgateway.cache import ResourceCache, SessionRegistry 

80from mcpgateway.common.models import InitializeResult 

81from mcpgateway.common.models import JSONRPCError as PydanticJSONRPCError 

82from mcpgateway.common.models import ListResourceTemplatesResult, LogLevel, Root 

83from mcpgateway.common.validators import SecurityValidator 

84from mcpgateway.config import settings 

85from mcpgateway.db import refresh_slugs_on_startup, SessionLocal 

86from mcpgateway.db import Tool as DbTool 

87from mcpgateway.handlers.sampling import SamplingHandler 

88from mcpgateway.middleware.compression import SSEAwareCompressMiddleware 

89from mcpgateway.middleware.correlation_id import CorrelationIDMiddleware 

90from mcpgateway.middleware.http_auth_middleware import HttpAuthMiddleware, run_pre_request_hooks 

91from mcpgateway.middleware.protocol_version import MCPProtocolVersionMiddleware 

92from mcpgateway.middleware.rbac import _ACCESS_DENIED_MSG, get_current_user_with_permissions, PermissionChecker, require_permission 

93from mcpgateway.middleware.request_logging_middleware import RequestLoggingMiddleware 

94from mcpgateway.middleware.security_headers import SecurityHeadersMiddleware 

95from mcpgateway.middleware.token_scoping import token_scoping_middleware 

96from mcpgateway.middleware.validation_middleware import ValidationMiddleware 

97from mcpgateway.observability import init_telemetry, OpenTelemetryRequestMiddleware, otel_tracing_enabled 

98from mcpgateway.plugins.framework import HttpHookType, PluginError, PluginManager, PluginViolationError, PromptHookType, ResourceHookType 

99from mcpgateway.plugins.framework.constants import PLUGIN_VIOLATION_CODE_MAPPING, PluginViolationCode, VALID_HTTP_STATUS_CODES 

100from mcpgateway.routers.server_well_known import router as server_well_known_router 

101from mcpgateway.routers.well_known import router as well_known_router 

102from mcpgateway.schemas import ( 

103 A2AAgentCreate, 

104 A2AAgentRead, 

105 A2AAgentUpdate, 

106 CursorPaginatedA2AAgentsResponse, 

107 CursorPaginatedGatewaysResponse, 

108 CursorPaginatedPromptsResponse, 

109 CursorPaginatedResourcesResponse, 

110 CursorPaginatedServersResponse, 

111 CursorPaginatedToolsResponse, 

112 GatewayCreate, 

113 GatewayRead, 

114 GatewayRefreshResponse, 

115 GatewayUpdate, 

116 JsonPathModifier, 

117 MetricsResponse, 

118 PromptCreate, 

119 PromptExecuteArgs, 

120 PromptRead, 

121 PromptUpdate, 

122 ResourceCreate, 

123 ResourceRead, 

124 ResourceSubscription, 

125 ResourceUpdate, 

126 RPCRequest, 

127 ServerCreate, 

128 ServerRead, 

129 ServerUpdate, 

130 TaggedEntity, 

131 TagInfo, 

132 ToolCreate, 

133 ToolRead, 

134 ToolUpdate, 

135) 

136from mcpgateway.services.a2a_service import A2AAgentError, A2AAgentNameConflictError, A2AAgentNotFoundError, A2AAgentService 

137from mcpgateway.services.cancellation_service import cancellation_service 

138from mcpgateway.services.completion_service import CompletionService 

139from mcpgateway.services.content_security import ContentSizeError, ContentTypeError 

140from mcpgateway.services.email_auth_service import EmailAuthService 

141from mcpgateway.services.export_service import ExportError, ExportService 

142from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayDuplicateConflictError, GatewayError, GatewayNameConflictError, GatewayNotFoundError 

143from mcpgateway.services.import_service import ConflictStrategy, ImportConflictError 

144from mcpgateway.services.import_service import ImportError as ImportServiceError 

145from mcpgateway.services.import_service import ImportService, ImportValidationError 

146from mcpgateway.services.log_aggregator import get_log_aggregator 

147from mcpgateway.services.logging_service import LoggingService 

148from mcpgateway.services.metrics import setup_metrics 

149from mcpgateway.services.permission_service import PermissionService 

150from mcpgateway.services.prompt_service import PromptError, PromptLockConflictError, PromptNameConflictError, PromptNotFoundError 

151from mcpgateway.services.resource_service import ResourceError, ResourceLockConflictError, ResourceNotFoundError, ResourceURIConflictError 

152from mcpgateway.services.server_service import ServerError, ServerLockConflictError, ServerNameConflictError, ServerNotFoundError 

153from mcpgateway.services.tag_service import TagService 

154from mcpgateway.services.tool_service import ToolError, ToolLockConflictError, ToolNameConflictError, ToolNotFoundError 

155from mcpgateway.transports.rust_mcp_runtime_proxy import RustMCPRuntimeProxy 

156from mcpgateway.transports.sse_transport import SSETransport 

157from mcpgateway.transports.streamablehttp_transport import ( 

158 _validate_streamable_session_access, 

159 get_streamable_http_auth_context, 

160 SessionManagerWrapper, 

161 set_shared_session_registry, 

162 streamable_http_auth, 

163 user_context_var, 

164) 

165from mcpgateway.utils.db_isready import wait_for_db_ready 

166from mcpgateway.utils.error_formatter import ErrorFormatter 

167from mcpgateway.utils.internal_http import internal_loopback_base_url, internal_loopback_verify 

168from mcpgateway.utils.metadata_capture import MetadataCapture 

169from mcpgateway.utils.orjson_response import ORJSONResponse 

170from mcpgateway.utils.passthrough_headers import set_global_passthrough_headers 

171from mcpgateway.utils.redis_client import close_redis_client, get_redis_client 

172from mcpgateway.utils.redis_isready import wait_for_redis_ready 

173from mcpgateway.utils.retry_manager import ResilientHttpClient 

174from mcpgateway.utils.token_scoping import validate_server_access 

175from mcpgateway.utils.trace_context import clear_trace_context, set_trace_context_from_teams, set_trace_session_id 

176from mcpgateway.utils.verify_credentials import extract_websocket_bearer_token, is_proxy_auth_trust_active, require_admin_auth, require_docs_auth_override, verify_jwt_token 

177from mcpgateway.validation.jsonrpc import JSONRPCError 

178from mcpgateway.version import router as version_router 

179 

180# Initialize logging service first 

181logging_service = LoggingService() 

182logger = logging_service.get_logger("mcpgateway") 

183 

184# Share the logging service with admin module 

185set_logging_service(logging_service) 

186 

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

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

189 

190# Wait for database to be ready before creating tables 

191wait_for_db_ready(max_tries=int(settings.db_max_retries), interval=int(settings.db_retry_interval_ms) / 1000, sync=True) # Converting ms to s 

192 

193# Create database tables 

194try: 

195 loop = asyncio.get_running_loop() 

196except RuntimeError: 

197 asyncio.run(bootstrap_db()) 

198else: 

199 loop.create_task(bootstrap_db()) 

200 

201# Initialize plugin manager as a singleton. 

202_PLUGINS_ENABLED = settings.plugins.enabled 

203if _PLUGINS_ENABLED: 

204 _plugin_settings = settings.plugins 

205 # First-Party 

206 from mcpgateway.plugins.policy import HOOK_PAYLOAD_POLICIES # noqa: E402 

207 

208 plugin_manager: PluginManager | None = PluginManager(_plugin_settings.config_file, timeout=_plugin_settings.plugin_timeout, hook_policies=HOOK_PAYLOAD_POLICIES) 

209else: 

210 plugin_manager = None # pylint: disable=invalid-name 

211 

212 

213# First-Party 

214# First-Party - import module-level service singletons 

215from mcpgateway.services.gateway_service import gateway_service # noqa: E402 

216from mcpgateway.services.prompt_service import prompt_service # noqa: E402 

217from mcpgateway.services.resource_service import resource_service # noqa: E402 

218from mcpgateway.services.root_service import root_service, RootServiceNotFoundError # noqa: E402 

219from mcpgateway.services.server_service import server_service # noqa: E402 

220from mcpgateway.services.tool_service import tool_service # noqa: E402 

221 

222# Services that do not expose module-level singletons are instantiated here 

223completion_service = CompletionService() 

224sampling_handler = SamplingHandler() 

225tag_service = TagService() 

226export_service = ExportService() 

227import_service = ImportService() 

228# Initialize A2A service only if A2A features are enabled 

229a2a_service = A2AAgentService() if settings.mcpgateway_a2a_enabled else None 

230 

231# Initialize session manager for Streamable HTTP transport 

232streamable_http_session = SessionManagerWrapper() 

233 

234# Wait for redis to be ready 

235if settings.cache_type == "redis" and settings.redis_url is not None: 

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

237 

238# Initialize session registry 

239session_registry = SessionRegistry( 

240 backend=settings.cache_type, 

241 redis_url=settings.redis_url if settings.cache_type == "redis" else None, 

242 database_url=settings.database_url if settings.cache_type == "database" else None, 

243 session_ttl=settings.session_ttl, 

244 message_ttl=settings.message_ttl, 

245) 

246set_shared_session_registry(session_registry) 

247 

248 

249# Helper function for authentication compatibility 

250def get_user_email(user): 

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

252 

253 Args: 

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

255 

256 Returns: 

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

258 

259 Examples: 

260 Test with dictionary user containing email: 

261 >>> from mcpgateway import main 

262 >>> user_dict = {'email': 'alice@example.com', 'role': 'admin'} 

263 >>> main.get_user_email(user_dict) 

264 'alice@example.com' 

265 

266 Test with dictionary user containing sub (JWT standard claim): 

267 >>> user_dict_sub = {'sub': 'bob@example.com', 'role': 'user'} 

268 >>> main.get_user_email(user_dict_sub) 

269 'bob@example.com' 

270 

271 Test with dictionary user containing both email and sub (email takes precedence): 

272 >>> user_dict_both = {'email': 'alice@example.com', 'sub': 'bob@example.com'} 

273 >>> main.get_user_email(user_dict_both) 

274 'alice@example.com' 

275 

276 Test with dictionary user without email or sub: 

277 >>> user_dict_no_email = {'username': 'charlie', 'role': 'user'} 

278 >>> main.get_user_email(user_dict_no_email) 

279 'unknown' 

280 

281 Test with string user (legacy format): 

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

283 >>> main.get_user_email(user_string) 

284 'charlie@company.com' 

285 

286 Test with None user: 

287 >>> main.get_user_email(None) 

288 'unknown' 

289 

290 Test with empty dictionary: 

291 >>> main.get_user_email({}) 

292 'unknown' 

293 

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

295 >>> main.get_user_email(123) 

296 '123' 

297 

298 Test with user object having various data types: 

299 >>> user_complex = {'email': 'david@test.org', 'id': 456, 'active': True} 

300 >>> main.get_user_email(user_complex) 

301 'david@test.org' 

302 

303 Test with empty string user: 

304 >>> main.get_user_email('') 

305 'unknown' 

306 

307 Test with boolean user: 

308 >>> main.get_user_email(True) 

309 'True' 

310 >>> main.get_user_email(False) 

311 'unknown' 

312 """ 

313 if isinstance(user, dict): 

314 # First try 'email', then 'sub' (JWT standard claim) 

315 return user.get("email") or user.get("sub") or "unknown" 

316 return str(user) if user else "unknown" 

317 

318 

319_INTERNAL_MCP_AUTH_CONTEXT_HEADER = "x-contextforge-auth-context" 

320_INTERNAL_MCP_RUNTIME_AUTH_HEADER = "x-contextforge-mcp-runtime-auth" 

321_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT = "contextforge-internal-mcp-runtime-v1" 

322_INTERNAL_MCP_SESSION_VALIDATED_HEADER = "x-contextforge-session-validated" 

323 

324 

325def _get_internal_mcp_auth_context(request: Request) -> Optional[Dict[str, Any]]: 

326 """Return trusted auth context forwarded from the StreamableHTTP MCP auth layer. 

327 

328 Args: 

329 request: Incoming request that may carry trusted MCP auth context on state. 

330 

331 Returns: 

332 The forwarded auth context dictionary when present, otherwise ``None``. 

333 """ 

334 internal_auth_context = getattr(request.state, "_mcp_internal_auth_context", None) 

335 if isinstance(internal_auth_context, dict): 

336 return internal_auth_context 

337 return None 

338 

339 

340def _decode_internal_mcp_auth_context(header_value: str) -> Dict[str, Any]: 

341 """Decode the trusted internal MCP auth header payload. 

342 

343 Args: 

344 header_value: Base64url-encoded trusted auth context header value. 

345 

346 Returns: 

347 Decoded auth context dictionary. 

348 

349 Raises: 

350 ValueError: If the decoded payload is not a JSON object. 

351 """ 

352 padding = "=" * (-len(header_value) % 4) 

353 decoded = base64.urlsafe_b64decode(f"{header_value}{padding}".encode("ascii")) 

354 payload = orjson.loads(decoded) 

355 if not isinstance(payload, dict): 

356 raise ValueError("Decoded internal MCP auth context must be an object") 

357 return payload 

358 

359 

360def _auth_encryption_secret_value() -> str: 

361 """Return the configured auth-encryption secret as a plain string. 

362 

363 Returns: 

364 The auth-encryption secret, normalized to a regular string. 

365 """ 

366 secret = settings.auth_encryption_secret 

367 if hasattr(secret, "get_secret_value"): 

368 return secret.get_secret_value() 

369 return str(secret) 

370 

371 

372@lru_cache(maxsize=8) 

373def _expected_internal_mcp_runtime_auth_header_for_secret(secret: str) -> str: 

374 """Return the shared secret-derived trust header for Rust->Python MCP hops. 

375 

376 Args: 

377 secret: Auth-encryption secret to derive the trust header from. 

378 

379 Returns: 

380 Hex-encoded SHA-256 digest derived from the provided auth secret. 

381 """ 

382 material = f"{secret}:{_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT}".encode("utf-8") 

383 return hashlib.sha256(material).hexdigest() 

384 

385 

386def _expected_internal_mcp_runtime_auth_header() -> str: 

387 """Return the current shared secret-derived trust header for Rust->Python MCP hops. 

388 

389 Returns: 

390 Hex-encoded SHA-256 digest derived from the current auth secret. 

391 """ 

392 return _expected_internal_mcp_runtime_auth_header_for_secret(_auth_encryption_secret_value()) 

393 

394 

395def _has_valid_internal_mcp_runtime_auth_header(request: Request) -> bool: 

396 """Validate the shared secret-derived trust header for internal MCP requests. 

397 

398 Args: 

399 request: Incoming internal MCP request. 

400 

401 Returns: 

402 ``True`` when the derived trust header matches the expected value. 

403 """ 

404 provided = request.headers.get(_INTERNAL_MCP_RUNTIME_AUTH_HEADER) 

405 if not provided: 

406 return False 

407 return hmac.compare_digest(provided, _expected_internal_mcp_runtime_auth_header()) 

408 

409 

410def _is_trusted_internal_mcp_runtime_request(request: Request) -> bool: 

411 """Return whether the request came from the local Rust runtime sidecar. 

412 

413 Args: 

414 request: Incoming request to inspect. 

415 

416 Returns: 

417 ``True`` when the request carries the trusted Rust runtime marker from 

418 loopback, otherwise ``False``. 

419 """ 

420 runtime_marker = request.headers.get("x-contextforge-mcp-runtime") 

421 client_host = getattr(getattr(request, "client", None), "host", None) 

422 return runtime_marker == "rust" and _has_valid_internal_mcp_runtime_auth_header(request) and client_host in ("127.0.0.1", "::1") 

423 

424 

425def _build_internal_mcp_forwarded_user(request: Request) -> Dict[str, Any]: 

426 """Build the authenticated user payload for internal Rust -> Python MCP dispatch. 

427 

428 Args: 

429 request: Trusted internal request forwarded from the Rust runtime. 

430 

431 Returns: 

432 Synthetic authenticated user payload used by internal MCP handlers. 

433 

434 Raises: 

435 HTTPException: If the request is not trusted or the forwarded auth context 

436 is missing or invalid. 

437 """ 

438 if not _is_trusted_internal_mcp_runtime_request(request): 

439 raise HTTPException(status_code=403, detail="Internal MCP dispatch is only available to the local Rust runtime") 

440 

441 header_value = request.headers.get(_INTERNAL_MCP_AUTH_CONTEXT_HEADER) 

442 if not header_value: 

443 raise HTTPException(status_code=400, detail="Missing trusted MCP auth context") 

444 

445 try: 

446 auth_context = _decode_internal_mcp_auth_context(header_value) 

447 except Exception as exc: 

448 raise HTTPException(status_code=400, detail=f"Invalid trusted MCP auth context: {exc}") from exc 

449 

450 setattr(request.state, "_mcp_internal_auth_context", auth_context) 

451 

452 if "teams" in auth_context and (auth_context["teams"] is None or isinstance(auth_context["teams"], list)): 

453 request.state.token_teams = auth_context["teams"] 

454 

455 if request.headers.get(_INTERNAL_MCP_SESSION_VALIDATED_HEADER) == "rust": 

456 auth_context["_rust_session_validated"] = True 

457 

458 forwarded_auth_method = auth_context.get("auth_method") or "mcp_internal_forward" 

459 

460 set_trace_context_from_teams( 

461 auth_context.get("teams"), 

462 user_email=auth_context.get("email"), 

463 is_admin=bool(auth_context.get("permission_is_admin", auth_context.get("is_admin", False))), 

464 auth_method=forwarded_auth_method, 

465 team_name=auth_context.get("team_name"), 

466 ) 

467 

468 return { 

469 "email": auth_context.get("email"), 

470 "full_name": auth_context.get("email") or "MCP Internal Forward", 

471 "is_admin": bool(auth_context.get("permission_is_admin", auth_context.get("is_admin", False))), 

472 "auth_method": forwarded_auth_method, 

473 "token_use": auth_context.get("token_use"), 

474 } 

475 

476 

477def _enforce_internal_mcp_server_scope(request: Request, server_id: str) -> None: 

478 """Validate trusted internal server scope against any forwarded token server scope. 

479 

480 Args: 

481 request: Trusted internal MCP request. 

482 server_id: Effective virtual server identifier for the operation. 

483 

484 Raises: 

485 HTTPException: If the forwarded token scope does not authorize the server. 

486 """ 

487 auth_context = _get_internal_mcp_auth_context(request) 

488 if not isinstance(auth_context, dict): 

489 return 

490 

491 scoped_server_id = auth_context.get("scoped_server_id") 

492 if isinstance(scoped_server_id, str) and scoped_server_id and not validate_server_access({"server_id": scoped_server_id}, server_id): 

493 raise HTTPException(status_code=403, detail=f"Token not authorized for server: {server_id}") 

494 

495 

496async def _authorize_internal_mcp_request(request: Request, db: Session, *, permission: str, method: str, server_id: Optional[str] = None): 

497 """Authorize trusted Rust-side MCP dispatch while preserving permissive MCP semantics. 

498 

499 For authenticated callers, this enforces the same token-scope and RBAC rules as 

500 the regular RPC dispatcher. For unauthenticated MCP callers in permissive mode, 

501 StreamableHTTP middleware already downgraded them to public-only scope and 

502 enforced per-server OAuth, so the internal Rust -> Python hop should not re-deny 

503 public-only requests merely because there is no authenticated RBAC identity. 

504 

505 Args: 

506 request: Trusted internal MCP request. 

507 db: Active database session. 

508 permission: RBAC permission required for the method. 

509 method: MCP method name being authorized. 

510 server_id: Optional virtual server identifier used for additional scope checks. 

511 

512 Returns: 

513 The forwarded user payload used for downstream authorization and scoping. 

514 """ 

515 user = _build_internal_mcp_forwarded_user(request) 

516 auth_context = _get_internal_mcp_auth_context(request) or {} 

517 

518 if server_id: 

519 _enforce_internal_mcp_server_scope(request, server_id) 

520 

521 if auth_context.get("is_authenticated", True) is True: 

522 await _ensure_rpc_permission(user, db, permission, method, request=request) 

523 

524 return user 

525 

526 

527def _build_internal_mcp_auth_scope( 

528 *, 

529 method: str, 

530 path: str, 

531 query_string: str, 

532 headers: Dict[str, str], 

533 client_ip: Optional[str], 

534) -> Dict[str, Any]: 

535 """Construct a synthetic ASGI scope for internal Rust -> Python MCP auth. 

536 

537 Args: 

538 method: HTTP method of the original public MCP request. 

539 path: Public MCP path, for example ``/mcp`` or ``/servers/<id>/mcp``. 

540 query_string: Raw query string without the leading ``?``. 

541 headers: Public request headers to replay through auth/token scoping. 

542 client_ip: Effective client IP derived by Rust from the public request. 

543 

544 Returns: 

545 ASGI scope dictionary suitable for token scoping and ``streamable_http_auth``. 

546 """ 

547 raw_headers = [] 

548 for name, value in headers.items(): 

549 if not isinstance(name, str) or not isinstance(value, str): 

550 continue 

551 raw_headers.append((name.lower().encode("latin-1"), value.encode("latin-1"))) 

552 

553 return { 

554 "type": "http", 

555 "method": method.upper(), 

556 "path": path, 

557 "raw_path": path.encode("latin-1"), 

558 "query_string": query_string.encode("latin-1"), 

559 "headers": raw_headers, 

560 "client": (client_ip or "unknown", 0), 

561 "state": {}, 

562 } 

563 

564 

565async def _run_internal_mcp_authentication( 

566 *, 

567 method: str, 

568 path: str, 

569 query_string: str, 

570 headers: Dict[str, str], 

571 client_ip: Optional[str], 

572) -> tuple[Optional[Response], Dict[str, Any]]: 

573 """Run token scoping and MCP transport auth for a direct Rust ingress request. 

574 

575 Runs HTTP_PRE_REQUEST plugin hooks (e.g. WXO auth token exchange) before 

576 authentication so the Rust MCP path gets identical plugin behavior to the 

577 Python middleware chain. 

578 

579 Args: 

580 method: HTTP method of the public request. 

581 path: Public request path. 

582 query_string: Raw query string without the leading ``?``. 

583 headers: Public request headers replayed from Rust. 

584 client_ip: Effective client IP for token-scope IP restriction checks. 

585 

586 Returns: 

587 Tuple of ``(error_response, auth_context)``. 

588 ``error_response`` is ``None`` on success; otherwise it contains the exact 

589 response generated by the existing token-scoping/auth layers. 

590 """ 

591 # Run pre-request plugin hooks (e.g. WXO JWT → team token exchange) 

592 # before building the auth scope, so plugins can transform headers. 

593 if plugin_manager and plugin_manager.has_hooks_for(HttpHookType.HTTP_PRE_REQUEST): 

594 headers, _, _ = await run_pre_request_hooks( 

595 plugin_manager=plugin_manager, 

596 headers=headers, 

597 path=path, 

598 method=method, 

599 client_host=client_ip, 

600 ) 

601 

602 scope = _build_internal_mcp_auth_scope( 

603 method=method, 

604 path=path, 

605 query_string=query_string, 

606 headers=headers, 

607 client_ip=client_ip, 

608 ) 

609 request = starletteRequest(scope) 

610 sent_messages: list[dict[str, Any]] = [] 

611 

612 async def _receive() -> dict[str, Any]: 

613 """Return an empty request body for the synthetic auth probe. 

614 

615 Returns: 

616 Minimal ASGI ``http.request`` message with no body content. 

617 """ 

618 return {"type": "http.request", "body": b"", "more_body": False} 

619 

620 async def _send(message: dict[str, Any]) -> None: 

621 """Capture ASGI response messages emitted by auth middleware. 

622 

623 Args: 

624 message: ASGI response message emitted by the auth stack. 

625 """ 

626 sent_messages.append(message) 

627 

628 def _captured_response() -> Response: 

629 """Build a concrete response from the captured ASGI messages. 

630 

631 Returns: 

632 Response reconstructed from the captured auth middleware output. 

633 """ 

634 status_code = 500 

635 response_headers: Dict[str, str] = {} 

636 body = b"" 

637 for message in sent_messages: 

638 if message.get("type") == "http.response.start": 

639 status_code = int(message.get("status", 500)) 

640 response_headers = { 

641 key.decode("latin-1"): value.decode("latin-1") for key, value in message.get("headers", []) if isinstance(key, (bytes, bytearray)) and isinstance(value, (bytes, bytearray)) 

642 } 

643 elif message.get("type") == "http.response.body": 

644 body += message.get("body", b"") 

645 return Response(content=body, status_code=status_code, headers=response_headers) 

646 

647 async def _call_next(_request: starletteRequest) -> Response: 

648 """Run the existing Streamable HTTP auth layer for the synthetic request. 

649 

650 Returns: 

651 Success response when authentication passes, otherwise the captured 

652 failure response emitted by the existing middleware chain. 

653 """ 

654 auth_ok = await streamable_http_auth(scope, _receive, _send) 

655 if auth_ok: 

656 return ORJSONResponse(status_code=200, content={"authenticated": True}) 

657 return _captured_response() 

658 

659 original_context = user_context_var.get() 

660 user_context_var.set({}) 

661 try: 

662 if settings.email_auth_enabled: 

663 response = await token_scoping_middleware(request, _call_next) 

664 else: 

665 response = await _call_next(request) 

666 

667 if response is None: 

668 response = _captured_response() 

669 

670 if response.status_code >= 400: 

671 return response, {} 

672 

673 return None, get_streamable_http_auth_context() 

674 finally: 

675 user_context_var.set(original_context) 

676 

677 

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

679 """ 

680 Normalize token teams to list of team IDs. 

681 

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

683 This normalizes to just IDs for consistent filtering. 

684 

685 Args: 

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

687 

688 Returns: 

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

690 

691 Examples: 

692 >>> from mcpgateway import main 

693 >>> main._normalize_token_teams(None) 

694 [] 

695 >>> main._normalize_token_teams([]) 

696 [] 

697 >>> main._normalize_token_teams(["team_a", "team_b"]) 

698 ['team_a', 'team_b'] 

699 >>> main._normalize_token_teams([{"id": "team_a", "name": "Team A"}]) 

700 ['team_a'] 

701 >>> main._normalize_token_teams([{"id": "t1"}, "t2", {"name": "no_id"}]) 

702 ['t1', 't2'] 

703 """ 

704 if not teams: 

705 return [] 

706 

707 normalized = [] 

708 for team in teams: 

709 if isinstance(team, dict): 

710 team_id = team.get("id") 

711 if team_id: 

712 normalized.append(team_id) 

713 elif isinstance(team, str): 

714 normalized.append(team) 

715 return normalized 

716 

717 

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

719 """ 

720 Extract and normalize teams from verified JWT token. 

721 

722 SECURITY: Uses normalize_token_teams for consistent secure-first semantics: 

723 - teams key missing → [] (public-only, secure default) 

724 - teams key null + is_admin=true → None (admin bypass) 

725 - teams key null + is_admin=false → [] (public-only) 

726 - teams key [] → [] (explicit public-only) 

727 - teams key [...] → normalized list of string IDs 

728 

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

730 to calling normalize_token_teams on the JWT payload. 

731 

732 Args: 

733 request: FastAPI request object 

734 

735 Returns: 

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

737 

738 Examples: 

739 >>> from mcpgateway import main 

740 >>> from unittest.mock import MagicMock 

741 >>> req = MagicMock() 

742 >>> req.state = MagicMock() 

743 >>> req.state.token_teams = ["team_a"] # Already normalized by auth.py 

744 >>> main._get_token_teams_from_request(req) 

745 ['team_a'] 

746 >>> req.state.token_teams = [] # Public-only 

747 >>> main._get_token_teams_from_request(req) 

748 [] 

749 """ 

750 internal_auth_context = _get_internal_mcp_auth_context(request) 

751 if isinstance(internal_auth_context, dict) and "teams" in internal_auth_context: 

752 internal_teams = internal_auth_context.get("teams") 

753 if internal_teams is None or isinstance(internal_teams, list): 

754 return internal_teams 

755 

756 # SECURITY: First check request.state.token_teams (already normalized by auth.py) 

757 # This is the preferred path as auth.py has already applied normalize_token_teams 

758 # Use getattr with a sentinel to distinguish "not set" from "set to None" 

759 _not_set = object() 

760 token_teams = getattr(request.state, "token_teams", _not_set) 

761 if token_teams is not _not_set and (token_teams is None or isinstance(token_teams, list)): 

762 return token_teams 

763 

764 # Fallback: Use cached verified payload and call normalize_token_teams 

765 cached = getattr(request.state, "_jwt_verified_payload", None) 

766 if cached and isinstance(cached, tuple) and len(cached) == 2: 

767 _, payload = cached 

768 if payload: 

769 # Use normalize_token_teams for consistent secure-first semantics 

770 return normalize_token_teams(payload) 

771 

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

773 return [] 

774 

775 

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

777 """ 

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

779 

780 Args: 

781 request: FastAPI request object 

782 user: User object from auth dependency 

783 

784 Returns: 

785 Tuple of (user_email, token_teams, is_admin) 

786 

787 Examples: 

788 >>> from mcpgateway import main 

789 >>> from unittest.mock import MagicMock 

790 >>> req = MagicMock() 

791 >>> req.state = MagicMock() 

792 >>> req.state._jwt_verified_payload = ("token", {"teams": ["t1"], "is_admin": True}) 

793 >>> user = {"email": "test@x.com", "is_admin": True} # User's is_admin is ignored 

794 >>> email, teams, is_admin = main._get_rpc_filter_context(req, user) 

795 >>> email 

796 'test@x.com' 

797 >>> teams 

798 ['t1'] 

799 >>> is_admin # From token payload, not user dict 

800 True 

801 """ 

802 # Get user email 

803 if hasattr(user, "email"): 

804 user_email = getattr(user, "email", None) 

805 elif isinstance(user, dict): 

806 user_email = user.get("sub") or user.get("email") 

807 else: 

808 user_email = str(user) if user else None 

809 

810 # Get normalized teams from verified token 

811 token_teams = _get_token_teams_from_request(request) 

812 

813 # Check if user is admin - MUST come from token, not DB user 

814 # This ensures that tokens with restricted scope (empty teams) don't inherit admin bypass 

815 is_admin = False 

816 internal_auth_context = _get_internal_mcp_auth_context(request) 

817 if isinstance(internal_auth_context, dict): 

818 if user_email is None: 

819 user_email = internal_auth_context.get("email") 

820 is_admin = bool(internal_auth_context.get("is_admin", False)) 

821 if token_teams is not None and len(token_teams) == 0: 

822 is_admin = False 

823 return user_email, token_teams, is_admin 

824 

825 cached = getattr(request.state, "_jwt_verified_payload", None) 

826 if cached and isinstance(cached, tuple) and len(cached) == 2: 

827 _, payload = cached 

828 if payload: 

829 # Check both top-level is_admin and nested user.is_admin in token 

830 is_admin = payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False) 

831 

832 # If token has empty teams array (public-only token), admin bypass is disabled 

833 # This allows admins to create properly scoped tokens for restricted access 

834 if token_teams is not None and len(token_teams) == 0: 

835 is_admin = False 

836 

837 return user_email, token_teams, is_admin 

838 

839 

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

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

842 

843 Args: 

844 request: Incoming request context. 

845 

846 Returns: 

847 ``True`` when a verified payload tuple is present, otherwise ``False``. 

848 """ 

849 internal_auth_context = _get_internal_mcp_auth_context(request) 

850 if isinstance(internal_auth_context, dict): 

851 return True 

852 cached = getattr(request.state, "_jwt_verified_payload", None) 

853 return bool(cached and isinstance(cached, tuple) and len(cached) == 2 and cached[1]) 

854 

855 

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

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

858 

859 Args: 

860 request: Incoming request context. 

861 user: Authenticated user context from dependency resolution. 

862 

863 Returns: 

864 Tuple of ``(requester_email, requester_is_admin)``. 

865 """ 

866 user_email, _token_teams, token_is_admin = _get_rpc_filter_context(request, user) 

867 resolved_email = user_email or get_user_email(user) 

868 

869 # If a JWT payload exists, respect token-derived admin semantics (including 

870 # public-only admin tokens where bypass is intentionally disabled). 

871 if _has_verified_jwt_payload(request): 

872 return resolved_email, token_is_admin 

873 

874 fallback_is_admin = False 

875 if hasattr(user, "is_admin"): 

876 fallback_is_admin = bool(getattr(user, "is_admin", False)) 

877 elif isinstance(user, dict): 

878 fallback_is_admin = bool(user.get("is_admin", False) or user.get("user", {}).get("is_admin", False)) 

879 

880 return resolved_email, token_is_admin or fallback_is_admin 

881 

882 

883def _get_scoped_resource_access_context(request: Request, user) -> tuple[Optional[str], Optional[List[str]]]: 

884 """Resolve scoped resource access context for the current requester. 

885 

886 Args: 

887 request: Incoming request context. 

888 user: Authenticated user context from dependency resolution. 

889 

890 Returns: 

891 Tuple of ``(user_email, token_teams)`` where ``(None, None)`` represents 

892 unrestricted admin access and ``[]`` represents public-only scope. 

893 """ 

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

895 

896 # Non-JWT admin contexts (for example basic-auth development mode) should 

897 # keep unrestricted access semantics. 

898 if not _has_verified_jwt_payload(request): 

899 _requester_email, fallback_admin = _get_request_identity(request, user) 

900 if fallback_admin: 

901 return None, None 

902 

903 if is_admin and token_teams is None: 

904 return None, None 

905 if token_teams is None: 

906 return user_email, [] 

907 return user_email, token_teams 

908 

909 

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

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

912 

913 Args: 

914 user: Authenticated user context. 

915 db: Active database session. 

916 

917 Returns: 

918 Permission checker payload with email and ``db`` keys. 

919 """ 

920 permission_user = dict(user) if isinstance(user, dict) else {"email": get_user_email(user)} 

921 if not permission_user.get("email"): 

922 permission_user["email"] = get_user_email(user) 

923 permission_user["db"] = db 

924 return permission_user 

925 

926 

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

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

929 

930 Args: 

931 request: Incoming request context. 

932 

933 Returns: 

934 None: no explicit scope cap (empty permissions or no JWT — defer to RBAC) 

935 set: explicit permission set (may contain '*' for wildcard) 

936 """ 

937 internal_auth_context = _get_internal_mcp_auth_context(request) 

938 if isinstance(internal_auth_context, dict): 

939 permissions = internal_auth_context.get("scoped_permissions") 

940 if not permissions: 

941 return None 

942 return set(permissions) 

943 

944 cached = getattr(request.state, "_jwt_verified_payload", None) 

945 if not cached or not isinstance(cached, tuple) or len(cached) != 2: 

946 return None 

947 _, payload = cached 

948 if not payload or not isinstance(payload, dict): 

949 return None 

950 scopes = payload.get("scopes") 

951 if not scopes or not isinstance(scopes, dict): 

952 return None 

953 permissions = scopes.get("permissions") 

954 if not permissions: # Empty list or None = defer to RBAC 

955 return None 

956 return set(permissions) 

957 

958 

959def _is_permission_admin_user(user) -> bool: 

960 """Return whether the caller already has permission-layer admin authority. 

961 

962 This is stricter than token-scope admin semantics. It is used only to skip 

963 redundant RBAC DB lookups after token scope caps have already been enforced. 

964 

965 Args: 

966 user: Authenticated user object or dict-like payload. 

967 

968 Returns: 

969 ``True`` when the caller already has permission-layer admin authority. 

970 """ 

971 if hasattr(user, "is_admin"): 

972 return bool(getattr(user, "is_admin", False)) 

973 if isinstance(user, dict): 

974 if "permission_is_admin" in user: 

975 return bool(user.get("permission_is_admin", False)) 

976 return False 

977 return False 

978 

979 

980async def _ensure_rpc_permission(user, db: Session, permission: str, method: str, request: Request | None = None) -> None: 

981 """Require a specific RPC permission for a method branch. 

982 

983 Enforces both layers: 

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

985 2. RBAC role-based permission check 

986 

987 Args: 

988 user: Authenticated user context. 

989 db: Active database session. 

990 permission: Permission required for the method. 

991 method: JSON-RPC method name being authorized. 

992 request: Optional FastAPI request for extracting token scopes. 

993 

994 Raises: 

995 JSONRPCError: If the requester lacks the required permission. 

996 """ 

997 # Layer 1: Token scope cap 

998 if request is not None: 

999 scoped = _extract_scoped_permissions(request) 

1000 if scoped is not None and "*" not in scoped and permission not in scoped: 

1001 logger.warning("RPC permission denied (token scope): method=%s, required=%s", method, permission) 

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

1003 

1004 if permission == "admin.system_config" and _is_permission_admin_user(user): 

1005 return 

1006 

1007 # Layer 2: RBAC check 

1008 # Session tokens have no explicit team_id, so check across all team-scoped roles. 

1009 # Mirrors the @require_permission decorator's check_any_team fallback (rbac.py:562-576). 

1010 check_any_team = isinstance(user, dict) and user.get("token_use") == "session" 

1011 checker = PermissionChecker(_build_rpc_permission_user(user, db)) 

1012 if not await checker.has_permission(permission, check_any_team=check_any_team): 

1013 logger.warning("RPC permission denied (RBAC): method=%s, required=%s", method, permission) 

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

1015 

1016 

1017def _serialize_mcp_tool_definition(tool: Any) -> Dict[str, Any]: 

1018 """Return an MCP-compliant tool definition without API-only metadata fields. 

1019 

1020 Args: 

1021 tool: Tool ORM object, pydantic model, or dict-like payload. 

1022 

1023 Returns: 

1024 MCP-compatible tool definition dictionary. 

1025 """ 

1026 if hasattr(tool, "model_dump"): 

1027 data = tool.model_dump(by_alias=True, exclude_none=True) 

1028 elif isinstance(tool, dict): 

1029 data = dict(tool) 

1030 else: 

1031 data = {} 

1032 

1033 name = data.get("name", getattr(tool, "name", None)) 

1034 description = data.get("description", getattr(tool, "description", None)) 

1035 input_schema = data.get("inputSchema", getattr(tool, "input_schema", None)) 

1036 

1037 payload: Dict[str, Any] = {} 

1038 if name is not None: 

1039 payload["name"] = name 

1040 if description is not None or name is not None or input_schema is not None: 

1041 payload["description"] = description or "" 

1042 if input_schema is not None: 

1043 payload["inputSchema"] = input_schema 

1044 

1045 output_schema = data.get("outputSchema", getattr(tool, "output_schema", None)) 

1046 if output_schema is not None: 

1047 payload["outputSchema"] = output_schema 

1048 

1049 annotations = data.get("annotations", getattr(tool, "annotations", None)) 

1050 if annotations is not None: 

1051 payload["annotations"] = annotations 

1052 

1053 return {key: value for key, value in payload.items() if value is not None} 

1054 

1055 

1056def _serialize_mcp_tool_definitions(tools: List[Any]) -> List[Dict[str, Any]]: 

1057 """Serialize tool records to MCP tool definitions. 

1058 

1059 Args: 

1060 tools: Iterable of tool-like records to serialize. 

1061 

1062 Returns: 

1063 List of MCP-compatible tool definitions. 

1064 """ 

1065 return [_serialize_mcp_tool_definition(tool) for tool in tools] 

1066 

1067 

1068def _serialize_legacy_tool_payloads(tools: List[Any]) -> List[Dict[str, Any]]: 

1069 """Serialize tool records using the legacy JSON-RPC shape. 

1070 

1071 Args: 

1072 tools: Iterable of tool-like records to serialize. 

1073 

1074 Returns: 

1075 List of legacy tool payload dictionaries. 

1076 """ 

1077 payloads: List[Dict[str, Any]] = [] 

1078 for tool in tools: 

1079 if hasattr(tool, "model_dump"): 

1080 payload = tool.model_dump(by_alias=True, exclude_none=True) 

1081 elif isinstance(tool, dict): 

1082 payload = dict(tool) 

1083 else: 

1084 payload = {} 

1085 payloads.append(payload) 

1086 return payloads 

1087 

1088 

1089def _enforce_scoped_resource_access(request: Request, db: Session, user, resource_path: str) -> None: 

1090 """Apply token-scope ownership checks for a concrete resource path. 

1091 

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

1093 enforce visibility even if middleware coverage regresses. 

1094 

1095 Args: 

1096 request: Incoming request context. 

1097 db: Active database session. 

1098 user: Authenticated user context. 

1099 resource_path: Canonical resource path (e.g. ``/tools/{id}``). 

1100 

1101 Raises: 

1102 HTTPException: If access to the target resource is not allowed. 

1103 """ 

1104 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user) 

1105 

1106 # Admin bypass / unrestricted scope 

1107 if scoped_token_teams is None: 

1108 return 

1109 

1110 if not token_scoping_middleware._check_resource_team_ownership( # pylint: disable=protected-access 

1111 resource_path, 

1112 scoped_token_teams, 

1113 db=db, 

1114 _user_email=scoped_user_email, 

1115 ): 

1116 logger.warning("Scoped resource access denied: user=%s, resource=%s", scoped_user_email, resource_path) 

1117 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_ACCESS_DENIED_MSG) 

1118 

1119 

1120async def _assert_session_owner_or_admin(request: Request, user, session_id: str) -> None: 

1121 """Ensure session operations are limited to the owner unless requester is admin. 

1122 

1123 Args: 

1124 request: Incoming request context. 

1125 user: Authenticated user context. 

1126 session_id: Target session identifier. 

1127 

1128 Raises: 

1129 HTTPException: If session is missing or requester is not authorized. 

1130 """ 

1131 session_owner = await session_registry.get_session_owner(session_id) 

1132 if not session_owner: 

1133 session_exists = await session_registry.session_exists(session_id) 

1134 if session_exists is False: 

1135 raise HTTPException(status_code=404, detail="Session not found") 

1136 raise HTTPException(status_code=403, detail="Session owner metadata unavailable") 

1137 

1138 requester_email, requester_is_admin = _get_request_identity(request, user) 

1139 if requester_is_admin: 

1140 return 

1141 if requester_email and requester_email == session_owner: 

1142 return 

1143 raise HTTPException(status_code=403, detail="Session access denied") 

1144 

1145 

1146async def _authorize_run_cancellation(request: Request, user, request_id: str, *, as_jsonrpc_error: bool) -> None: 

1147 """Authorize a notifications/cancelled request for a specific run id. 

1148 

1149 Args: 

1150 request: Incoming request context. 

1151 user: Authenticated user context. 

1152 request_id: Run/request identifier to cancel. 

1153 as_jsonrpc_error: Raise ``JSONRPCError`` when True, otherwise ``HTTPException``. 

1154 

1155 Raises: 

1156 JSONRPCError: When ``as_jsonrpc_error`` is True and cancellation is not authorized. 

1157 HTTPException: When ``as_jsonrpc_error`` is False and cancellation is not authorized. 

1158 """ 

1159 requester_email, requester_token_teams, requester_is_admin = _get_rpc_filter_context(request, user) 

1160 requester_teams = [] if requester_token_teams is None else list(requester_token_teams) 

1161 run_status = await cancellation_service.get_status(request_id) 

1162 

1163 if run_status is None: 

1164 # Notifications are best-effort; unknown request ids should be accepted 

1165 # as no-ops rather than rejected as authorization failures. 

1166 return 

1167 

1168 run_owner_email = run_status.get("owner_email") 

1169 run_owner_team_ids = run_status.get("owner_team_ids") or [] 

1170 requester_is_owner = bool(run_owner_email and requester_email and run_owner_email == requester_email) 

1171 requester_shares_team = bool(run_owner_team_ids and requester_teams and any(team in run_owner_team_ids for team in requester_teams)) 

1172 unauthorized = not requester_is_admin and not requester_is_owner and not requester_shares_team 

1173 

1174 if unauthorized: 

1175 if as_jsonrpc_error: 

1176 raise JSONRPCError(-32003, "Not authorized to cancel this run", {"requestId": request_id}) 

1177 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to cancel this run") 

1178 

1179 

1180# Initialize cache 

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

1182 

1183 

1184def _rust_build_included() -> bool: 

1185 """Return whether the current image includes Rust MCP artifacts. 

1186 

1187 Returns: 

1188 ``True`` when the current image contains the Rust MCP binaries/plugins. 

1189 """ 

1190 return version_module.rust_build_included() 

1191 

1192 

1193def _rust_runtime_managed() -> bool: 

1194 """Return whether the gateway expects to manage the Rust MCP sidecar locally. 

1195 

1196 Returns: 

1197 ``True`` when the gateway should launch and supervise the Rust sidecar. 

1198 """ 

1199 return version_module.rust_runtime_managed() 

1200 

1201 

1202def _current_mcp_transport_mount() -> str: 

1203 """Return which public /mcp transport is currently mounted. 

1204 

1205 Returns: 

1206 Runtime label identifying the currently mounted public MCP transport. 

1207 """ 

1208 return version_module.current_mcp_transport_mount() 

1209 

1210 

1211def _should_mount_public_rust_transport() -> bool: 

1212 """Return whether the public ``/mcp`` path should be served directly by Rust. 

1213 

1214 Returns: 

1215 ``True`` only when the Rust runtime is enabled and the session-auth reuse 

1216 path is enabled, allowing Rust to safely own steady-state public MCP 

1217 session traffic. Otherwise returns ``False`` and leaves public MCP on 

1218 the Python ingress path. 

1219 """ 

1220 return version_module.should_mount_public_rust_transport() 

1221 

1222 

1223def _should_use_rust_public_session_stack() -> bool: 

1224 """Return whether Rust should own the effective public MCP session stack. 

1225 

1226 Returns: 

1227 ``True`` only when the Rust runtime is enabled and session-auth reuse is 

1228 enabled, allowing the public transport, session metadata, replay/resume, 

1229 live-stream, and affinity behavior to stay on a consistent Rust-backed 

1230 path. Otherwise returns ``False`` so the public MCP session stack falls 

1231 back to Python semantics. 

1232 """ 

1233 return version_module.should_use_rust_public_session_stack() 

1234 

1235 

1236def _current_mcp_runtime_mode() -> str: 

1237 """Return a compact runtime-mode label for observability. 

1238 

1239 Returns: 

1240 Human-readable runtime mode label for health/readiness reporting. 

1241 """ 

1242 return version_module.current_mcp_runtime_mode() 

1243 

1244 

1245def _current_mcp_session_core_mode() -> str: 

1246 """Return which session core currently owns MCP session metadata. 

1247 

1248 Returns: 

1249 ``"rust"`` when the Rust session core is enabled, otherwise ``"python"``. 

1250 """ 

1251 return version_module.current_mcp_session_core_mode() 

1252 

1253 

1254def _current_mcp_event_store_mode() -> str: 

1255 """Return which runtime currently owns MCP resumable event-store semantics. 

1256 

1257 Returns: 

1258 ``"rust"`` when the Rust event store is enabled, otherwise ``"python"``. 

1259 """ 

1260 return version_module.current_mcp_event_store_mode() 

1261 

1262 

1263def _current_mcp_resume_core_mode() -> str: 

1264 """Return which runtime currently owns public MCP replay/resume behavior. 

1265 

1266 Returns: 

1267 ``"rust"`` when Rust owns replay/resume, otherwise ``"python"``. 

1268 """ 

1269 return version_module.current_mcp_resume_core_mode() 

1270 

1271 

1272def _current_mcp_live_stream_core_mode() -> str: 

1273 """Return which runtime currently owns non-resume public GET /mcp SSE behavior. 

1274 

1275 Returns: 

1276 ``"rust"`` when Rust owns live GET /mcp streaming, otherwise ``"python"``. 

1277 """ 

1278 return version_module.current_mcp_live_stream_core_mode() 

1279 

1280 

1281def _current_mcp_affinity_core_mode() -> str: 

1282 """Return which runtime currently owns MCP multi-worker session-affinity forwarding. 

1283 

1284 Returns: 

1285 ``"rust"`` when Rust owns session-affinity forwarding, otherwise ``"python"``. 

1286 """ 

1287 return version_module.current_mcp_affinity_core_mode() 

1288 

1289 

1290def _current_mcp_session_auth_reuse_mode() -> str: 

1291 """Return which runtime currently owns MCP session-bound auth-context reuse. 

1292 

1293 Returns: 

1294 ``"rust"`` when Rust session auth reuse is enabled, otherwise ``"python"``. 

1295 """ 

1296 return version_module.current_mcp_session_auth_reuse_mode() 

1297 

1298 

1299def _mcp_runtime_status_payload() -> Dict[str, Any]: 

1300 """Return MCP runtime diagnostics for health/readiness endpoints. 

1301 

1302 Returns: 

1303 Diagnostic payload describing the active MCP runtime configuration. 

1304 """ 

1305 return version_module.mcp_runtime_status_payload() 

1306 

1307 

1308def _apply_runtime_mode_headers(response: Response) -> None: 

1309 """Attach MCP runtime mode headers to a response. 

1310 

1311 Args: 

1312 response: Response object to annotate. 

1313 """ 

1314 response.headers["x-contextforge-mcp-runtime-mode"] = _current_mcp_runtime_mode() 

1315 response.headers["x-contextforge-mcp-transport-mounted"] = _current_mcp_transport_mount() 

1316 response.headers["x-contextforge-rust-build-included"] = "true" if _rust_build_included() else "false" 

1317 response.headers["x-contextforge-mcp-session-core-mode"] = _current_mcp_session_core_mode() 

1318 response.headers["x-contextforge-mcp-event-store-mode"] = _current_mcp_event_store_mode() 

1319 response.headers["x-contextforge-mcp-resume-core-mode"] = _current_mcp_resume_core_mode() 

1320 response.headers["x-contextforge-mcp-live-stream-core-mode"] = _current_mcp_live_stream_core_mode() 

1321 response.headers["x-contextforge-mcp-affinity-core-mode"] = _current_mcp_affinity_core_mode() 

1322 response.headers["x-contextforge-mcp-session-auth-reuse-mode"] = _current_mcp_session_auth_reuse_mode() 

1323 

1324 

1325# Type aliases for improved readability 

1326ToolsResponse: TypeAlias = Union[List[ToolRead], CursorPaginatedToolsResponse, List[Dict[Any, Any]], Dict[Any, Any], ORJSONResponse] 

1327ToolResponse: TypeAlias = Union[ToolRead, Dict[Any, Any], ORJSONResponse] 

1328 

1329 

1330@lru_cache(maxsize=512) 

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

1332 """Cache parsed JSONPath expression. 

1333 

1334 Args: 

1335 jsonpath: The JSONPath expression string. 

1336 

1337 Returns: 

1338 Parsed JSONPath object. 

1339 

1340 Raises: 

1341 Exception: If the JSONPath expression is invalid. 

1342 """ 

1343 return parse(jsonpath) 

1344 

1345 

1346def _parse_apijsonpath(raw: Optional[Union[str, JsonPathModifier]]) -> Optional[JsonPathModifier]: 

1347 """ 

1348 Parse apijsonpath parameter from either a JSON string or a JsonPathModifier model. 

1349 

1350 Performs early validation of JSONPath syntax to fail fast and provide clear error messages. 

1351 

1352 Args: 

1353 raw: Either a JSON-encoded string or a JsonPathModifier instance 

1354 

1355 Returns: 

1356 Parsed JsonPathModifier or None if raw is None 

1357 

1358 Raises: 

1359 HTTPException: If the JSON string is invalid, unexpected type provided, 

1360 jsonpath expression is empty, or JSONPath syntax is invalid (400 Bad Request) 

1361 """ 

1362 if raw is None: 

1363 return None 

1364 

1365 if isinstance(raw, str): 

1366 try: 

1367 parsed = JsonPathModifier.model_validate(json.loads(raw)) 

1368 # Validate jsonpath is not empty if provided 

1369 if parsed.jsonpath is not None: 

1370 if not parsed.jsonpath.strip(): 

1371 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="JSONPath expression cannot be empty") 

1372 # Early validation: ensure JSONPath syntax is valid 

1373 try: 

1374 _parse_jsonpath(parsed.jsonpath) 

1375 except Exception as parse_ex: 

1376 detail = f"Invalid JSONPath syntax: {parse_ex}" if settings.log_level == "DEBUG" else "Invalid JSONPath expression" 

1377 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) 

1378 return parsed 

1379 except HTTPException: 

1380 # Re-raise HTTPException as-is (includes empty jsonpath and syntax validation) 

1381 raise 

1382 except json.JSONDecodeError as ex: 

1383 # User error: malformed JSON (JSONDecodeError is subclass of ValueError, so catch it specifically) 

1384 detail = f"Invalid apijsonpath JSON: {ex}" if settings.log_level == "DEBUG" else "Invalid apijsonpath format" 

1385 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) 

1386 except ValidationError as ex: 

1387 # Pydantic validation error 

1388 detail = f"Invalid apijsonpath structure: {ex}" if settings.log_level == "DEBUG" else "Invalid apijsonpath structure" 

1389 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) 

1390 except Exception as ex: 

1391 # Unexpected error - log it and return generic message 

1392 logger.error(f"Unexpected error parsing apijsonpath: {ex}", exc_info=True) 

1393 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to parse apijsonpath") 

1394 elif isinstance(raw, JsonPathModifier): 

1395 # Validate jsonpath is not empty if provided 

1396 if raw.jsonpath is not None: 

1397 if not raw.jsonpath.strip(): 

1398 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="JSONPath expression cannot be empty") 

1399 # Early validation: ensure JSONPath syntax is valid 

1400 try: 

1401 _parse_jsonpath(raw.jsonpath) 

1402 except Exception as parse_ex: 

1403 detail = f"Invalid JSONPath syntax: {parse_ex}" if settings.log_level == "DEBUG" else "Invalid JSONPath expression" 

1404 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) 

1405 return raw 

1406 

1407 # Unexpected type - fail fast with clear error message 

1408 # Only show type name in debug mode to avoid information disclosure 

1409 type_info = f": got {type(raw).__name__}" if settings.log_level == "DEBUG" else "" 

1410 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid apijsonpath type{type_info}") 

1411 

1412 

1413def jsonpath_modifier(data: Any, jsonpath: str = "$[*]", mappings: Optional[Dict[str, str]] = None) -> Union[List, Dict]: 

1414 """ 

1415 Applies the given JSONPath expression and mappings to the data. 

1416 Uses cached parsed expressions for performance. 

1417 

1418 Args: 

1419 data: The JSON data to query. 

1420 jsonpath: The JSONPath expression to apply. 

1421 mappings: Optional dictionary of mappings where keys are new field names 

1422 and values are JSONPath expressions. 

1423 

1424 Returns: 

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

1426 

1427 Raises: 

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

1429 

1430 Examples: 

1431 >>> jsonpath_modifier({'a': 1, 'b': 2}, '$.a') 

1432 [1] 

1433 >>> jsonpath_modifier([{'a': 1}, {'a': 2}], '$[*].a') 

1434 [1, 2] 

1435 >>> jsonpath_modifier({'a': {'b': 2}}, '$.a.b') 

1436 [2] 

1437 >>> jsonpath_modifier({'a': 1}, '$.b') 

1438 [] 

1439 """ 

1440 if not jsonpath: 

1441 jsonpath = "$[*]" 

1442 

1443 # Log jsonpath_modifier invocation with structured data (only if debug enabled) 

1444 if logger.isEnabledFor(logging.DEBUG): 

1445 data_length = len(data) if isinstance(data, list) else None 

1446 logger.debug( 

1447 f"jsonpath_modifier: path='{SecurityValidator.sanitize_log_message(jsonpath)}', has_mappings={mappings is not None}, " f"data_type={type(data).__name__}, data_length={data_length}" 

1448 ) 

1449 

1450 try: 

1451 main_expr: JSONPath = _parse_jsonpath(jsonpath) 

1452 except Exception as e: 

1453 raise HTTPException(status_code=400, detail=f"Invalid main JSONPath expression: {e}") 

1454 

1455 try: 

1456 main_matches = main_expr.find(data) 

1457 except Exception as e: 

1458 raise HTTPException(status_code=400, detail=f"Error executing main JSONPath: {e}") 

1459 

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

1461 

1462 if mappings: 

1463 results = transform_data_with_mappings(results, mappings) 

1464 

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

1466 return results[0] 

1467 

1468 return results 

1469 

1470 

1471def transform_data_with_mappings(data: list[Any], mappings: dict[str, str]) -> list[Any]: 

1472 """ 

1473 Applies mappings to data using cached JSONPath expressions. 

1474 Parses each mapping expression once per call, not per item. 

1475 

1476 Args: 

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

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

1479 

1480 Returns: 

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

1482 

1483 Raises: 

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

1485 

1486 Examples: 

1487 >>> transform_data_with_mappings([{'first_name': "Bruce", 'second_name': "Wayne"},{'first_name': "Diana", 'second_name': "Prince"}], {"n": "$.first_name"}) 

1488 [{'n': 'Bruce'}, {'n': 'Diana'}] 

1489 """ 

1490 # Pre-parse all mapping expressions once (not per item) 

1491 parsed_mappings: Dict[str, JSONPath] = {} 

1492 for new_key, mapping_expr_str in mappings.items(): 

1493 try: 

1494 parsed_mappings[new_key] = _parse_jsonpath(mapping_expr_str) 

1495 except Exception as e: 

1496 raise HTTPException(status_code=400, detail=f"Invalid mapping JSONPath for key '{new_key}': {e}") 

1497 

1498 mapped_results = [] 

1499 for item in data: 

1500 mapped_item = {} 

1501 for new_key, mapping_expr in parsed_mappings.items(): 

1502 try: 

1503 mapping_matches = mapping_expr.find(item) 

1504 except Exception as e: 

1505 raise HTTPException(status_code=400, detail=f"Error executing mapping JSONPath for key '{new_key}': {e}") 

1506 

1507 if not mapping_matches: 

1508 mapped_item[new_key] = None 

1509 elif len(mapping_matches) == 1: 

1510 mapped_item[new_key] = mapping_matches[0].value 

1511 else: 

1512 mapped_item[new_key] = [m.value for m in mapping_matches] 

1513 mapped_results.append(mapped_item) 

1514 

1515 return mapped_results 

1516 

1517 

1518async def attempt_to_bootstrap_sso_providers(): 

1519 """ 

1520 Try to bootstrap SSO provider services based on settings. 

1521 """ 

1522 try: 

1523 # First-Party 

1524 from mcpgateway.utils.sso_bootstrap import bootstrap_sso_providers # pylint: disable=import-outside-toplevel 

1525 

1526 await bootstrap_sso_providers() 

1527 logger.info("SSO providers bootstrapped successfully") 

1528 except Exception as e: 

1529 logger.warning(f"Failed to bootstrap SSO providers: {e}") 

1530 

1531 

1532#################### 

1533# Startup/Shutdown # 

1534#################### 

1535def _can_manage_sighup_handler() -> bool: 

1536 """Return whether this runtime context can safely install process signal handlers. 

1537 

1538 Returns: 

1539 ``True`` when startup is running on the process main thread and SIGHUP is available. 

1540 """ 

1541 return hasattr(signal, "SIGHUP") and threading.current_thread() is threading.main_thread() 

1542 

1543 

1544def _install_sighup_handler() -> bool: 

1545 """Install the SIGHUP handler when the current runtime context supports it. 

1546 

1547 Returns: 

1548 ``True`` when the handler was installed in the current runtime context. 

1549 """ 

1550 if not _can_manage_sighup_handler(): 

1551 logger.debug("Skipping SIGHUP handler registration outside the main thread") 

1552 return False 

1553 

1554 # First-Party 

1555 from mcpgateway.handlers.signal_handlers import sighup_handler # pylint: disable=import-outside-toplevel 

1556 

1557 signal.signal(signal.SIGHUP, sighup_handler) 

1558 return True 

1559 

1560 

1561def _restore_default_sighup_handler() -> None: 

1562 """Restore the default SIGHUP handler when the current runtime context supports it. 

1563 

1564 Returns: 

1565 ``None``. 

1566 """ 

1567 if not _can_manage_sighup_handler(): 

1568 return 

1569 signal.signal(signal.SIGHUP, signal.SIG_DFL) 

1570 

1571 

1572@asynccontextmanager 

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

1574 """ 

1575 Manage the application's startup and shutdown lifecycle. 

1576 

1577 The function initialises every core service on entry and then 

1578 shuts them down in reverse order on exit. 

1579 

1580 Args: 

1581 _app (FastAPI): FastAPI app 

1582 

1583 Yields: 

1584 None 

1585 

1586 Raises: 

1587 SystemExit: When a critical startup error occurs that prevents 

1588 the application from starting successfully. 

1589 Exception: Any unhandled error that occurs during service 

1590 initialisation or shutdown is re-raised to the caller. 

1591 """ 

1592 aggregation_stop_event: Optional[asyncio.Event] = None 

1593 aggregation_loop_task: Optional[asyncio.Task] = None 

1594 aggregation_backfill_task: Optional[asyncio.Task] = None 

1595 

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

1597 await logging_service.initialize() 

1598 logger.info("Starting ContextForge services") 

1599 

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

1601 await get_redis_client() 

1602 

1603 # Initialize shared HTTP client (connection pool for all outbound requests) 

1604 # First-Party 

1605 from mcpgateway.services.http_client_service import SharedHttpClient # pylint: disable=import-outside-toplevel 

1606 

1607 await SharedHttpClient.get_instance() 

1608 

1609 # Update HTTP pool metrics after SharedHttpClient is initialized 

1610 if hasattr(app.state, "update_http_pool_metrics"): 

1611 app.state.update_http_pool_metrics() 

1612 

1613 # Initialize MCP session pool (for session reuse across tool invocations) 

1614 # Also initialize if session affinity is enabled (needs the ownership registry) 

1615 if settings.mcp_session_pool_enabled or settings.mcpgateway_session_affinity_enabled: 

1616 # First-Party 

1617 from mcpgateway.services.mcp_session_pool import init_mcp_session_pool # pylint: disable=import-outside-toplevel 

1618 

1619 # Auto-align pool health check interval to min of pool and gateway settings 

1620 effective_health_check_interval = min( 

1621 settings.health_check_interval, 

1622 settings.mcp_session_pool_health_check_interval, 

1623 ) 

1624 

1625 max_sessions_per_key = settings.mcpgateway_session_affinity_max_sessions if settings.mcpgateway_session_affinity_enabled else settings.mcp_session_pool_max_per_key 

1626 init_mcp_session_pool( 

1627 max_sessions_per_key=max_sessions_per_key, 

1628 session_ttl_seconds=settings.mcp_session_pool_ttl, 

1629 health_check_interval_seconds=effective_health_check_interval, 

1630 acquire_timeout_seconds=settings.mcp_session_pool_acquire_timeout, 

1631 session_create_timeout_seconds=settings.mcp_session_pool_create_timeout, 

1632 circuit_breaker_threshold=settings.mcp_session_pool_circuit_breaker_threshold, 

1633 circuit_breaker_reset_seconds=settings.mcp_session_pool_circuit_breaker_reset, 

1634 identity_headers=frozenset(settings.mcp_session_pool_identity_headers), 

1635 idle_pool_eviction_seconds=settings.mcp_session_pool_idle_eviction, 

1636 # Use dedicated transport timeout (default 30s to match MCP SDK default). 

1637 # This is separate from health_check_timeout to allow long-running tool calls. 

1638 default_transport_timeout_seconds=settings.mcp_session_pool_transport_timeout, 

1639 # Configurable health check chain - ordered list of methods to try. 

1640 health_check_methods=settings.mcp_session_pool_health_check_methods, 

1641 health_check_timeout_seconds=settings.mcp_session_pool_health_check_timeout, 

1642 ) 

1643 logger.info("MCP session pool initialized") 

1644 

1645 # Initialize LLM chat router Redis client 

1646 # First-Party 

1647 from mcpgateway.routers.llmchat_router import init_redis as init_llmchat_redis # pylint: disable=import-outside-toplevel 

1648 

1649 await init_llmchat_redis() 

1650 

1651 # Initialize observability (Phoenix tracing) 

1652 init_telemetry() 

1653 logger.info("Observability initialized") 

1654 

1655 try: 

1656 # Validate security configuration 

1657 validate_security_configuration() 

1658 

1659 if plugin_manager: 

1660 logger.debug("plugin_manager.initialize() starting...") 

1661 try: 

1662 await plugin_manager.initialize() 

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

1664 except Exception as diag_exc: 

1665 logger.error(f"plugin_manager.initialize() failed: {diag_exc}", exc_info=True) 

1666 raise 

1667 

1668 if settings.enable_header_passthrough: 

1669 await setup_passthrough_headers() 

1670 else: 

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

1672 

1673 await tool_service.initialize() 

1674 await resource_service.initialize() 

1675 await prompt_service.initialize() 

1676 await gateway_service.initialize() 

1677 

1678 # Start notification service for event-driven refresh (after gateway_service is ready) 

1679 if settings.mcp_session_pool_enabled: 

1680 # First-Party 

1681 from mcpgateway.services.mcp_session_pool import start_pool_notification_service # pylint: disable=import-outside-toplevel 

1682 

1683 await start_pool_notification_service(gateway_service) 

1684 

1685 # Start heartbeat and RPC listener for multi-worker session affinity. 

1686 # This must be outside the mcp_session_pool_enabled guard because 

1687 # affinity-only deployments (pool disabled, affinity enabled) still 

1688 # need heartbeat and RPC forwarding. 

1689 if settings.mcpgateway_session_affinity_enabled: 

1690 # First-Party 

1691 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool # pylint: disable=import-outside-toplevel 

1692 

1693 pool = get_mcp_session_pool() 

1694 pool.start_heartbeat() 

1695 pool._rpc_listener_task = asyncio.create_task(pool.start_rpc_listener()) # pylint: disable=protected-access 

1696 logger.info("Multi-worker session affinity heartbeat and RPC listener started") 

1697 

1698 await root_service.initialize() 

1699 await completion_service.initialize() 

1700 await sampling_handler.initialize() 

1701 await export_service.initialize() 

1702 await import_service.initialize() 

1703 if a2a_service: 

1704 await a2a_service.initialize() 

1705 await resource_cache.initialize() 

1706 await streamable_http_session.initialize() 

1707 await session_registry.initialize() 

1708 

1709 # Initialize OrchestrationService for tool cancellation if enabled 

1710 if settings.mcpgateway_tool_cancellation_enabled: 

1711 await cancellation_service.initialize() 

1712 logger.info("Tool cancellation feature enabled") 

1713 else: 

1714 logger.info("Tool cancellation feature disabled") 

1715 

1716 # Initialize elicitation service 

1717 if settings.mcpgateway_elicitation_enabled: 

1718 # First-Party 

1719 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel 

1720 

1721 elicitation_service = get_elicitation_service() 

1722 await elicitation_service.start() 

1723 logger.info("Elicitation service initialized") 

1724 

1725 # Initialize metrics buffer service for batching metric writes 

1726 if settings.metrics_buffer_enabled: 

1727 # First-Party 

1728 from mcpgateway.services.metrics_buffer_service import get_metrics_buffer_service # pylint: disable=import-outside-toplevel 

1729 

1730 metrics_buffer_service = get_metrics_buffer_service() 

1731 await metrics_buffer_service.start() 

1732 if settings.db_metrics_recording_enabled: 

1733 logger.info("Metrics buffer service initialized") 

1734 else: 

1735 logger.info("Metrics buffer service initialized (recording disabled)") 

1736 

1737 # Initialize metrics cleanup service for automatic deletion of old metrics 

1738 if settings.metrics_cleanup_enabled: 

1739 # First-Party 

1740 from mcpgateway.services.metrics_cleanup_service import get_metrics_cleanup_service # pylint: disable=import-outside-toplevel 

1741 

1742 metrics_cleanup_service = get_metrics_cleanup_service() 

1743 await metrics_cleanup_service.start() 

1744 logger.info("Metrics cleanup service initialized (retention: %d days)", settings.metrics_retention_days) 

1745 

1746 # Initialize metrics rollup service for hourly aggregation 

1747 if settings.metrics_rollup_enabled: 

1748 # First-Party 

1749 from mcpgateway.services.metrics_rollup_service import get_metrics_rollup_service # pylint: disable=import-outside-toplevel 

1750 

1751 metrics_rollup_service = get_metrics_rollup_service() 

1752 await metrics_rollup_service.start() 

1753 logger.info("Metrics rollup service initialized (interval: %dh)", settings.metrics_rollup_interval_hours) 

1754 

1755 refresh_slugs_on_startup() 

1756 

1757 # Bootstrap SSO providers from environment configuration 

1758 if settings.sso_enabled: 

1759 await attempt_to_bootstrap_sso_providers() 

1760 

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

1762 

1763 _install_sighup_handler() 

1764 

1765 # Start cache invalidation subscriber for cross-worker cache synchronization 

1766 # First-Party 

1767 from mcpgateway.cache.registry_cache import get_cache_invalidation_subscriber # pylint: disable=import-outside-toplevel 

1768 

1769 cache_invalidation_subscriber = get_cache_invalidation_subscriber() 

1770 await cache_invalidation_subscriber.start() 

1771 

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

1773 logging_service.configure_uvicorn_after_startup() 

1774 

1775 if settings.metrics_aggregation_enabled and settings.metrics_aggregation_auto_start: 

1776 aggregation_stop_event = asyncio.Event() 

1777 log_aggregator = get_log_aggregator() 

1778 

1779 async def run_log_backfill() -> None: 

1780 """Backfill log aggregation metrics for configured hours.""" 

1781 hours = getattr(settings, "metrics_aggregation_backfill_hours", 0) 

1782 if hours <= 0: 

1783 return 

1784 try: 

1785 await asyncio.to_thread(log_aggregator.backfill, hours) 

1786 logger.info("Log aggregation backfill completed for last %s hour(s)", hours) 

1787 except Exception as backfill_error: # pragma: no cover - defensive logging 

1788 logger.warning("Log aggregation backfill failed: %s", backfill_error) 

1789 

1790 async def run_log_aggregation_loop() -> None: 

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

1792 

1793 Raises: 

1794 asyncio.CancelledError: When aggregation is stopped 

1795 """ 

1796 interval_seconds = max(1, int(settings.metrics_aggregation_window_minutes)) * 60 

1797 logger.info( 

1798 "Starting log aggregation loop (window=%s min)", 

1799 log_aggregator.aggregation_window_minutes, 

1800 ) 

1801 try: 

1802 while not aggregation_stop_event.is_set(): 

1803 try: 

1804 await asyncio.to_thread(log_aggregator.aggregate_all_components) 

1805 except Exception as agg_error: # pragma: no cover - defensive logging 

1806 logger.warning("Log aggregation loop iteration failed: %s", agg_error) 

1807 

1808 try: 

1809 await asyncio.wait_for(aggregation_stop_event.wait(), timeout=interval_seconds) 

1810 except asyncio.TimeoutError: 

1811 continue 

1812 except asyncio.CancelledError: 

1813 logger.debug("Log aggregation loop cancelled") 

1814 raise 

1815 finally: 

1816 logger.info("Log aggregation loop stopped") 

1817 

1818 aggregation_backfill_task = asyncio.create_task(run_log_backfill()) 

1819 aggregation_loop_task = asyncio.create_task(run_log_aggregation_loop()) 

1820 elif settings.metrics_aggregation_enabled: 

1821 logger.info("Metrics aggregation auto-start disabled; performance metrics will be generated on-demand when requested.") 

1822 

1823 yield 

1824 except Exception as e: 

1825 logger.error(f"Error during startup: {str(e)}") 

1826 # For plugin errors, exit cleanly without stack trace spam 

1827 if "Plugin initialization failed" in str(e): 

1828 # Suppress uvicorn error logging for clean exit 

1829 logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL) 

1830 raise SystemExit(1) 

1831 raise 

1832 finally: 

1833 # Restore default SIGHUP handling in case we reset signal handlers. 

1834 try: 

1835 _restore_default_sighup_handler() 

1836 except Exception as exc: # pragma: no cover - defensive 

1837 logger.debug(f"Failed to restore default SIGHUP handler: {exc}") 

1838 

1839 if aggregation_stop_event is not None: 

1840 aggregation_stop_event.set() 

1841 for task in (aggregation_backfill_task, aggregation_loop_task): 

1842 if task: 

1843 task.cancel() 

1844 with suppress(asyncio.CancelledError): 

1845 await task 

1846 

1847 # Shutdown plugin manager 

1848 if plugin_manager: 

1849 try: 

1850 await plugin_manager.shutdown() 

1851 logger.info("Plugin manager shutdown complete") 

1852 except Exception as e: 

1853 logger.error(f"Error shutting down plugin manager: {str(e)}") 

1854 

1855 # Stop cache invalidation subscriber 

1856 try: 

1857 # First-Party 

1858 from mcpgateway.cache.registry_cache import get_cache_invalidation_subscriber # pylint: disable=import-outside-toplevel 

1859 

1860 cache_invalidation_subscriber = get_cache_invalidation_subscriber() 

1861 await cache_invalidation_subscriber.stop() 

1862 except Exception as e: 

1863 logger.debug(f"Error stopping cache invalidation subscriber: {e}") 

1864 

1865 logger.info("Shutting down ContextForge services") 

1866 # await stop_streamablehttp() 

1867 # Build service list conditionally 

1868 services_to_shutdown: List[Any] = [ 

1869 resource_cache, 

1870 sampling_handler, 

1871 import_service, 

1872 export_service, 

1873 logging_service, 

1874 completion_service, 

1875 root_service, 

1876 gateway_service, 

1877 prompt_service, 

1878 resource_service, 

1879 tool_service, 

1880 streamable_http_session, 

1881 session_registry, 

1882 ] 

1883 

1884 # Add cancellation service if enabled 

1885 if settings.mcpgateway_tool_cancellation_enabled: 

1886 services_to_shutdown.insert(0, cancellation_service) # Shutdown early to stop accepting new cancellations 

1887 

1888 if a2a_service: 

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

1890 

1891 # Add elicitation service if enabled 

1892 if settings.mcpgateway_elicitation_enabled: 

1893 # First-Party 

1894 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel 

1895 

1896 elicitation_service = get_elicitation_service() 

1897 services_to_shutdown.insert(5, elicitation_service) 

1898 

1899 # Add metrics buffer service if enabled (flush remaining metrics before shutdown) 

1900 if settings.metrics_buffer_enabled: 

1901 # First-Party 

1902 from mcpgateway.services.metrics_buffer_service import get_metrics_buffer_service # pylint: disable=import-outside-toplevel 

1903 

1904 metrics_buffer_service = get_metrics_buffer_service() 

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

1906 

1907 # Add metrics rollup service if enabled (shutdown before cleanup) 

1908 if settings.metrics_rollup_enabled: 

1909 # First-Party 

1910 from mcpgateway.services.metrics_rollup_service import get_metrics_rollup_service # pylint: disable=import-outside-toplevel 

1911 

1912 metrics_rollup_service = get_metrics_rollup_service() 

1913 services_to_shutdown.insert(1, metrics_rollup_service) 

1914 

1915 # Add metrics cleanup service if enabled 

1916 if settings.metrics_cleanup_enabled: 

1917 # First-Party 

1918 from mcpgateway.services.metrics_cleanup_service import get_metrics_cleanup_service # pylint: disable=import-outside-toplevel 

1919 

1920 metrics_cleanup_service = get_metrics_cleanup_service() 

1921 services_to_shutdown.insert(2, metrics_cleanup_service) 

1922 

1923 await shutdown_services(services_to_shutdown) 

1924 

1925 # Shutdown MCP session pool (before shared HTTP client) 

1926 # Must match the init condition (pool OR affinity) to cover affinity-only deployments. 

1927 if settings.mcp_session_pool_enabled or settings.mcpgateway_session_affinity_enabled: 

1928 # First-Party 

1929 from mcpgateway.services.mcp_session_pool import close_mcp_session_pool # pylint: disable=import-outside-toplevel 

1930 

1931 await close_mcp_session_pool() 

1932 

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

1934 await SharedHttpClient.shutdown() 

1935 

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

1937 await close_redis_client() 

1938 

1939 logger.info("Shutdown complete") 

1940 

1941 

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

1943 """ 

1944 Awaits shutdown of services provided in a list 

1945 

1946 Args: 

1947 services_to_shutdown (list[Any]): list of services to shutdown 

1948 """ 

1949 for service in services_to_shutdown: 

1950 try: 

1951 await service.shutdown() 

1952 except Exception as e: 

1953 logger.error(f"Error shutting down {service.__class__.__name__}: {str(e)}") 

1954 

1955 

1956async def setup_passthrough_headers(): 

1957 """ 

1958 Enables configuration and logs active settings as needed for when passthrough headers are enabled. 

1959 """ 

1960 logger.info(f"🔄 Header Passthrough: ENABLED (default headers: {settings.default_passthrough_headers})") 

1961 if settings.enable_overwrite_base_headers: 

1962 logger.warning("⚠️ Base Header Override: ENABLED - Client headers can override gateway headers") 

1963 else: 

1964 logger.info("🔒 Base Header Override: DISABLED - Gateway headers take precedence") 

1965 db_gen = get_db() 

1966 db = next(db_gen) # pylint: disable=stop-iteration-return 

1967 try: 

1968 await set_global_passthrough_headers(db) 

1969 finally: 

1970 db.commit() # End transaction cleanly 

1971 db.close() 

1972 

1973 

1974# Initialize FastAPI app with orjson for 2-3x faster JSON serialization 

1975app = FastAPI( 

1976 title=settings.app_name, 

1977 version=__version__, 

1978 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.", 

1979 root_path=settings.app_root_path, 

1980 lifespan=lifespan, 

1981 default_response_class=ORJSONResponse, # Use orjson for high-performance JSON serialization 

1982) 

1983 

1984# Setup metrics instrumentation 

1985setup_metrics(app) 

1986 

1987 

1988def validate_security_configuration(): 

1989 """ 

1990 Validate security configuration on startup. 

1991 This function encapsulates: 

1992 - verifying the configuration, 

1993 - logging the output for warnings, 

1994 - critical issues 

1995 - security recommendations 

1996 

1997 Args: None 

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

1999 """ 

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

2001 

2002 # Get security status 

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

2004 security_warnings = security_status["warnings"] 

2005 

2006 log_security_warnings(security_warnings) 

2007 

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

2009 critical_issues = [] 

2010 

2011 if settings.jwt_secret_key in ("my-test-key", "my-test-key-but-now-longer-than-32-bytes") and not settings.dev_mode: # nosec B105 - checking for default values 

2012 critical_issues.append("Using default JWT secret in non-dev mode. Set JWT_SECRET_KEY environment variable!") 

2013 

2014 if settings.basic_auth_password.get_secret_value() == "changeme" and settings.mcpgateway_ui_enabled: # nosec B105 - checking for default value 

2015 critical_issues.append("Admin UI enabled with default password. Set BASIC_AUTH_PASSWORD environment variable!") 

2016 

2017 log_critical_issues(critical_issues) 

2018 

2019 # Warn about ephemeral storage without strict user-in-DB mode 

2020 if not getattr(settings, "require_user_in_db", False): 

2021 is_ephemeral = ":memory:" in settings.database_url or settings.database_url == "sqlite:///./mcp.db" 

2022 if is_ephemeral: 

2023 logger.warning("Using potentially ephemeral storage with platform admin bootstrap enabled. Consider using persistent storage or setting REQUIRE_USER_IN_DB=true for production.") 

2024 

2025 # Warn about default JWT issuer/audience in non-development environments 

2026 if settings.environment != "development": 

2027 if settings.jwt_issuer == "mcpgateway": 

2028 logger.warning("Using default JWT_ISSUER in %s environment. Set a unique JWT_ISSUER per environment to prevent cross-environment token acceptance.", settings.environment) 

2029 if settings.jwt_audience == "mcpgateway-api": 

2030 logger.warning("Using default JWT_AUDIENCE in %s environment. Set a unique JWT_AUDIENCE per environment to prevent cross-environment token acceptance.", settings.environment) 

2031 

2032 log_security_recommendations(security_status) 

2033 

2034 

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

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

2037 

2038 Args: 

2039 security_warnings: List of security warning messages. 

2040 """ 

2041 if security_warnings: 

2042 logger.warning("=" * 60) 

2043 logger.warning("🚨 SECURITY WARNINGS DETECTED:") 

2044 logger.warning("=" * 60) 

2045 for warning in security_warnings: 

2046 logger.warning(f" {warning}") 

2047 logger.warning("=" * 60) 

2048 

2049 

2050def log_critical_issues(critical_issues: list[Any]): 

2051 """ 

2052 Log critical based on configuration settings 

2053 If REQUIRE_STRONG_SECRETS set, this will output critical errors and exit the mcpgateway server. 

2054 

2055 Args: 

2056 critical_issues: List 

2057 

2058 Returns: None 

2059 """ 

2060 # Handle critical issues based on REQUIRE_STRONG_SECRETS setting 

2061 if critical_issues: 

2062 if settings.require_strong_secrets: 

2063 logger.error("=" * 60) 

2064 logger.error("💀 CRITICAL SECURITY ISSUES DETECTED:") 

2065 logger.error("=" * 60) 

2066 for issue in critical_issues: 

2067 logger.error(f"{issue}") 

2068 logger.error("=" * 60) 

2069 logger.error("Startup aborted due to REQUIRE_STRONG_SECRETS=true") 

2070 logger.error("To proceed anyway, set REQUIRE_STRONG_SECRETS=false") 

2071 logger.error("=" * 60) 

2072 sys.exit(1) 

2073 else: 

2074 # Log as warnings if not enforcing 

2075 logger.warning("=" * 60) 

2076 logger.warning("⚠️ Critical security issues detected (REQUIRE_STRONG_SECRETS=false):") 

2077 for issue in critical_issues: 

2078 logger.warning(f"{issue}") 

2079 logger.warning("=" * 60) 

2080 

2081 

2082def log_security_recommendations(security_status: settings.SecurityStatus): 

2083 """ 

2084 Log security recommendations based on configuration settings 

2085 

2086 Args: 

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

2088 

2089 Returns: None 

2090 """ 

2091 if not security_status["secure_secrets"] or not security_status["auth_enabled"]: 

2092 logger.info("=" * 60) 

2093 logger.info("📋 SECURITY RECOMMENDATIONS:") 

2094 logger.info("=" * 60) 

2095 

2096 if settings.jwt_secret_key in ("my-test-key", "my-test-key-but-now-longer-than-32-bytes"): # nosec B105 - checking for default value 

2097 logger.info(" • Generate a strong JWT secret:") 

2098 logger.info(" python3 -c 'import secrets; print(secrets.token_urlsafe(32))'") 

2099 

2100 if settings.basic_auth_password.get_secret_value() == "changeme": # nosec B105 - checking for default value 

2101 logger.info(" • Set a strong admin password in BASIC_AUTH_PASSWORD") 

2102 

2103 if not settings.auth_required: 

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

2105 

2106 if settings.skip_ssl_verify: 

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

2108 

2109 logger.info("=" * 60) 

2110 

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

2112 

2113 

2114# Global exceptions handlers 

2115@app.exception_handler(ValidationError) 

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

2117 """Handle Pydantic validation errors globally. 

2118 

2119 Intercepts ValidationError exceptions raised anywhere in the application 

2120 and returns a properly formatted JSON error response with detailed 

2121 validation error information. 

2122 

2123 Args: 

2124 _request: The FastAPI request object that triggered the validation error. 

2125 (Unused but required by FastAPI's exception handler interface) 

2126 exc: The Pydantic ValidationError exception containing validation 

2127 failure details. 

2128 

2129 Returns: 

2130 JSONResponse: A 422 Unprocessable Entity response with formatted 

2131 validation error details. 

2132 

2133 Examples: 

2134 >>> from pydantic import ValidationError, BaseModel 

2135 >>> from fastapi import Request 

2136 >>> import asyncio 

2137 >>> 

2138 >>> class TestModel(BaseModel): 

2139 ... name: str 

2140 ... age: int 

2141 >>> 

2142 >>> # Create a validation error 

2143 >>> try: 

2144 ... TestModel(name="", age="invalid") 

2145 ... except ValidationError as e: 

2146 ... # Test our handler 

2147 ... result = asyncio.run(validation_exception_handler(None, e)) 

2148 ... result.status_code 

2149 422 

2150 """ 

2151 return ORJSONResponse(status_code=422, content=ErrorFormatter.format_validation_error(exc)) 

2152 

2153 

2154@app.exception_handler(RequestValidationError) 

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

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

2157 

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

2159 parsing before the request reaches your endpoint. 

2160 

2161 Args: 

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

2163 exc: The RequestValidationError exception containing failure details. 

2164 

2165 Returns: 

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

2167 """ 

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

2169 error_details = [] 

2170 

2171 for error in exc.errors(): 

2172 loc = error.get("loc", []) 

2173 msg = error.get("msg", "Unknown error") 

2174 ctx = error.get("ctx", {"error": {}}) 

2175 type_ = error.get("type", "value_error") 

2176 # Ensure ctx is JSON serializable 

2177 if isinstance(ctx, dict): 

2178 ctx_serializable = {k: (str(v) if isinstance(v, Exception) else v) for k, v in ctx.items()} 

2179 else: 

2180 ctx_serializable = str(ctx) 

2181 error_detail = {"type": type_, "loc": loc, "msg": msg, "ctx": ctx_serializable} 

2182 error_details.append(error_detail) 

2183 

2184 response_content = {"detail": error_details} 

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

2186 return await fastapi_default_validation_handler(_request, exc) 

2187 

2188 

2189@app.exception_handler(IntegrityError) 

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

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

2192 

2193 Intercepts IntegrityError exceptions (e.g., unique constraint violations, 

2194 foreign key constraints) and returns a properly formatted JSON error response. 

2195 This provides consistent error handling for database constraint violations 

2196 across the entire application. 

2197 

2198 Args: 

2199 _request: The FastAPI request object that triggered the database error. 

2200 (Unused but required by FastAPI's exception handler interface) 

2201 exc: The SQLAlchemy IntegrityError exception containing constraint 

2202 violation details. 

2203 

2204 Returns: 

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

2206 

2207 Examples: 

2208 >>> from sqlalchemy.exc import IntegrityError 

2209 >>> from fastapi import Request 

2210 >>> import asyncio 

2211 >>> 

2212 >>> # Create a mock integrity error 

2213 >>> mock_error = IntegrityError("statement", {}, Exception("duplicate key")) 

2214 >>> result = asyncio.run(database_exception_handler(None, mock_error)) 

2215 >>> result.status_code 

2216 409 

2217 >>> # Verify ErrorFormatter.format_database_error is called 

2218 >>> hasattr(result, 'body') 

2219 True 

2220 """ 

2221 return ORJSONResponse(status_code=409, content=ErrorFormatter.format_database_error(exc)) 

2222 

2223 

2224@app.exception_handler(ContentSizeError) 

2225async def content_size_exception_handler(_request: Request, exc: ContentSizeError): 

2226 """Handle content size limit violations globally. 

2227 

2228 Args: 

2229 _request: The incoming request (unused, required by FastAPI handler interface). 

2230 exc: The ContentSizeError with actual_size, max_size, and content_type. 

2231 

2232 Returns: 

2233 ORJSONResponse: A 413 Payload Too Large response with structured error details. 

2234 """ 

2235 return ORJSONResponse(status_code=413, content={"detail": {"error": f"{exc.content_type} size limit exceeded", "message": str(exc), "actual_size": exc.actual_size, "max_size": exc.max_size}}) 

2236 

2237 

2238# RFC 9110 §5.6.2 'token' pattern for header field names: 

2239# token = 1*tchar 

2240# tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" 

2241# / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" 

2242# / DIGIT / ALPHA 

2243_RFC9110_TOKEN_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") 

2244 

2245 

2246def _validate_http_headers(headers: dict[str, str]) -> Optional[dict[str, str]]: 

2247 """Validate headers according to RFC 9110. 

2248 

2249 Args: 

2250 headers: dict of headers 

2251 

2252 Returns: 

2253 Optional[dict[str, str]]: dictionary of valid headers 

2254 

2255 Rules enforced: 

2256 - Header name must match RFC 9110 'token'. 

2257 - No whitespace before colon (enforced by dictionary usage). 

2258 - Header value must not contain CTL characters (0x00–0x1F, 0x7F), 

2259 except SP (0x20) and HTAB (0x09) which are allowed. 

2260 """ 

2261 validated: dict[str, str] = {} 

2262 for key, value in headers.items(): 

2263 # Validate header name (RFC 9110 token) 

2264 if not _RFC9110_TOKEN_RE.match(key): 

2265 logger.warning(f"Invalid header name: {key}") 

2266 continue 

2267 # RFC 9110: Reject CTLs (0x00–0x1F, 0x7F). Allow SP (0x20) and HTAB (0x09). 

2268 valid = True 

2269 for ch in value: 

2270 code = ord(ch) 

2271 if (0 <= code <= 31 or code == 127) and code not in (9, 32): 

2272 valid = False 

2273 break 

2274 if not valid: 

2275 logger.warning(f"Header value contains invalid characters: {key}") 

2276 continue 

2277 validated[key] = value 

2278 return validated if validated else None 

2279 

2280 

2281@app.exception_handler(PluginViolationError) 

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

2283 """Handle plugins violations globally. 

2284 

2285 Intercepts PluginViolationError exceptions (e.g., OPA policy violation) and returns a properly formatted JSON error response. 

2286 This provides consistent error handling for plugin violation across the entire application. 

2287 

2288 Args: 

2289 _request: The FastAPI request object that triggered the database error. 

2290 (Unused but required by FastAPI's exception handler interface) 

2291 exc: The PluginViolationError exception containing constraint 

2292 violation details. 

2293 

2294 Returns: 

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

2296 Uses HTTP status code from violation if present (e.g., 429 for rate limiting), 

2297 otherwise defaults to 200 for JSON-RPC compliance. 

2298 

2299 Examples: 

2300 >>> from mcpgateway.plugins.framework import PluginViolationError 

2301 >>> from mcpgateway.plugins.framework.models import PluginViolation 

2302 >>> from fastapi import Request 

2303 >>> import asyncio 

2304 >>> import json 

2305 >>> 

2306 >>> # Create a plugin violation error 

2307 >>> mock_error = PluginViolationError(message="plugin violation",violation = PluginViolation( 

2308 ... reason="Invalid input", 

2309 ... description="The input contains prohibited content", 

2310 ... code="PROHIBITED_CONTENT", 

2311 ... details={"field": "message", "value": "test"} 

2312 ... )) 

2313 >>> result = asyncio.run(plugin_violation_exception_handler(None, mock_error)) 

2314 >>> result.status_code 

2315 422 

2316 >>> content = orjson.loads(result.body.decode()) 

2317 >>> content["error"]["code"] 

2318 -32602 

2319 >>> "Plugin Violation:" in content["error"]["message"] 

2320 True 

2321 >>> content["error"]["data"]["plugin_error_code"] 

2322 'PROHIBITED_CONTENT' 

2323 """ 

2324 policy_violation = exc.violation.model_dump() if exc.violation else {} 

2325 message = exc.violation.description if exc.violation else "A plugin violation occurred." 

2326 policy_violation["message"] = exc.message 

2327 status_code = exc.violation.mcp_error_code if exc.violation and exc.violation.mcp_error_code else -32602 

2328 violation_details: dict[str, Any] = {} 

2329 http_status = 200 

2330 if exc.violation: 

2331 if exc.violation.description: 

2332 violation_details["description"] = exc.violation.description 

2333 if exc.violation.details: 

2334 violation_details["details"] = exc.violation.details 

2335 if exc.violation.code: 

2336 violation_details["plugin_error_code"] = exc.violation.code 

2337 if exc.violation.plugin_name: 

2338 violation_details["plugin_name"] = exc.violation.plugin_name 

2339 

2340 # Use HTTP status code from violation if present (e.g., 429 for rate limiting) 

2341 http_status = exc.violation.http_status_code if exc.violation.http_status_code else None 

2342 if http_status and not VALID_HTTP_STATUS_CODES.get(http_status): 

2343 logger.warning(f"Invalid HTTP status code {http_status} from violation, defaulting to 200") 

2344 http_status = None 

2345 if not http_status: 

2346 logger.debug("Using Plugin violation code mapping for lack of http_status_code") 

2347 mapping: Optional[PluginViolationCode] = PLUGIN_VIOLATION_CODE_MAPPING.get(exc.violation.code) if exc.violation.code else None 

2348 if not mapping: 

2349 http_status = 200 

2350 else: 

2351 http_status = mapping.code 

2352 

2353 json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Violation: " + message, data=violation_details) 

2354 

2355 # Collect HTTP headers from violation if present 

2356 headers = exc.violation.http_headers if exc.violation and exc.violation.http_headers else None 

2357 

2358 response = ORJSONResponse(status_code=http_status, content={"error": json_rpc_error.model_dump()}) 

2359 if headers: 

2360 validated_headers = _validate_http_headers(headers) 

2361 if validated_headers: 

2362 response.headers.update(validated_headers) 

2363 return response 

2364 

2365 

2366@app.exception_handler(PluginError) 

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

2368 """Handle plugins errors globally. 

2369 

2370 Intercepts PluginError exceptions and returns a properly formatted JSON error response. 

2371 This provides consistent error handling for plugin error across the entire application. 

2372 

2373 Args: 

2374 _request: The FastAPI request object that triggered the database error. 

2375 (Unused but required by FastAPI's exception handler interface) 

2376 exc: The PluginError exception containing constraint 

2377 violation details. 

2378 

2379 Returns: 

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

2381 

2382 Examples: 

2383 >>> from mcpgateway.plugins.framework import PluginError 

2384 >>> from mcpgateway.plugins.framework.models import PluginErrorModel 

2385 >>> from fastapi import Request 

2386 >>> import asyncio 

2387 >>> import json 

2388 >>> 

2389 >>> # Create a plugin error 

2390 >>> mock_error = PluginError(error = PluginErrorModel( 

2391 ... message="plugin error", 

2392 ... code="timeout", 

2393 ... plugin_name="abc", 

2394 ... details={"field": "message", "value": "test"} 

2395 ... )) 

2396 >>> result = asyncio.run(plugin_exception_handler(None, mock_error)) 

2397 >>> result.status_code 

2398 200 

2399 >>> content = orjson.loads(result.body.decode()) 

2400 >>> content["error"]["code"] 

2401 -32603 

2402 >>> "Plugin Error:" in content["error"]["message"] 

2403 True 

2404 >>> content["error"]["data"]["plugin_error_code"] 

2405 'timeout' 

2406 >>> content["error"]["data"]["plugin_name"] 

2407 'abc' 

2408 """ 

2409 message = exc.error.message if exc.error else "A plugin error occurred." 

2410 status_code = exc.error.mcp_error_code if exc.error else -32603 

2411 error_details: dict[str, Any] = {} 

2412 if exc.error: 

2413 if exc.error.details: 

2414 error_details["details"] = exc.error.details 

2415 if exc.error.code: 

2416 error_details["plugin_error_code"] = exc.error.code 

2417 if exc.error.plugin_name: 

2418 error_details["plugin_name"] = exc.error.plugin_name 

2419 json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Error: " + message, data=error_details) 

2420 return ORJSONResponse(status_code=200, content={"error": json_rpc_error.model_dump()}) 

2421 

2422 

2423@app.exception_handler(ContentTypeError) 

2424async def content_type_exception_handler(_request: Request, exc: ContentTypeError): 

2425 """Handle MIME type validation failures globally. 

2426 

2427 Args: 

2428 _request: The incoming request (unused, required by FastAPI handler interface). 

2429 exc: The ContentTypeError with mime_type and allowed_types. 

2430 

2431 Returns: 

2432 ORJSONResponse: A 415 Unsupported Media Type response with error details. 

2433 """ 

2434 return ORJSONResponse( 

2435 status_code=415, 

2436 content={ 

2437 "detail": { 

2438 "error": "Unsupported MIME type", 

2439 "message": str(exc), 

2440 "mime_type": exc.mime_type, 

2441 "allowed_types": exc.allowed_types[:5], # Limit to first 5 

2442 } 

2443 }, 

2444 ) 

2445 

2446 

2447def _normalize_scope_path(scope_path: str, root_path: str) -> str: 

2448 """Strip ``root_path`` prefix from *scope_path* when a reverse proxy forwards the full path. 

2449 

2450 Returns the route-only path (e.g. ``"/qa/gateway/docs"`` -> ``"/docs"``). 

2451 A ``root_path`` of ``"/"`` is ignored to avoid stripping the leading slash 

2452 from every path. Trailing slashes on *root_path* are stripped before 

2453 comparison so that ``"/qa/gateway/"`` is handled identically to 

2454 ``"/qa/gateway"``. 

2455 

2456 Args: 

2457 scope_path: The full path from the request scope. 

2458 root_path: The root path prefix to be stripped. 

2459 

2460 Returns: 

2461 The normalized path with the root_path prefix removed. 

2462 """ 

2463 if root_path and len(root_path) > 1: 

2464 root_path = root_path.rstrip("/") 

2465 if root_path and len(root_path) > 1 and scope_path.startswith(root_path): 

2466 rest = scope_path[len(root_path) :] 

2467 # Ensure we matched a full path segment, not a partial prefix 

2468 # e.g. root_path="/app" must not strip from "/application/admin" 

2469 if not rest or rest[0] == "/": 

2470 return rest or "/" 

2471 return scope_path 

2472 

2473 

2474class DocsAuthMiddleware(BaseHTTPMiddleware): 

2475 """ 

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

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

2478 

2479 If a request to one of these paths is made without a valid token, 

2480 the request is rejected with a 401 or 403 error. 

2481 

2482 Note: 

2483 OPTIONS requests are exempt from authentication to support CORS preflight 

2484 as per RFC 7231 Section 4.3.7 (OPTIONS must not require authentication). 

2485 

2486 Note: 

2487 When DOCS_ALLOW_BASIC_AUTH is enabled, Basic Authentication 

2488 is also accepted using BASIC_AUTH_USER and BASIC_AUTH_PASSWORD credentials. 

2489 """ 

2490 

2491 async def dispatch(self, request: Request, call_next): 

2492 """ 

2493 Intercepts incoming requests to check if they are accessing protected documentation routes. 

2494 If so, it requires a valid Bearer token; otherwise, it allows the request to proceed. 

2495 

2496 Args: 

2497 request (Request): The incoming HTTP request. 

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

2499 

2500 Returns: 

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

2502 

2503 Examples: 

2504 >>> import asyncio 

2505 >>> from unittest.mock import Mock, AsyncMock, patch 

2506 >>> from fastapi import HTTPException 

2507 >>> from fastapi.responses import JSONResponse 

2508 >>> 

2509 >>> # Test unprotected path - should pass through 

2510 >>> middleware = DocsAuthMiddleware(None) 

2511 >>> request = Mock() 

2512 >>> request.url.path = "/api/tools" 

2513 >>> request.scope = {"path": "/api/tools", "root_path": ""} 

2514 >>> request.method = "GET" 

2515 >>> request.headers.get.return_value = None 

2516 >>> call_next = AsyncMock(return_value="response") 

2517 >>> 

2518 >>> result = asyncio.run(middleware.dispatch(request, call_next)) 

2519 >>> result 

2520 'response' 

2521 >>> 

2522 >>> # Test that middleware checks protected paths 

2523 >>> request.url.path = "/docs" 

2524 >>> isinstance(middleware, DocsAuthMiddleware) 

2525 True 

2526 """ 

2527 protected_paths = ["/docs", "/redoc", "/openapi.json"] 

2528 

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

2530 if request.method == "OPTIONS": 

2531 return await call_next(request) 

2532 

2533 # Get path from scope to handle root_path correctly 

2534 scope_path = request.scope.get("path", request.url.path) 

2535 root_path = request.scope.get("root_path", "") 

2536 scope_path = _normalize_scope_path(scope_path, root_path) 

2537 

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

2539 

2540 if is_protected: 

2541 try: 

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

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

2544 

2545 # Use dedicated docs authentication that bypasses global auth settings 

2546 await require_docs_auth_override(token, cookie_token) 

2547 except HTTPException as e: 

2548 return ORJSONResponse(status_code=e.status_code, content={"detail": e.detail}, headers=e.headers if e.headers else None) 

2549 

2550 # Proceed to next middleware or route 

2551 return await call_next(request) 

2552 

2553 

2554class AdminAuthMiddleware(BaseHTTPMiddleware): 

2555 """ 

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

2557 

2558 Exempts login-related paths and static assets: 

2559 - /admin/login - login page 

2560 - /admin/logout - logout action 

2561 - /admin/forgot-password - self-service password reset request page 

2562 - /admin/reset-password/* - self-service password reset completion page 

2563 - /admin/static/* - static assets 

2564 

2565 All other /admin/* routes require the user to be authenticated AND be an admin. 

2566 Non-admin authenticated users receive a 403 Forbidden response. 

2567 

2568 Note: This middleware respects the auth_required setting. When auth_required=False 

2569 (typically in test environments), the middleware allows requests to pass through 

2570 and relies on endpoint-level authentication which can be mocked in tests. 

2571 """ 

2572 

2573 # Public paths under /admin that do not require prior authentication. 

2574 EXEMPT_PATHS = [ 

2575 "/admin/login", 

2576 "/admin/logout", 

2577 "/admin/forgot-password", 

2578 "/admin/reset-password", 

2579 "/admin/static", 

2580 ] 

2581 

2582 @staticmethod 

2583 def _error_response(request: Request, root_path: str, status_code: int, detail: str, error_param: str = None): 

2584 """Return appropriate error response based on request Accept header. 

2585 

2586 Args: 

2587 request: The incoming HTTP request. 

2588 root_path: The root path prefix for the application. 

2589 status_code: HTTP status code for JSON responses. 

2590 detail: Error message detail. 

2591 error_param: Optional error parameter for login redirect URL. 

2592 

2593 Returns: 

2594 Response with HX-Redirect for HTMX requests, RedirectResponse for HTML requests, ORJSONResponse for API requests. 

2595 """ 

2596 accept_header = request.headers.get("accept", "") 

2597 is_htmx = request.headers.get("hx-request") == "true" 

2598 if "text/html" in accept_header or is_htmx: 

2599 login_url = f"{root_path}/admin/login" if root_path else "/admin/login" 

2600 if error_param: 

2601 login_url = f"{login_url}?error={error_param}" 

2602 if is_htmx: 

2603 return Response(status_code=200, headers={"HX-Redirect": login_url}) 

2604 return RedirectResponse(url=login_url, status_code=302) 

2605 return ORJSONResponse(status_code=status_code, content={"detail": detail}) 

2606 

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

2608 """ 

2609 Check admin privileges for admin routes. 

2610 

2611 Args: 

2612 request (Request): The incoming HTTP request. 

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

2614 

2615 Returns: 

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

2617 """ 

2618 # Skip admin auth check if auth is not required (e.g., test environments) 

2619 # This allows tests to mock authentication at the dependency level 

2620 if not settings.auth_required: 

2621 return await call_next(request) 

2622 

2623 # Get path from scope to handle root_path correctly 

2624 scope_path = request.scope.get("path", request.url.path) 

2625 root_path = request.scope.get("root_path", "") 

2626 scope_path = _normalize_scope_path(scope_path, root_path) 

2627 

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

2629 if request.method == "OPTIONS": 

2630 return await call_next(request) 

2631 

2632 # Check if this is an admin route 

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

2634 

2635 if not is_admin_route: 

2636 return await call_next(request) 

2637 

2638 # Check if path is exempt (login, logout, static) 

2639 is_exempt = any(scope_path.startswith(p) for p in self.EXEMPT_PATHS) 

2640 if is_exempt: 

2641 return await call_next(request) 

2642 

2643 # For protected admin routes, verify admin status 

2644 try: 

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

2646 cookie_token = request.cookies.get("jwt_token") or request.cookies.get("access_token") 

2647 

2648 # Extract token from header or cookie 

2649 jwt_token = None 

2650 if cookie_token: 

2651 jwt_token = cookie_token 

2652 elif token and token.startswith("Bearer "): 

2653 jwt_token = token.split(" ", 1)[1] 

2654 

2655 username = None 

2656 token_teams = None 

2657 

2658 if jwt_token: 

2659 # Try JWT authentication first 

2660 try: 

2661 payload = await verify_jwt_token(jwt_token) 

2662 username = payload.get("sub") or payload.get("email") 

2663 

2664 if not username: 

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

2666 

2667 # Check if token is revoked (if JTI exists) 

2668 jti = payload.get("jti") 

2669 if jti: 

2670 try: 

2671 is_revoked = await asyncio.to_thread(_check_token_revoked_sync, jti) 

2672 if is_revoked: 

2673 logger.warning(f"Admin access denied for revoked token: {SecurityValidator.sanitize_log_message(str(username))}") 

2674 return self._error_response(request, root_path, 401, "Token has been revoked", "token_revoked") 

2675 except Exception as revoke_error: 

2676 logger.warning(f"Token revocation check failed: {revoke_error}") 

2677 # Continue - don't fail auth if revocation check fails 

2678 

2679 # SECURITY: Apply token scope semantics for admin paths. 

2680 # Use the same token_use-aware resolution as auth.py. 

2681 token_use = payload.get("token_use") 

2682 if token_use == "session": # nosec B105 - Not a password; token_use is a JWT claim type 

2683 is_admin = payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False) 

2684 token_teams = await resolve_session_teams(payload, username, {"is_admin": is_admin}) 

2685 else: 

2686 # API token or legacy path: embedded teams claim semantics 

2687 token_teams = normalize_token_teams(payload) 

2688 except Exception: 

2689 # JWT validation failed, try API token 

2690 token_hash = hashlib.sha256(jwt_token.encode()).hexdigest() 

2691 api_token_info = await asyncio.to_thread(_lookup_api_token_sync, token_hash) 

2692 

2693 if api_token_info: 

2694 if api_token_info.get("expired"): 

2695 return ORJSONResponse(status_code=401, content={"detail": "API token expired"}) 

2696 if api_token_info.get("revoked"): 

2697 return ORJSONResponse(status_code=401, content={"detail": "API token has been revoked"}) 

2698 username = api_token_info["user_email"] 

2699 logger.debug(f"Admin auth via API token: {SecurityValidator.sanitize_log_message(str(username))}") 

2700 

2701 # NOTE: Basic auth is NOT supported for admin UI endpoints. 

2702 # While AdminAuthMiddleware could validate Basic credentials, the admin 

2703 # endpoints use get_current_user_with_permissions which requires JWT tokens. 

2704 # Supporting Basic auth would require passing auth context to routes, 

2705 # which increases complexity and attack surface. Use JWT or API tokens instead. 

2706 

2707 if not username and is_proxy_auth_trust_active(settings): 

2708 # Proxy authentication path (when MCP client auth is disabled and proxy auth is trusted) 

2709 proxy_user = request.headers.get(settings.proxy_user_header) 

2710 if proxy_user: 

2711 username = proxy_user 

2712 logger.debug(f"Admin auth via proxy header: {SecurityValidator.sanitize_log_message(str(username))}") 

2713 

2714 if not username: 

2715 # No authentication method succeeded - redirect to login or return 401 

2716 return self._error_response(request, root_path, 401, "Authentication required") 

2717 

2718 # SECURITY: Public-only tokens (teams=[]) never grant admin-path access, 

2719 # even for admin identities. Admin bypass requires explicit teams=null + is_admin=true. 

2720 if token_teams is not None and len(token_teams) == 0: 

2721 logger.warning(f"Admin access denied for public-only token: {SecurityValidator.sanitize_log_message(str(username))}") 

2722 return self._error_response(request, root_path, 403, "Admin privileges required", "admin_required") 

2723 

2724 # Check if user exists, is active, and has admin permissions 

2725 db = next(get_db()) 

2726 try: 

2727 auth_service = EmailAuthService(db) 

2728 user = await auth_service.get_user_by_email(username) 

2729 

2730 if not user: 

2731 # Platform admin bootstrap (when REQUIRE_USER_IN_DB=false) 

2732 platform_admin_email = getattr(settings, "platform_admin_email", "admin@example.com") 

2733 if not settings.require_user_in_db and username == platform_admin_email: 

2734 logger.info(f"Platform admin bootstrap authentication for {SecurityValidator.sanitize_log_message(str(username))}") 

2735 # Allow platform admin through - they have implicit admin privileges 

2736 else: 

2737 return self._error_response(request, root_path, 401, "User not found") 

2738 else: 

2739 # User exists in DB - check active status 

2740 if not user.is_active: 

2741 logger.warning(f"Admin access denied for disabled user: {SecurityValidator.sanitize_log_message(str(username))}") 

2742 return self._error_response(request, root_path, 403, "Account is disabled", "account_disabled") 

2743 

2744 # Check if user has admin permissions (either is_admin flag OR admin.* RBAC permissions) 

2745 # This allows granular admin access for users with specific admin permissions. 

2746 # When the request is team-scoped (?team_id=...), include team-scoped roles 

2747 # so that developer/viewer roles with admin.dashboard can access the UI. 

2748 permission_service = PermissionService(db) 

2749 request_team_id = request.query_params.get("team_id") 

2750 # Normalize to hex so hyphenated UUIDs match DB-stored hex IDs. 

2751 # Fall back to raw value for non-UUID team IDs (e.g. from legacy tokens). 

2752 if request_team_id: 

2753 try: 

2754 request_team_id = uuid.UUID(request_team_id).hex 

2755 except (ValueError, AttributeError): 

2756 pass # keep raw value for non-UUID token_teams 

2757 # Only trust team_id if it is in the user's DB-resolved teams 

2758 validated_team_id = request_team_id if (token_teams and request_team_id and request_team_id in token_teams) else None 

2759 has_admin_access = await permission_service.has_admin_permission(username, team_id=validated_team_id, token_teams=token_teams) 

2760 if not has_admin_access: 

2761 logger.warning(f"Admin access denied for user without admin permissions: {SecurityValidator.sanitize_log_message(str(username))}") 

2762 return self._error_response(request, root_path, 403, "Admin privileges required", "admin_required") 

2763 finally: 

2764 db.close() 

2765 

2766 except HTTPException as e: 

2767 return self._error_response(request, root_path, e.status_code, e.detail) 

2768 except Exception as e: 

2769 logger.error(f"Admin auth middleware error: {e}") 

2770 return ORJSONResponse(status_code=500, content={"detail": "Authentication error"}) 

2771 

2772 # Proceed to next middleware or route 

2773 return await call_next(request) 

2774 

2775 

2776class MCPPathRewriteMiddleware: 

2777 """ 

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

2779 

2780 - Rewrites paths like '/servers/<server_id>/mcp' to '/mcp/'. 

2781 - Only paths ending with '/mcp' or '/mcp/' (but not exactly '/mcp' or '/mcp/') are rewritten. 

2782 - Authentication is performed before any path rewriting. 

2783 - If authentication fails, the request is not processed further. 

2784 - All other requests are passed through without change. 

2785 - Routes through the middleware stack (including CORSMiddleware) for proper CORS preflight handling. 

2786 

2787 Attributes: 

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

2789 """ 

2790 

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

2792 """ 

2793 Initialize the middleware with the ASGI application. 

2794 

2795 Args: 

2796 application (Callable): The next ASGI application to handle the request. 

2797 dispatch (Callable, optional): An optional dispatch function for additional middleware processing. 

2798 

2799 Example: 

2800 >>> import asyncio 

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

2802 >>> app_mock = AsyncMock() 

2803 >>> middleware = MCPPathRewriteMiddleware(app_mock) 

2804 >>> isinstance(middleware.application, AsyncMock) 

2805 True 

2806 """ 

2807 self.application = application 

2808 self.dispatch = dispatch # this can be TokenScopingMiddleware 

2809 

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

2811 """ 

2812 Intercept and potentially rewrite the incoming HTTP request path. 

2813 

2814 Args: 

2815 scope (dict): The ASGI connection scope. 

2816 receive (Callable): Awaitable that yields events from the client. 

2817 send (Callable): Awaitable used to send events to the client. 

2818 

2819 Examples: 

2820 >>> import asyncio 

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

2822 >>> app_mock = AsyncMock() 

2823 >>> middleware = MCPPathRewriteMiddleware(app_mock) 

2824 

2825 >>> # Test path rewriting for /servers/123/mcp 

2826 >>> scope = { "type": "http", "path": "/servers/123/mcp", "headers": [(b"host", b"example.com")] } 

2827 >>> receive = AsyncMock() 

2828 >>> send = AsyncMock() 

2829 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True): 

2830 ... asyncio.run(middleware(scope, receive, send)) 

2831 >>> scope["path"] 

2832 '/mcp/' 

2833 >>> app_mock.assert_called() 

2834 

2835 >>> # Test regular path (no rewrite) 

2836 >>> scope = { "type": "http","path": "/tools","headers": [(b"host", b"example.com")] } 

2837 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True): 

2838 ... asyncio.run(middleware(scope, receive, send)) 

2839 ... scope["path"] 

2840 '/tools' 

2841 """ 

2842 if scope["type"] != "http": 

2843 await self.application(scope, receive, send) 

2844 return 

2845 

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

2847 if self.dispatch is not None: 

2848 request = starletteRequest(scope, receive=receive) 

2849 

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

2851 """ 

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

2853 

2854 Args: 

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

2856 

2857 Returns: 

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

2859 """ 

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

2861 

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

2863 

2864 if response is None: 

2865 # Either the dispatch handled the response itself, 

2866 # or it blocked the request. Just return. 

2867 return 

2868 

2869 await response(scope, receive, send) 

2870 return 

2871 

2872 # Otherwise, just continue as normal 

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

2874 

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

2876 """ 

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

2878 

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

2880 (continuing through middleware stack including CORSMiddleware). 

2881 

2882 Args: 

2883 scope (dict): The ASGI connection scope containing request metadata. 

2884 receive (Callable): The function to receive events from the client. 

2885 send (Callable): The function to send events to the client. 

2886 

2887 Example: 

2888 >>> import asyncio 

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

2890 >>> app_mock = AsyncMock() 

2891 >>> middleware = MCPPathRewriteMiddleware(app_mock) 

2892 >>> scope = {"type": "http", "path": "/servers/123/mcp"} 

2893 >>> receive = AsyncMock() 

2894 >>> send = AsyncMock() 

2895 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True): 

2896 ... asyncio.run(middleware._call_streamable_http(scope, receive, send)) 

2897 >>> app_mock.assert_called_once_with(scope, receive, send) 

2898 """ 

2899 # Auth check first 

2900 auth_ok = await streamable_http_auth(scope, receive, send) 

2901 if not auth_ok: 

2902 return 

2903 

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

2905 scope["modified_path"] = original_path 

2906 

2907 # Skip rewriting for well-known URIs (RFC 9728 OAuth metadata, etc.) 

2908 # These paths may end with /mcp but should not be rewritten to the MCP transport 

2909 if not original_path.startswith("/.well-known/"): 

2910 if (original_path.endswith("/mcp") and original_path != "/mcp") or (original_path.endswith("/mcp/") and original_path != "/mcp/"): 

2911 # SECURITY: Only rewrite recognised MCP paths — /servers/{id}/mcp. 

2912 # Arbitrary prefixes (e.g. /foo/mcp) must NOT be rewritten to 

2913 # /mcp/ as that would expose the global MCP transport under 

2914 # undocumented aliases, broadening the externally reachable 

2915 # route surface. 

2916 if original_path.startswith("/servers/"): 

2917 # Validate that a non-empty server_id segment is present. 

2918 # Without this check, paths like /servers//mcp (empty ID) 

2919 # would be rewritten and silently fall through (#3891). 

2920 _srv_match = re.match(r"/servers/([^/]+)/mcp", original_path) 

2921 if not _srv_match: 

2922 response = ORJSONResponse({"detail": "Invalid server identifier"}, status_code=404) 

2923 await response(scope, receive, send) 

2924 return 

2925 else: 

2926 # Not a /servers/ path — do not rewrite, pass through 

2927 await self.application(scope, receive, send) 

2928 return 

2929 # Rewrite to /mcp/ and continue through middleware (lets CORSMiddleware handle preflight) 

2930 scope["path"] = "/mcp/" 

2931 await self.application(scope, receive, send) 

2932 return 

2933 await self.application(scope, receive, send) 

2934 

2935 

2936# Configure CORS with environment-aware origins 

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

2938 

2939# Ensure we never use wildcard in production 

2940if settings.environment == "production" and not cors_origins: 

2941 logger.warning("No CORS origins configured for production environment. CORS will be disabled.") 

2942 cors_origins = [] 

2943 

2944app.add_middleware( 

2945 CORSMiddleware, 

2946 allow_origins=cors_origins, 

2947 allow_credentials=settings.cors_allow_credentials, 

2948 allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], 

2949 allow_headers=["*"], 

2950 expose_headers=["Content-Length", "X-Request-ID", "X-Password-Change-Required"], 

2951 max_age=600, # Cache preflight requests for 10 minutes 

2952) 

2953 

2954# Add response compression middleware (Brotli, Zstd, GZip) 

2955# Automatically negotiates compression algorithm based on client Accept-Encoding header 

2956# Priority: Brotli (best compression) > Zstd (fast) > GZip (universal fallback) 

2957# Only compress responses larger than minimum_size to avoid overhead 

2958# NOTE: When json_response_enabled=False (SSE mode), /mcp paths are excluded from 

2959# compression to prevent buffering/breaking of streaming responses. See middleware/compression.py. 

2960if settings.compression_enabled: 

2961 app.add_middleware( 

2962 SSEAwareCompressMiddleware, 

2963 minimum_size=settings.compression_minimum_size, 

2964 gzip_level=settings.compression_gzip_level, 

2965 brotli_quality=settings.compression_brotli_quality, 

2966 zstd_level=settings.compression_zstd_level, 

2967 ) 

2968 logger.info( 

2969 f"🗜️ Response compression enabled (SSE-aware): minimum_size={settings.compression_minimum_size}B, " 

2970 f"gzip_level={settings.compression_gzip_level}, " 

2971 f"brotli_quality={settings.compression_brotli_quality}, " 

2972 f"zstd_level={settings.compression_zstd_level}" 

2973 ) 

2974else: 

2975 logger.info("🚫 Response compression disabled") 

2976 

2977# Add security headers middleware 

2978app.add_middleware(SecurityHeadersMiddleware) 

2979 

2980# Add validation middleware if explicitly enabled 

2981if settings.validation_middleware_enabled: 

2982 app.add_middleware(ValidationMiddleware) 

2983 logger.info("🔒 Input validation and output sanitization middleware enabled") 

2984else: 

2985 logger.info("🔒 Input validation and output sanitization middleware disabled") 

2986 

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

2988app.add_middleware(MCPProtocolVersionMiddleware) 

2989 

2990# Add token scoping middleware (only when email auth is enabled) 

2991if settings.email_auth_enabled: 

2992 app.add_middleware(BaseHTTPMiddleware, dispatch=token_scoping_middleware) 

2993 # Add streamable HTTP middleware for /mcp routes with token scoping 

2994 app.add_middleware(MCPPathRewriteMiddleware, dispatch=token_scoping_middleware) 

2995else: 

2996 # Add streamable HTTP middleware for /mcp routes 

2997 app.add_middleware(MCPPathRewriteMiddleware) 

2998 

2999# Add HTTP authentication hook middleware for plugins (before auth dependencies) 

3000if plugin_manager: 

3001 app.add_middleware(HttpAuthMiddleware, plugin_manager=plugin_manager) 

3002 logger.info("🔌 HTTP authentication hooks enabled for plugins") 

3003 

3004# Add request logging middleware FIRST (always enabled for gateway boundary logging) 

3005# IMPORTANT: Must be registered BEFORE CorrelationIDMiddleware so it executes AFTER correlation ID is set 

3006# Gateway boundary logging (request_started/completed) runs regardless of log_requests setting 

3007# Detailed payload logging only runs if log_detailed_requests=True 

3008app.add_middleware( 

3009 RequestLoggingMiddleware, 

3010 enable_gateway_logging=True, 

3011 log_detailed_requests=settings.log_requests, 

3012 log_level=settings.log_level, 

3013 max_body_size=settings.log_detailed_max_body_size, 

3014 log_resolve_user_identity=settings.log_resolve_user_identity, 

3015 log_detailed_skip_endpoints=settings.log_detailed_skip_endpoints, 

3016 log_detailed_sample_rate=settings.log_detailed_sample_rate, 

3017) 

3018 

3019# Add custom DocsAuthMiddleware 

3020app.add_middleware(DocsAuthMiddleware) 

3021 

3022# Add AdminAuthMiddleware to protect admin routes (requires admin privileges) 

3023# This ensures all /admin/* routes (except login/logout) require admin status 

3024app.add_middleware(AdminAuthMiddleware) 

3025 

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

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

3028 

3029# Add correlation ID middleware if enabled 

3030# Note: Registered AFTER RequestLoggingMiddleware so correlation ID is available when RequestLoggingMiddleware executes 

3031if settings.correlation_id_enabled: 

3032 app.add_middleware(CorrelationIDMiddleware) 

3033 logger.info(f"✅ Correlation ID tracking enabled (header: {settings.correlation_id_header})") 

3034 

3035# Add authentication context middleware if security logging is enabled 

3036# This middleware extracts user context and logs security events (authentication attempts) 

3037# Note: This is independent of observability - security logging is always important 

3038if settings.security_logging_enabled: 

3039 # First-Party 

3040 from mcpgateway.middleware.auth_middleware import AuthContextMiddleware 

3041 

3042 app.add_middleware(AuthContextMiddleware) 

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

3044else: 

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

3046 

3047# Add token usage logging middleware 

3048# This tracks API token usage for analytics and security monitoring 

3049# Note: Runs after AuthContextMiddleware so request.state.auth_method is available 

3050if settings.token_usage_logging_enabled: 

3051 # First-Party 

3052 from mcpgateway.middleware.token_usage_middleware import TokenUsageMiddleware # noqa: E402 

3053 

3054 app.add_middleware(TokenUsageMiddleware) 

3055 logger.info("📊 Token usage logging middleware enabled - tracking API token usage") 

3056else: 

3057 logger.info("📊 Token usage logging middleware disabled") 

3058 

3059# Add observability middleware if enabled 

3060# Note: Middleware runs in REVERSE order (last added runs first) 

3061# If AuthContextMiddleware is already registered, ObservabilityMiddleware wraps it 

3062# Execution order will be: AuthContext -> Observability -> Request Handler 

3063# Wire observability adapter into the plugin manager when observability is enabled 

3064if settings.observability_enabled: 

3065 # First-Party 

3066 from mcpgateway.middleware.observability_middleware import ObservabilityMiddleware 

3067 from mcpgateway.plugins.observability_adapter import ObservabilityServiceAdapter 

3068 from mcpgateway.services.observability_service import ObservabilityService 

3069 

3070 _service = ObservabilityService() 

3071 app.add_middleware(ObservabilityMiddleware, enabled=True, service=_service) 

3072 if plugin_manager: 

3073 plugin_manager.observability = ObservabilityServiceAdapter(service=_service) 

3074 logger.info("🔍 Observability middleware enabled - tracing include-listed requests") 

3075else: 

3076 logger.info("🔍 Observability middleware disabled") 

3077 

3078# Add OTEL request-root tracing middleware when external tracing is enabled. 

3079# Registered last so it wraps the full request path, including mounted /mcp ASGI handling. 

3080if otel_tracing_enabled(): 

3081 app.add_middleware(OpenTelemetryRequestMiddleware) 

3082 logger.info("🧵 OTEL request tracing middleware enabled for transport request roots") 

3083else: 

3084 logger.info("🧵 OTEL request tracing middleware disabled") 

3085 

3086# Database query logging middleware (for N+1 detection) 

3087if settings.db_query_log_enabled: 

3088 # First-Party 

3089 from mcpgateway.db import engine 

3090 from mcpgateway.middleware.db_query_logging import setup_query_logging 

3091 

3092 setup_query_logging(app, engine) 

3093 logger.info(f"📊 Database query logging enabled - logs: {settings.db_query_log_file}") 

3094else: 

3095 logger.debug("📊 Database query logging disabled (enable with DB_QUERY_LOG_ENABLED=true)") 

3096 

3097# Set up Jinja2 templates and store in app state for later use 

3098# auto_reload=False in production prevents re-parsing templates on each request (performance) 

3099jinja_env = Environment( 

3100 loader=FileSystemLoader(str(settings.templates_dir)), 

3101 autoescape=True, 

3102 auto_reload=settings.templates_auto_reload, 

3103) 

3104 

3105 

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

3107# that were stored with HTML entities (e.g., &#x27; instead of ') 

3108# NOTE: This filter can be removed after all deployments have run the c1c2c3c4c5c6 migration, 

3109# which decodes all existing HTML entities in the database. After that migration, this filter 

3110# becomes a no-op since new data is stored without HTML encoding. 

3111def decode_html_entities(value: str) -> str: 

3112 """Decode HTML entities in strings for display. 

3113 

3114 This filter handles legacy data that was stored with HTML entities. 

3115 New data is stored without encoding, but this ensures old records display correctly. 

3116 

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

3118 

3119 Args: 

3120 value: String that may contain HTML entities 

3121 

3122 Returns: 

3123 String with HTML entities decoded to their original characters 

3124 """ 

3125 if not value: 

3126 return value 

3127 

3128 return html.unescape(value) 

3129 

3130 

3131jinja_env.filters["decode_html"] = decode_html_entities 

3132 

3133 

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

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

3136 

3137 Unlike the built-in ``|tojson`` filter (which returns ``Markup``, bypassing 

3138 autoescape), this filter returns a plain ``str``. Jinja2 autoescape then 

3139 HTML-encodes the ``"`` characters to ``&quot;``, keeping the enclosing 

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

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

3142 

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

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

3145 

3146 Args: 

3147 value: Any JSON-serialisable object. 

3148 

3149 Returns: 

3150 Plain string with JSON content (autoescape will HTML-encode it). 

3151 """ 

3152 s = orjson.dumps(value, default=str).decode() 

3153 # Same HTML-safety replacements as Jinja2's htmlsafe_json_dumps, 

3154 # but we return a plain str so autoescape encodes the remaining `"`. 

3155 s = s.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e").replace("'", "\\u0027") 

3156 return s 

3157 

3158 

3159jinja_env.filters["tojson_attr"] = tojson_attr 

3160 

3161templates = Jinja2Templates(env=jinja_env) 

3162if not settings.templates_auto_reload: 

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

3164app.state.templates = templates 

3165 

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

3167app.state.plugin_manager = plugin_manager 

3168 

3169# Initialize plugin service with plugin manager 

3170if plugin_manager: 

3171 # First-Party 

3172 from mcpgateway.services.plugin_service import get_plugin_service 

3173 

3174 plugin_service = get_plugin_service() 

3175 plugin_service.set_plugin_manager(plugin_manager) 

3176 

3177# Create API routers 

3178protocol_router = APIRouter(prefix="/protocol", tags=["Protocol"]) 

3179tool_router = APIRouter(prefix="/tools", tags=["Tools"]) 

3180resource_router = APIRouter(prefix="/resources", tags=["Resources"]) 

3181prompt_router = APIRouter(prefix="/prompts", tags=["Prompts"]) 

3182gateway_router = APIRouter(prefix="/gateways", tags=["Gateways"]) 

3183root_router = APIRouter(prefix="/roots", tags=["Roots"]) 

3184utility_router = APIRouter(tags=["Utilities"]) 

3185server_router = APIRouter(prefix="/servers", tags=["Servers"]) 

3186metrics_router = APIRouter(prefix="/metrics", tags=["Metrics"]) 

3187tag_router = APIRouter(prefix="/tags", tags=["Tags"]) 

3188export_import_router = APIRouter(tags=["Export/Import"]) 

3189a2a_router = APIRouter(prefix="/a2a", tags=["A2A Agents"]) 

3190 

3191# Basic Auth setup 

3192 

3193 

3194# Database dependency 

3195def get_db(request: Request = None): 

3196 """ 

3197 Dependency function to provide a database session. 

3198 

3199 When observability is enabled, this reuses the session created by 

3200 ObservabilityMiddleware (stored in request.state.db) to avoid duplicate 

3201 session creation. When observability is disabled or the middleware hasn't 

3202 created a session, this creates its own session. 

3203 

3204 **Transaction Control**: This function ALWAYS controls transaction boundaries 

3205 (commit/rollback) regardless of whether it creates the session or reuses one 

3206 from middleware. This ensures predictable transaction semantics for route 

3207 handlers and maintains data integrity. 

3208 

3209 **Session Lifecycle**: Middleware manages session lifecycle (create/close) 

3210 while this function manages transactions (commit/rollback). This separation 

3211 of concerns prevents the transaction management violation described in #3731. 

3212 

3213 Commits the transaction on successful completion to avoid implicit rollbacks 

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

3215 

3216 This function handles connection failures gracefully by invalidating broken 

3217 connections. When a connection is broken (e.g., due to PgBouncer timeout or 

3218 network issues), the rollback will fail. In this case, we invalidate the 

3219 session to ensure the broken connection is discarded from the pool rather 

3220 than being returned in a bad state. 

3221 

3222 Args: 

3223 request: Optional FastAPI request object (injected automatically) 

3224 

3225 Yields: 

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

3227 

3228 Raises: 

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

3230 

3231 Ensures: 

3232 - Transaction is committed on success (for both owned and reused sessions) 

3233 - Transaction is rolled back on error (for both owned and reused sessions) 

3234 - Session is closed only if created by this function (not if reused from middleware) 

3235 - Broken connections are invalidated to prevent pool corruption 

3236 

3237 Examples: 

3238 >>> # Test that get_db returns a generator 

3239 >>> db_gen = get_db() 

3240 >>> hasattr(db_gen, '__next__') 

3241 True 

3242 >>> # Test cleanup happens 

3243 >>> try: 

3244 ... db = next(db_gen) 

3245 ... type(db).__name__ 

3246 ... finally: 

3247 ... try: 

3248 ... next(db_gen) 

3249 ... except StopIteration: 

3250 ... pass # Expected - generator cleanup 

3251 'ResilientSession' 

3252 """ 

3253 # Check if ObservabilityMiddleware already created a request-scoped session 

3254 # This eliminates duplicate session creation when observability is enabled (Issue #3467) 

3255 if request is not None and hasattr(request, "state") and hasattr(request.state, "db"): 

3256 db = request.state.db 

3257 if db is not None: 

3258 logger.debug(f"[GET_DB] Reusing session from middleware: {id(db)}") 

3259 # Yield the middleware's session. We control transactions, middleware controls lifecycle. 

3260 try: 

3261 yield db 

3262 # Commit on successful completion (only if transaction still active) 

3263 # The transaction can become inactive if an exception occurred during 

3264 # async context manager cleanup (e.g., CancelledError during MCP session teardown). 

3265 if db.is_active: 

3266 db.commit() 

3267 except Exception: 

3268 try: 

3269 # Always call rollback() in exception handler. 

3270 # rollback() is safe to call even when is_active=False - it succeeds and 

3271 # restores the session to a usable state. When is_active=False (e.g., after 

3272 # IntegrityError), rollback() is actually REQUIRED to clear the failed state. 

3273 # Skipping rollback when is_active=False would leave the session unusable. 

3274 db.rollback() 

3275 except Exception: 

3276 # Connection is broken - invalidate to remove from pool 

3277 # This handles cases like PgBouncer query_wait_timeout where 

3278 # the connection is dead and rollback itself fails 

3279 try: 

3280 db.invalidate() 

3281 except Exception: 

3282 pass # nosec B110 - Best effort cleanup on connection failure 

3283 raise 

3284 # Don't close - middleware owns the session lifecycle 

3285 return 

3286 

3287 # Fallback: Create our own session (observability disabled or middleware didn't create one) 

3288 db = SessionLocal() 

3289 logger.debug(f"[GET_DB] DB session created: {id(db)}") 

3290 try: 

3291 yield db 

3292 # Only commit if the transaction is still active. 

3293 # The transaction can become inactive if an exception occurred during 

3294 # async context manager cleanup (e.g., CancelledError during MCP session teardown). 

3295 if db.is_active: 

3296 db.commit() 

3297 except Exception: 

3298 try: 

3299 # Always call rollback() in exception handler. 

3300 # rollback() is safe to call even when is_active=False - it succeeds and 

3301 # restores the session to a usable state. When is_active=False (e.g., after 

3302 # IntegrityError), rollback() is actually REQUIRED to clear the failed state. 

3303 # Skipping rollback when is_active=False would leave the session unusable. 

3304 db.rollback() 

3305 except Exception: 

3306 # Connection is broken - invalidate to remove from pool 

3307 # This handles cases like PgBouncer query_wait_timeout where 

3308 # the connection is dead and rollback itself fails 

3309 try: 

3310 db.invalidate() 

3311 except Exception: 

3312 pass # nosec B110 - Best effort cleanup on connection failure 

3313 raise 

3314 finally: 

3315 try: 

3316 db.close() 

3317 except Exception: 

3318 pass # nosec B110 - Best effort cleanup on already-failed prompt bridge sessions 

3319 

3320 

3321async def require_valid_server(server_id: str, db: Session = Depends(get_db)) -> str: 

3322 """FastAPI dependency that validates a server_id exists in the database. 

3323 

3324 Provides a reusable, fail-closed guard for any server-scoped endpoint. 

3325 Uses the lightweight ``entity_exists()`` check — no eager loading. 

3326 

3327 Args: 

3328 server_id: Path parameter extracted by FastAPI. 

3329 db: Database session from the ``get_db`` dependency. 

3330 

3331 Returns: 

3332 The validated server_id string. 

3333 

3334 Raises: 

3335 HTTPException: 404 if the server does not exist, 503 on database errors. 

3336 """ 

3337 try: 

3338 if not await server_service.entity_exists(db, server_id): 

3339 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found") 

3340 except HTTPException: 

3341 raise 

3342 except Exception: 

3343 raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Service unavailable — unable to verify server") 

3344 return server_id 

3345 

3346 

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

3348 """Read JSON payload using orjson. 

3349 

3350 Args: 

3351 request: Incoming FastAPI request to read JSON from. 

3352 

3353 Returns: 

3354 Parsed JSON payload. 

3355 

3356 Raises: 

3357 HTTPException: 400 for invalid JSON bodies. 

3358 """ 

3359 body = await request.body() 

3360 if not body: 

3361 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON in request body") 

3362 try: 

3363 return orjson.loads(body) 

3364 except orjson.JSONDecodeError as exc: 

3365 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON in request body") from exc 

3366 

3367 

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

3369 """Validates the provided API key. 

3370 

3371 This function checks if the provided API key matches the expected one 

3372 based on the settings. If the validation fails, it raises an HTTPException 

3373 with a 401 Unauthorized status. 

3374 

3375 Args: 

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

3377 

3378 Raises: 

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

3380 

3381 Examples: 

3382 >>> from mcpgateway.config import settings 

3383 >>> from pydantic import SecretStr 

3384 >>> settings.auth_required = True 

3385 >>> settings.basic_auth_user = "admin" 

3386 >>> settings.basic_auth_password = SecretStr("secret") 

3387 >>> 

3388 >>> # Valid API key 

3389 >>> require_api_key("admin:secret") # Should not raise 

3390 >>> 

3391 >>> # Invalid API key 

3392 >>> try: 

3393 ... require_api_key("wrong:key") 

3394 ... except HTTPException as e: 

3395 ... e.status_code 

3396 401 

3397 """ 

3398 if settings.auth_required: 

3399 expected = f"{settings.basic_auth_user}:{settings.basic_auth_password.get_secret_value()}" 

3400 if api_key != expected: 

3401 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") 

3402 

3403 

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

3405 """ 

3406 Invalidates the resource cache. 

3407 

3408 If a specific URI is provided, only that resource will be removed from the cache. 

3409 If no URI is provided, the entire resource cache will be cleared. 

3410 

3411 Args: 

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

3413 

3414 Examples: 

3415 >>> import asyncio 

3416 >>> # Test clearing specific URI from cache 

3417 >>> resource_cache.set("/test/resource", {"content": "test data"}) 

3418 >>> resource_cache.get("/test/resource") is not None 

3419 True 

3420 >>> asyncio.run(invalidate_resource_cache("/test/resource")) 

3421 >>> resource_cache.get("/test/resource") is None 

3422 True 

3423 >>> 

3424 >>> # Test clearing entire cache 

3425 >>> resource_cache.set("/resource1", {"content": "data1"}) 

3426 >>> resource_cache.set("/resource2", {"content": "data2"}) 

3427 >>> asyncio.run(invalidate_resource_cache()) 

3428 >>> resource_cache.get("/resource1") is None and resource_cache.get("/resource2") is None 

3429 True 

3430 """ 

3431 if uri: 

3432 resource_cache.delete(uri) 

3433 else: 

3434 resource_cache.clear() 

3435 

3436 

3437def get_protocol_from_request(request: Request) -> str: 

3438 """ 

3439 Return "https" or "http" based on: 

3440 1) X-Forwarded-Proto (if set by a proxy) 

3441 2) request.url.scheme (e.g. when Gunicorn/Uvicorn is terminating TLS) 

3442 

3443 Args: 

3444 request (Request): The FastAPI request object. 

3445 

3446 Returns: 

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

3448 

3449 Examples: 

3450 Test with X-Forwarded-Proto header (proxy scenario): 

3451 >>> from mcpgateway import main 

3452 >>> from fastapi import Request 

3453 >>> from urllib.parse import urlparse 

3454 >>> 

3455 >>> # Mock request with X-Forwarded-Proto 

3456 >>> scope = { 

3457 ... 'type': 'http', 

3458 ... 'scheme': 'http', 

3459 ... 'headers': [(b'x-forwarded-proto', b'https')], 

3460 ... 'server': ('testserver', 80), 

3461 ... 'path': '/', 

3462 ... } 

3463 >>> req = Request(scope) 

3464 >>> main.get_protocol_from_request(req) 

3465 'https' 

3466 

3467 Test with comma-separated X-Forwarded-Proto: 

3468 >>> scope_multi = { 

3469 ... 'type': 'http', 

3470 ... 'scheme': 'http', 

3471 ... 'headers': [(b'x-forwarded-proto', b'https,http')], 

3472 ... 'server': ('testserver', 80), 

3473 ... 'path': '/', 

3474 ... } 

3475 >>> req_multi = Request(scope_multi) 

3476 >>> main.get_protocol_from_request(req_multi) 

3477 'https' 

3478 

3479 Test without X-Forwarded-Proto (direct connection): 

3480 >>> scope_direct = { 

3481 ... 'type': 'http', 

3482 ... 'scheme': 'https', 

3483 ... 'headers': [], 

3484 ... 'server': ('testserver', 443), 

3485 ... 'path': '/', 

3486 ... } 

3487 >>> req_direct = Request(scope_direct) 

3488 >>> main.get_protocol_from_request(req_direct) 

3489 'https' 

3490 

3491 Test with HTTP direct connection: 

3492 >>> scope_http = { 

3493 ... 'type': 'http', 

3494 ... 'scheme': 'http', 

3495 ... 'headers': [], 

3496 ... 'server': ('testserver', 80), 

3497 ... 'path': '/', 

3498 ... } 

3499 >>> req_http = Request(scope_http) 

3500 >>> main.get_protocol_from_request(req_http) 

3501 'http' 

3502 """ 

3503 forwarded = request.headers.get("x-forwarded-proto") 

3504 if forwarded: 

3505 # may be a comma-separated list; take the first 

3506 return forwarded.split(",")[0].strip() 

3507 return request.url.scheme 

3508 

3509 

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

3511 """ 

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

3513 

3514 Args: 

3515 request (Request): The FastAPI request object. 

3516 

3517 Returns: 

3518 str: The base URL with the correct protocol. 

3519 

3520 Examples: 

3521 Test URL protocol update with HTTPS proxy: 

3522 >>> from mcpgateway import main 

3523 >>> from fastapi import Request 

3524 >>> 

3525 >>> # Mock request with HTTPS forwarded proto 

3526 >>> scope_https = { 

3527 ... 'type': 'http', 

3528 ... 'scheme': 'http', 

3529 ... 'server': ('example.com', 80), 

3530 ... 'path': '/', 

3531 ... 'headers': [(b'x-forwarded-proto', b'https')], 

3532 ... } 

3533 >>> req_https = Request(scope_https) 

3534 >>> url = main.update_url_protocol(req_https) 

3535 >>> url.startswith('https://example.com') 

3536 True 

3537 

3538 Test URL protocol update with HTTP direct: 

3539 >>> scope_http = { 

3540 ... 'type': 'http', 

3541 ... 'scheme': 'http', 

3542 ... 'server': ('localhost', 8000), 

3543 ... 'path': '/', 

3544 ... 'headers': [], 

3545 ... } 

3546 >>> req_http = Request(scope_http) 

3547 >>> url = main.update_url_protocol(req_http) 

3548 >>> url.startswith('http://localhost:8000') 

3549 True 

3550 

3551 Test URL protocol update preserves host and port: 

3552 >>> scope_port = { 

3553 ... 'type': 'http', 

3554 ... 'scheme': 'https', 

3555 ... 'server': ('api.test.com', 443), 

3556 ... 'path': '/', 

3557 ... 'headers': [], 

3558 ... } 

3559 >>> req_port = Request(scope_port) 

3560 >>> url = main.update_url_protocol(req_port) 

3561 >>> 'api.test.com' in url and url.startswith('https://') 

3562 True 

3563 

3564 Test trailing slash removal: 

3565 >>> # URL should not end with trailing slash 

3566 >>> url = main.update_url_protocol(req_http) 

3567 >>> url.endswith('/') 

3568 False 

3569 """ 

3570 parsed = urlparse(str(request.base_url)) 

3571 proto = get_protocol_from_request(request) 

3572 new_parsed = parsed._replace(scheme=proto) 

3573 # urlunparse keeps netloc and path intact 

3574 return str(urlunparse(new_parsed)).rstrip("/") 

3575 

3576 

3577# Protocol APIs # 

3578@protocol_router.post("/initialize") 

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

3580 """ 

3581 Initialize a protocol. 

3582 

3583 This endpoint handles the initialization process of a protocol by accepting 

3584 a JSON request body and processing it. The `require_auth` dependency ensures that 

3585 the user is authenticated before proceeding. 

3586 

3587 Args: 

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

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

3590 

3591 Returns: 

3592 InitializeResult: The result of the initialization process. 

3593 

3594 Raises: 

3595 HTTPException: If the request body contains invalid JSON, a 400 Bad Request error is raised. 

3596 """ 

3597 try: 

3598 body = await _read_request_json(request) 

3599 

3600 logger.debug(f"Authenticated user {SecurityValidator.sanitize_log_message(str(user))} is initializing the protocol.") 

3601 return await session_registry.handle_initialize_logic(body) 

3602 

3603 except orjson.JSONDecodeError: 

3604 raise HTTPException( 

3605 status_code=status.HTTP_400_BAD_REQUEST, 

3606 detail="Invalid JSON in request body", 

3607 ) 

3608 

3609 

3610@protocol_router.post("/ping") 

3611async def ping(request: Request, user=Depends(get_current_user)) -> JSONResponse: 

3612 """ 

3613 Handle a ping request according to the MCP specification. 

3614 

3615 This endpoint expects a JSON-RPC request with the method "ping" and responds 

3616 with a JSON-RPC response containing an empty result, as required by the protocol. 

3617 

3618 Args: 

3619 request (Request): The incoming FastAPI request. 

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

3621 

3622 Returns: 

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

3624 

3625 Raises: 

3626 HTTPException: If the request method is not "ping". 

3627 """ 

3628 req_id: Optional[str] = None 

3629 try: 

3630 body: dict = await _read_request_json(request) 

3631 if body.get("method") != "ping": 

3632 raise HTTPException(status_code=400, detail="Invalid method") 

3633 req_id = body.get("id") 

3634 logger.debug(f"Authenticated user {SecurityValidator.sanitize_log_message(str(user))} sent ping request.") 

3635 # Return an empty result per the MCP ping specification. 

3636 response: dict = {"jsonrpc": "2.0", "id": req_id, "result": {}} 

3637 return ORJSONResponse(content=response) 

3638 except Exception as e: 

3639 error_response: dict = { 

3640 "jsonrpc": "2.0", 

3641 "id": req_id, # Now req_id is always defined 

3642 "error": {"code": -32603, "message": "Internal error", "data": str(e)}, 

3643 } 

3644 return ORJSONResponse(status_code=500, content=error_response) 

3645 

3646 

3647@protocol_router.post("/notifications") 

3648async def handle_notification(request: Request, user=Depends(get_current_user)) -> None: 

3649 """ 

3650 Handles incoming notifications from clients. Depending on the notification method, 

3651 different actions are taken (e.g., logging initialization, cancellation, or messages). 

3652 

3653 Args: 

3654 request (Request): The incoming request containing the notification data. 

3655 user (str): The authenticated user making the request. 

3656 """ 

3657 body = await _read_request_json(request) 

3658 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} sent a notification") 

3659 if body.get("method") == "notifications/initialized": 

3660 logger.info("Client initialized") 

3661 await logging_service.notify("Client initialized", LogLevel.INFO) 

3662 elif body.get("method") == "notifications/cancelled": 

3663 # Note: requestId can be 0 (valid per JSON-RPC), so use 'is not None' and normalize to string 

3664 raw_request_id = body.get("params", {}).get("requestId") 

3665 request_id = str(raw_request_id) if raw_request_id is not None else None 

3666 reason = body.get("params", {}).get("reason") 

3667 logger.info(f"Request cancelled: {request_id}, reason: {reason}") 

3668 # Attempt local cancellation per MCP spec 

3669 if request_id is not None: 

3670 await _authorize_run_cancellation(request, user, request_id, as_jsonrpc_error=False) 

3671 await cancellation_service.cancel_run(request_id, reason=reason) 

3672 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO) 

3673 elif body.get("method") == "notifications/message": 

3674 params = body.get("params", {}) 

3675 await logging_service.notify( 

3676 params.get("data"), 

3677 LogLevel(params.get("level", "info")), 

3678 params.get("logger"), 

3679 ) 

3680 

3681 

3682@protocol_router.post("/completion/complete") 

3683async def handle_completion(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)): 

3684 """ 

3685 Handles the completion of tasks by processing a completion request. 

3686 

3687 Args: 

3688 request (Request): The incoming request with completion data. 

3689 db (Session): The database session used to interact with the data store. 

3690 user (str): The authenticated user making the request. 

3691 

3692 Returns: 

3693 The result of the completion process. 

3694 """ 

3695 body = await _read_request_json(request) 

3696 logger.debug(f"User {SecurityValidator.sanitize_log_message(user['email'])} sent a completion request") 

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

3698 if is_admin and token_teams is None: 

3699 user_email = None 

3700 elif token_teams is None: 

3701 token_teams = [] 

3702 return await completion_service.handle_completion(db, body, user_email=user_email, token_teams=token_teams) 

3703 

3704 

3705@protocol_router.post("/sampling/createMessage") 

3706async def handle_sampling(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)): 

3707 """ 

3708 Handles the creation of a new message for sampling. 

3709 

3710 Args: 

3711 request (Request): The incoming request with sampling data. 

3712 db (Session): The database session used to interact with the data store. 

3713 user (str): The authenticated user making the request. 

3714 

3715 Returns: 

3716 The result of the message creation process. 

3717 """ 

3718 logger.debug(f"User {SecurityValidator.sanitize_log_message(user['email'])} sent a sampling request") 

3719 body = await _read_request_json(request) 

3720 return await sampling_handler.create_message(db, body) 

3721 

3722 

3723############### 

3724# Server APIs # 

3725############### 

3726@server_router.get("", response_model=Union[List[ServerRead], CursorPaginatedServersResponse]) 

3727@server_router.get("/", response_model=Union[List[ServerRead], CursorPaginatedServersResponse]) 

3728@require_permission("servers.read") 

3729async def list_servers( 

3730 request: Request, 

3731 cursor: Optional[str] = Query(None, description="Cursor for pagination"), 

3732 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"), 

3733 limit: Optional[int] = Query(None, ge=0, description="Maximum number of servers to return"), 

3734 include_inactive: bool = False, 

3735 include_metrics: bool = False, 

3736 tags: Optional[str] = None, 

3737 team_id: Optional[str] = None, 

3738 visibility: Optional[str] = None, 

3739 db: Session = Depends(get_db), 

3740 user=Depends(get_current_user_with_permissions), 

3741) -> Union[List[ServerRead], Dict[str, Any]]: 

3742 """ 

3743 Lists servers accessible to the user, with team filtering and cursor pagination support. 

3744 

3745 Args: 

3746 request (Request): The incoming request object for team_id retrieval. 

3747 cursor (Optional[str]): Cursor for pagination. 

3748 include_pagination (bool): Include cursor pagination metadata in response. 

3749 limit (Optional[int]): Maximum number of servers to return. 

3750 include_inactive (bool): Whether to include inactive servers in the response. 

3751 include_metrics (bool): Whether to include aggregated metrics in the response. 

3752 tags (Optional[str]): Comma-separated list of tags to filter by. 

3753 team_id (Optional[str]): Filter by specific team ID. 

3754 visibility (Optional[str]): Filter by visibility (private, team, public). 

3755 db (Session): The database session used to interact with the data store. 

3756 user (str): The authenticated user making the request. 

3757 

3758 Returns: 

3759 Union[List[ServerRead], Dict[str, Any]]: A list of server objects or paginated response with nextCursor. 

3760 """ 

3761 # Parse tags parameter if provided 

3762 tags_list = None 

3763 if tags: 

3764 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] 

3765 # Get user email for team filtering 

3766 user_email = get_user_email(user) 

3767 

3768 # Check team ID from token 

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

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

3771 

3772 # Check for team ID mismatch 

3773 if team_id is not None and token_team_id is not None and team_id != token_team_id: 

3774 return ORJSONResponse( 

3775 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

3776 status_code=status.HTTP_403_FORBIDDEN, 

3777 ) 

3778 

3779 # For listing, only narrow by team_id when explicitly requested via query param. 

3780 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping 

3781 # (public + team resources). Auto-narrowing would exclude public servers. 

3782 

3783 # SECURITY: token_teams is normalized in auth.py: 

3784 # - None: admin bypass (is_admin=true with explicit null teams) - sees ALL resources 

3785 # - []: public-only (missing teams or explicit empty) - sees only public 

3786 # - [...]: team-scoped - sees public + teams + user's private 

3787 is_admin_bypass = token_teams is None 

3788 is_public_only_token = token_teams is not None and len(token_teams) == 0 

3789 

3790 # Use consolidated server listing with optional team filtering 

3791 # For admin bypass: pass user_email=None and token_teams=None to skip all filtering 

3792 logger.debug( 

3793 f"User: {SecurityValidator.sanitize_log_message(user_email)} requested server list with include_inactive={include_inactive}, tags={tags_list}, team_id={team_id}, visibility={visibility}" 

3794 ) 

3795 data, next_cursor = await server_service.list_servers( 

3796 db=db, 

3797 cursor=cursor, 

3798 limit=limit, 

3799 include_inactive=include_inactive, 

3800 include_metrics=include_metrics, 

3801 tags=tags_list, 

3802 user_email=None if is_admin_bypass else user_email, # Admin bypass: no user filtering 

3803 team_id=team_id, 

3804 visibility="public" if is_public_only_token and not visibility else visibility, 

3805 token_teams=token_teams, # None = admin bypass, [] = public-only, [...] = team-scoped 

3806 ) 

3807 

3808 if include_pagination: 

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

3810 return data 

3811 

3812 

3813@server_router.get("/{server_id}", response_model=ServerRead) 

3814@require_permission("servers.read") 

3815async def get_server(server_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> ServerRead: 

3816 """ 

3817 Retrieves a server by its ID. 

3818 

3819 Args: 

3820 server_id (str): The ID of the server to retrieve. 

3821 request (Request): The incoming request used for scoped access validation. 

3822 db (Session): The database session used to interact with the data store. 

3823 user (str): The authenticated user making the request. 

3824 

3825 Returns: 

3826 ServerRead: The server object with the specified ID. 

3827 

3828 Raises: 

3829 HTTPException: If the server is not found. 

3830 """ 

3831 try: 

3832 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested server with ID {server_id}") 

3833 server = await server_service.get_server(db, server_id) 

3834 _enforce_scoped_resource_access(request, db, user, f"/servers/{server_id}") 

3835 return server 

3836 except ServerNotFoundError as e: 

3837 raise HTTPException(status_code=404, detail=str(e)) 

3838 

3839 

3840@server_router.post("", response_model=ServerRead, status_code=201) 

3841@server_router.post("/", response_model=ServerRead, status_code=201) 

3842@require_permission("servers.create") 

3843async def create_server( 

3844 server: ServerCreate, 

3845 request: Request, 

3846 team_id: Optional[str] = Body(None, description="Team ID to assign server to"), 

3847 visibility: Optional[str] = Body(None, description="Server visibility: private, team, public"), 

3848 db: Session = Depends(get_db), 

3849 user=Depends(get_current_user_with_permissions), 

3850) -> ServerRead: 

3851 """ 

3852 Creates a new server. 

3853 

3854 Args: 

3855 server (ServerCreate): The data for the new server. 

3856 request (Request): The incoming request object for extracting metadata. 

3857 team_id (Optional[str]): Team ID to assign the server to. 

3858 visibility (str): Server visibility level (private, team, public). 

3859 db (Session): The database session used to interact with the data store. 

3860 user (str): The authenticated user making the request. 

3861 

3862 Returns: 

3863 ServerRead: The created server object. 

3864 

3865 Raises: 

3866 HTTPException: If there is a conflict with the server name or other errors. 

3867 """ 

3868 try: 

3869 # Extract metadata from request 

3870 metadata = MetadataCapture.extract_creation_metadata(request, user) 

3871 

3872 # Get user email and handle team assignment 

3873 user_email = get_user_email(user) 

3874 

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

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

3877 

3878 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources 

3879 is_public_only_token = token_teams is not None and len(token_teams) == 0 

3880 if is_public_only_token and visibility in ("team", "private"): 

3881 return ORJSONResponse( 

3882 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."}, 

3883 status_code=status.HTTP_403_FORBIDDEN, 

3884 ) 

3885 

3886 # Check for team ID mismatch (only for non-public-only tokens) 

3887 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: 

3888 return ORJSONResponse( 

3889 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

3890 status_code=status.HTTP_403_FORBIDDEN, 

3891 ) 

3892 

3893 # Determine final team ID (public-only tokens get no team) 

3894 if is_public_only_token: 

3895 team_id = None 

3896 else: 

3897 team_id = team_id or token_team_id 

3898 

3899 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new server for team {team_id}") 

3900 result = await server_service.register_server( 

3901 db, 

3902 server, 

3903 created_by=metadata["created_by"], 

3904 created_from_ip=metadata["created_from_ip"], 

3905 created_via=metadata["created_via"], 

3906 created_user_agent=metadata["created_user_agent"], 

3907 team_id=team_id, 

3908 owner_email=user_email, 

3909 visibility=visibility, 

3910 ) 

3911 db.commit() 

3912 db.close() 

3913 return result 

3914 except ServerNameConflictError as e: 

3915 raise HTTPException(status_code=409, detail=str(e)) 

3916 except ServerError as e: 

3917 raise HTTPException(status_code=400, detail=str(e)) 

3918 except ValidationError as e: 

3919 logger.error(f"Validation error while creating server: {e}") 

3920 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e)) 

3921 except IntegrityError as e: 

3922 logger.error(f"Integrity error while creating server: {e}") 

3923 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e)) 

3924 

3925 

3926@server_router.put("/{server_id}", response_model=ServerRead) 

3927@require_permission("servers.update") 

3928async def update_server( 

3929 server_id: str, 

3930 server: ServerUpdate, 

3931 request: Request, 

3932 db: Session = Depends(get_db), 

3933 user=Depends(get_current_user_with_permissions), 

3934) -> ServerRead: 

3935 """ 

3936 Updates the information of an existing server. 

3937 

3938 Args: 

3939 server_id (str): The ID of the server to update. 

3940 server (ServerUpdate): The updated server data. 

3941 request (Request): The incoming request object containing metadata. 

3942 db (Session): The database session used to interact with the data store. 

3943 user (str): The authenticated user making the request. 

3944 

3945 Returns: 

3946 ServerRead: The updated server object. 

3947 

3948 Raises: 

3949 HTTPException: If the server is not found, there is a name conflict, or other errors. 

3950 """ 

3951 try: 

3952 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is updating server with ID {server_id}") 

3953 # Extract modification metadata 

3954 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service 

3955 

3956 user_email: str = get_user_email(user) 

3957 

3958 result = await server_service.update_server( 

3959 db, 

3960 server_id, 

3961 server, 

3962 user_email, 

3963 modified_by=mod_metadata["modified_by"], 

3964 modified_from_ip=mod_metadata["modified_from_ip"], 

3965 modified_via=mod_metadata["modified_via"], 

3966 modified_user_agent=mod_metadata["modified_user_agent"], 

3967 ) 

3968 db.commit() 

3969 db.close() 

3970 return result 

3971 except PermissionError as e: 

3972 raise HTTPException(status_code=403, detail=str(e)) 

3973 except ServerNotFoundError as e: 

3974 raise HTTPException(status_code=404, detail=str(e)) 

3975 except ServerNameConflictError as e: 

3976 raise HTTPException(status_code=409, detail=str(e)) 

3977 except ServerError as e: 

3978 raise HTTPException(status_code=400, detail=str(e)) 

3979 except ValidationError as e: 

3980 logger.error(f"Validation error while updating server {server_id}: {e}") 

3981 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e)) 

3982 except IntegrityError as e: 

3983 logger.error(f"Integrity error while updating server {server_id}: {e}") 

3984 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e)) 

3985 

3986 

3987@server_router.post("/{server_id}/state", response_model=ServerRead) 

3988@require_permission("servers.update") 

3989async def set_server_state( 

3990 server_id: str, 

3991 activate: bool = True, 

3992 db: Session = Depends(get_db), 

3993 user=Depends(get_current_user_with_permissions), 

3994) -> ServerRead: 

3995 """ 

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

3997 

3998 Args: 

3999 server_id (str): The ID of the server to set state for. 

4000 activate (bool): Whether to activate or deactivate the server. 

4001 db (Session): The database session used to interact with the data store. 

4002 user (str): The authenticated user making the request. 

4003 

4004 Returns: 

4005 ServerRead: The server object after the status change. 

4006 

4007 Raises: 

4008 HTTPException: If the server is not found or there is an error. 

4009 """ 

4010 try: 

4011 user_email = user.get("email") if isinstance(user, dict) else str(user) 

4012 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is setting server with ID {server_id} to {'active' if activate else 'inactive'}") 

4013 return await server_service.set_server_state(db, server_id, activate, user_email=user_email) 

4014 except PermissionError as e: 

4015 raise HTTPException(status_code=403, detail=str(e)) 

4016 except ServerNotFoundError as e: 

4017 raise HTTPException(status_code=404, detail=str(e)) 

4018 except ServerLockConflictError as e: 

4019 raise HTTPException(status_code=409, detail=str(e)) 

4020 except ServerError as e: 

4021 raise HTTPException(status_code=400, detail=str(e)) 

4022 

4023 

4024@server_router.post("/{server_id}/toggle", response_model=ServerRead, deprecated=True) 

4025@require_permission("servers.update") 

4026async def toggle_server_status( 

4027 server_id: str, 

4028 activate: bool = True, 

4029 db: Session = Depends(get_db), 

4030 user=Depends(get_current_user_with_permissions), 

4031) -> ServerRead: 

4032 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release. 

4033 

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

4035 

4036 Args: 

4037 server_id: The server ID. 

4038 activate: Whether to activate (True) or deactivate (False) the server. 

4039 db: Database session. 

4040 user: Authenticated user context. 

4041 

4042 Returns: 

4043 The updated server. 

4044 """ 

4045 

4046 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2) 

4047 return await set_server_state(server_id, activate, db, user) 

4048 

4049 

4050@server_router.delete("/{server_id}", response_model=Dict[str, str]) 

4051@require_permission("servers.delete") 

4052async def delete_server( 

4053 server_id: str, 

4054 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this server"), 

4055 db: Session = Depends(get_db), 

4056 user=Depends(get_current_user_with_permissions), 

4057) -> Dict[str, str]: 

4058 """ 

4059 Deletes a server by its ID. 

4060 

4061 Args: 

4062 server_id (str): The ID of the server to delete. 

4063 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this server. 

4064 db (Session): The database session used to interact with the data store. 

4065 user (str): The authenticated user making the request. 

4066 

4067 Returns: 

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

4069 

4070 Raises: 

4071 HTTPException: If the server is not found or there is an error. 

4072 """ 

4073 try: 

4074 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is deleting server with ID {server_id}") 

4075 user_email = user.get("email") if isinstance(user, dict) else str(user) 

4076 await server_service.get_server(db, server_id) 

4077 await server_service.delete_server(db, server_id, user_email=user_email, purge_metrics=purge_metrics) 

4078 db.commit() 

4079 db.close() 

4080 return { 

4081 "status": "success", 

4082 "message": f"Server {server_id} deleted successfully", 

4083 } 

4084 except PermissionError as e: 

4085 raise HTTPException(status_code=403, detail=str(e)) 

4086 except ServerNotFoundError as e: 

4087 raise HTTPException(status_code=404, detail=str(e)) 

4088 except ServerError as e: 

4089 raise HTTPException(status_code=400, detail=str(e)) 

4090 

4091 

4092@server_router.get("/{server_id}/sse") 

4093@require_permission("servers.use") 

4094async def sse_endpoint(request: Request, server_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)): 

4095 """ 

4096 Establishes a Server-Sent Events (SSE) connection for real-time updates about a server. 

4097 

4098 Args: 

4099 request (Request): The incoming request. 

4100 server_id (str): The ID of the server for which updates are received. 

4101 db (Session): The database session used for server existence and scope checks. 

4102 user (str): The authenticated user making the request. 

4103 

4104 Returns: 

4105 The SSE response object for the established connection. 

4106 

4107 Raises: 

4108 HTTPException: If there is an error in establishing the SSE connection. 

4109 asyncio.CancelledError: If the request is cancelled during SSE setup. 

4110 """ 

4111 try: 

4112 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is establishing SSE connection for server {server_id}") 

4113 await server_service.get_server(db, server_id) 

4114 _enforce_scoped_resource_access(request, db, user, f"/servers/{server_id}/sse") 

4115 

4116 base_url = update_url_protocol(request) 

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

4118 

4119 # SSE transport generates its own session_id - server-initiated, not client-provided 

4120 transport = SSETransport(base_url=server_sse_url) 

4121 await transport.connect() 

4122 await session_registry.add_session(transport.session_id, transport) 

4123 await session_registry.set_session_owner(transport.session_id, get_user_email(user)) 

4124 

4125 # Extract auth token from request (header OR cookie, like get_current_user_with_permissions) 

4126 # MUST be computed BEFORE create_sse_response to avoid race condition (Finding 1) 

4127 auth_token = None 

4128 auth_header = request.headers.get("authorization", "") 

4129 if auth_header.lower().startswith("bearer "): 

4130 auth_token = auth_header[7:] 

4131 elif hasattr(request, "cookies") and request.cookies: 

4132 # Cookie auth (admin UI sessions) 

4133 auth_token = request.cookies.get("jwt_token") or request.cookies.get("access_token") 

4134 

4135 # Extract and normalize token teams 

4136 # Returns None if no JWT payload (non-JWT auth), or list if JWT exists 

4137 # SECURITY: Preserve None vs [] distinction for admin bypass: 

4138 # - None: unrestricted (admin keeps bypass, non-admin gets their accessible resources) 

4139 # - []: public-only (admin bypass disabled) 

4140 # - [...]: team-scoped access 

4141 token_teams = _get_token_teams_from_request(request) 

4142 

4143 # Preserve is_admin from user object (for cookie-authenticated admins) 

4144 is_admin = False 

4145 if hasattr(user, "is_admin"): 

4146 is_admin = getattr(user, "is_admin", False) 

4147 elif isinstance(user, dict): 

4148 is_admin = user.get("is_admin", False) or user.get("user", {}).get("is_admin", False) 

4149 

4150 # Create enriched user dict 

4151 user_with_token = dict(user) if isinstance(user, dict) else {"email": getattr(user, "email", str(user))} 

4152 user_with_token["auth_token"] = auth_token 

4153 user_with_token["token_teams"] = token_teams # None for unrestricted, [] for public-only, [...] for team-scoped 

4154 user_with_token["is_admin"] = is_admin # Preserve admin status for fallback token 

4155 

4156 # Capture passthrough headers from the original SSE request for loopback /rpc calls. 

4157 # Without this, headers like X-Upstream-Authorization are silently dropped. See #3640. 

4158 # First-Party 

4159 from mcpgateway.utils.passthrough_headers import safe_extract_headers_for_loopback # pylint: disable=import-outside-toplevel 

4160 

4161 user_with_token["_passthrough_headers"] = safe_extract_headers_for_loopback(dict(request.headers), "SSE") 

4162 

4163 # Defensive cleanup callback - runs immediately on client disconnect 

4164 async def on_disconnect_cleanup() -> None: 

4165 """Clean up session when SSE client disconnects.""" 

4166 try: 

4167 await session_registry.remove_session(transport.session_id) 

4168 logger.debug("Defensive session cleanup completed: %s", transport.session_id) 

4169 except Exception as e: 

4170 logger.warning("Defensive session cleanup failed for %s: %s", transport.session_id, e) 

4171 

4172 # CRITICAL: Create and register respond task BEFORE create_sse_response (Finding 1 fix) 

4173 # This ensures the task exists when disconnect callback runs, preventing orphaned tasks 

4174 respond_task = asyncio.create_task(session_registry.respond(server_id, user_with_token, session_id=transport.session_id)) 

4175 session_registry.register_respond_task(transport.session_id, respond_task) 

4176 

4177 try: 

4178 response = await transport.create_sse_response(request, on_disconnect_callback=on_disconnect_cleanup) 

4179 except asyncio.CancelledError: 

4180 # Request cancelled - still need to clean up to prevent orphaned tasks 

4181 logger.debug(f"SSE request cancelled for {transport.session_id}, cleaning up") 

4182 try: 

4183 await session_registry.remove_session(transport.session_id) 

4184 except Exception as cleanup_error: 

4185 logger.warning(f"Cleanup after SSE cancellation failed: {cleanup_error}") 

4186 raise # Re-raise CancelledError 

4187 except Exception as sse_error: 

4188 # CRITICAL: Cleanup on failure - respond task and session would be orphaned otherwise 

4189 logger.error(f"create_sse_response failed for {transport.session_id}: {sse_error}") 

4190 try: 

4191 await session_registry.remove_session(transport.session_id) 

4192 except Exception as cleanup_error: 

4193 logger.warning(f"Cleanup after SSE failure also failed: {cleanup_error}") 

4194 raise 

4195 

4196 tasks = BackgroundTasks() 

4197 tasks.add_task(session_registry.remove_session, transport.session_id) 

4198 response.background = tasks 

4199 logger.info(f"SSE connection established: {transport.session_id}") 

4200 return response 

4201 except ServerNotFoundError as e: 

4202 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

4203 except HTTPException: 

4204 raise 

4205 except Exception as e: 

4206 logger.error(f"SSE connection error: {e}") 

4207 raise HTTPException(status_code=500, detail="SSE connection failed") 

4208 

4209 

4210@server_router.post("/{server_id}/message") 

4211@require_permission("servers.use") 

4212async def message_endpoint(request: Request, server_id: str = Depends(require_valid_server), user=Depends(get_current_user_with_permissions)): 

4213 """ 

4214 Handles incoming messages for a specific server. 

4215 

4216 Args: 

4217 request (Request): The incoming message request. 

4218 server_id (str): The ID of the server receiving the message. 

4219 user (str): The authenticated user making the request. 

4220 

4221 Returns: 

4222 JSONResponse: A success status after processing the message. 

4223 

4224 Raises: 

4225 HTTPException: If there are errors processing the message. 

4226 """ 

4227 try: 

4228 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} sent a message to server {server_id}") 

4229 session_id = request.query_params.get("session_id") 

4230 if not session_id: 

4231 logger.error("Missing session_id in message request") 

4232 raise HTTPException(status_code=400, detail="Missing session_id") 

4233 set_trace_session_id(session_id) 

4234 

4235 await _assert_session_owner_or_admin(request, user, session_id) 

4236 

4237 message = await _read_request_json(request) 

4238 

4239 # Check if this is an elicitation response (JSON-RPC response with result containing action) 

4240 is_elicitation_response = False 

4241 if "result" in message and isinstance(message.get("result"), dict): 

4242 result_data = message["result"] 

4243 if "action" in result_data and result_data.get("action") in ["accept", "decline", "cancel"]: 

4244 # This looks like an elicitation response 

4245 request_id = message.get("id") 

4246 if request_id: 

4247 # Try to complete the elicitation 

4248 # First-Party 

4249 from mcpgateway.common.models import ElicitResult # pylint: disable=import-outside-toplevel 

4250 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel 

4251 

4252 elicitation_service = get_elicitation_service() 

4253 try: 

4254 elicit_result = ElicitResult(**result_data) 

4255 if elicitation_service.complete_elicitation(request_id, elicit_result): 

4256 logger.info(f"Completed elicitation {request_id} from session {session_id}") 

4257 is_elicitation_response = True 

4258 except Exception as e: 

4259 logger.warning(f"Failed to process elicitation response: {e}") 

4260 

4261 # If not an elicitation response, broadcast normally 

4262 if not is_elicitation_response: 

4263 await session_registry.broadcast( 

4264 session_id=session_id, 

4265 message=message, 

4266 ) 

4267 

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

4269 except ValueError as e: 

4270 logger.error(f"Invalid message format: {e}") 

4271 raise HTTPException(status_code=400, detail=str(e)) 

4272 except HTTPException: 

4273 raise 

4274 except Exception as e: 

4275 logger.error(f"Message handling error: {e}") 

4276 raise HTTPException(status_code=500, detail="Failed to process message") 

4277 

4278 

4279@server_router.get("/{server_id}/tools", response_model=List[ToolRead]) 

4280@require_permission("servers.read") 

4281async def server_get_tools( 

4282 request: Request, 

4283 server_id: str, 

4284 include_inactive: bool = False, 

4285 include_metrics: bool = False, 

4286 db: Session = Depends(get_db), 

4287 user=Depends(get_current_user_with_permissions), 

4288) -> List[Dict[str, Any]]: 

4289 """ 

4290 List tools for the server with an option to include inactive tools. 

4291 

4292 This endpoint retrieves a list of tools from the database, optionally including 

4293 those that are inactive. The inactive filter helps administrators manage tools 

4294 that have been deactivated but not deleted from the system. 

4295 

4296 Args: 

4297 request (Request): FastAPI request object. 

4298 server_id (str): ID of the server 

4299 include_inactive (bool): Whether to include inactive tools in the results. 

4300 include_metrics (bool): Whether to include metrics in the tools results. 

4301 db (Session): Database session dependency. 

4302 user (str): Authenticated user dependency. 

4303 

4304 Returns: 

4305 List[ToolRead]: A list of tool records formatted with by_alias=True. 

4306 """ 

4307 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} has listed tools for the server_id: {server_id}") 

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

4309 _req_email, _req_is_admin = user_email, is_admin 

4310 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None 

4311 # Admin bypass - only when token has NO team restrictions (token_teams is None) 

4312 # If token has explicit team scope (even empty [] for public-only), respect it 

4313 if is_admin and token_teams is None: 

4314 user_email = None 

4315 token_teams = None # Admin unrestricted 

4316 elif token_teams is None: 

4317 token_teams = [] # Non-admin without teams = public-only (secure default) 

4318 tools = await tool_service.list_server_tools( 

4319 db, 

4320 server_id=server_id, 

4321 include_inactive=include_inactive, 

4322 include_metrics=include_metrics, 

4323 user_email=user_email, 

4324 token_teams=token_teams, 

4325 requesting_user_email=_req_email, 

4326 requesting_user_is_admin=_req_is_admin, 

4327 requesting_user_team_roles=_req_team_roles, 

4328 ) 

4329 return [tool.model_dump(by_alias=True) for tool in tools] 

4330 

4331 

4332@server_router.get("/{server_id}/resources", response_model=List[ResourceRead]) 

4333@require_permission("servers.read") 

4334async def server_get_resources( 

4335 request: Request, 

4336 server_id: str, 

4337 include_inactive: bool = False, 

4338 include_metrics: bool = False, 

4339 db: Session = Depends(get_db), 

4340 user=Depends(get_current_user_with_permissions), 

4341) -> List[Dict[str, Any]]: 

4342 """ 

4343 List resources for the server with an option to include inactive resources. 

4344 

4345 This endpoint retrieves a list of resources from the database, optionally including 

4346 those that are inactive. The inactive filter is useful for administrators who need 

4347 to view or manage resources that have been deactivated but not deleted. 

4348 

4349 Args: 

4350 request (Request): FastAPI request object. 

4351 server_id (str): ID of the server 

4352 include_inactive (bool): Whether to include inactive resources in the results. 

4353 include_metrics (bool): Whether to include aggregated metrics in the results. 

4354 db (Session): Database session dependency. 

4355 user (str): Authenticated user dependency. 

4356 

4357 Returns: 

4358 List[ResourceRead]: A list of resource records formatted with by_alias=True. 

4359 """ 

4360 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} has listed resources for the server_id: {server_id}") 

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

4362 # Admin bypass - only when token has NO team restrictions (token_teams is None) 

4363 # If token has explicit team scope (even empty [] for public-only), respect it 

4364 if is_admin and token_teams is None: 

4365 user_email = None 

4366 token_teams = None # Admin unrestricted 

4367 elif token_teams is None: 

4368 token_teams = [] # Non-admin without teams = public-only (secure default) 

4369 resources = await resource_service.list_server_resources( 

4370 db, server_id=server_id, include_inactive=include_inactive, include_metrics=include_metrics, user_email=user_email, token_teams=token_teams 

4371 ) 

4372 return [resource.model_dump(by_alias=True) for resource in resources] 

4373 

4374 

4375@server_router.get("/{server_id}/prompts", response_model=List[PromptRead]) 

4376@require_permission("servers.read") 

4377async def server_get_prompts( 

4378 request: Request, 

4379 server_id: str, 

4380 include_inactive: bool = False, 

4381 include_metrics: bool = False, 

4382 db: Session = Depends(get_db), 

4383 user=Depends(get_current_user_with_permissions), 

4384) -> List[Dict[str, Any]]: 

4385 """ 

4386 List prompts for the server with an option to include inactive prompts. 

4387 

4388 This endpoint retrieves a list of prompts from the database, optionally including 

4389 those that are inactive. The inactive filter helps administrators see and manage 

4390 prompts that have been deactivated but not deleted from the system. 

4391 

4392 Args: 

4393 request (Request): FastAPI request object. 

4394 server_id (str): ID of the server 

4395 include_inactive (bool): Whether to include inactive prompts in the results. 

4396 include_metrics (bool): Whether to include aggregated metrics in the results. 

4397 db (Session): Database session dependency. 

4398 user (str): Authenticated user dependency. 

4399 

4400 Returns: 

4401 List[PromptRead]: A list of prompt records formatted with by_alias=True. 

4402 """ 

4403 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} has listed prompts for the server_id: {server_id}") 

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

4405 # Admin bypass - only when token has NO team restrictions (token_teams is None) 

4406 # If token has explicit team scope (even empty [] for public-only), respect it 

4407 if is_admin and token_teams is None: 

4408 user_email = None 

4409 token_teams = None # Admin unrestricted 

4410 elif token_teams is None: 

4411 token_teams = [] # Non-admin without teams = public-only (secure default) 

4412 prompts = await prompt_service.list_server_prompts(db, server_id=server_id, include_inactive=include_inactive, include_metrics=include_metrics, user_email=user_email, token_teams=token_teams) 

4413 return [prompt.model_dump(by_alias=True) for prompt in prompts] 

4414 

4415 

4416################## 

4417# A2A Agent APIs # 

4418################## 

4419@a2a_router.get("", response_model=Union[List[A2AAgentRead], CursorPaginatedA2AAgentsResponse]) 

4420@a2a_router.get("/", response_model=Union[List[A2AAgentRead], CursorPaginatedA2AAgentsResponse]) 

4421@require_permission("a2a.read") 

4422async def list_a2a_agents( 

4423 request: Request, 

4424 include_inactive: bool = False, 

4425 tags: Optional[str] = None, 

4426 team_id: Optional[str] = Query(None, description="Filter by team ID"), 

4427 visibility: Optional[str] = Query(None, description="Filter by visibility (private, team, public)"), 

4428 cursor: Optional[str] = Query(None, description="Cursor for pagination"), 

4429 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"), 

4430 limit: Optional[int] = Query(None, description="Maximum number of agents to return"), 

4431 db: Session = Depends(get_db), 

4432 user=Depends(get_current_user_with_permissions), 

4433) -> Union[List[A2AAgentRead], Dict[str, Any]]: 

4434 """ 

4435 Lists A2A agents user has access to with cursor pagination and team filtering. 

4436 

4437 Args: 

4438 request (Request): The FastAPI request object for team_id retrieval. 

4439 include_inactive (bool): Whether to include inactive agents in the response. 

4440 tags (Optional[str]): Comma-separated list of tags to filter by. 

4441 team_id (Optional[str]): Team ID to filter by. 

4442 visibility (Optional[str]): Visibility level to filter by. 

4443 cursor (Optional[str]): Cursor for pagination. 

4444 include_pagination (bool): Include cursor pagination metadata in response. 

4445 limit (Optional[int]): Maximum number of agents to return. 

4446 db (Session): The database session used to interact with the data store. 

4447 user (str): The authenticated user making the request. 

4448 

4449 Returns: 

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

4451 

4452 Raises: 

4453 HTTPException: If A2A service is not available. 

4454 """ 

4455 # Parse tags parameter if provided 

4456 tags_list = None 

4457 if tags: 

4458 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] 

4459 

4460 if a2a_service is None: 

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

4462 

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

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

4465 

4466 # Admin bypass - only when token has NO team restrictions (token_teams is None) 

4467 # If token has explicit team scope (even for admins), respect it for least-privilege 

4468 if is_admin and token_teams is None: 

4469 user_email = None 

4470 token_teams = None # Admin unrestricted 

4471 elif token_teams is None: 

4472 token_teams = [] # Non-admin without teams = public-only (secure default) 

4473 

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

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

4476 

4477 # Check for team ID mismatch (only applies when both are specified and token has teams) 

4478 if team_id is not None and token_team_id is not None and team_id != token_team_id: 

4479 return ORJSONResponse( 

4480 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

4481 status_code=status.HTTP_403_FORBIDDEN, 

4482 ) 

4483 

4484 # For listing, only narrow by team_id when explicitly requested via query param. 

4485 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping. 

4486 

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

4488 

4489 # Use consolidated agent listing with token-based team filtering 

4490 data, next_cursor = await a2a_service.list_agents( 

4491 db=db, 

4492 cursor=cursor, 

4493 include_inactive=include_inactive, 

4494 tags=tags_list, 

4495 limit=limit, 

4496 user_email=user_email, 

4497 token_teams=token_teams, 

4498 team_id=team_id, 

4499 visibility=visibility, 

4500 ) 

4501 

4502 if include_pagination: 

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

4504 return data 

4505 

4506 

4507@a2a_router.get("/{agent_id}", response_model=A2AAgentRead) 

4508@require_permission("a2a.read") 

4509async def get_a2a_agent( 

4510 agent_id: str, 

4511 request: Request, 

4512 db: Session = Depends(get_db), 

4513 user=Depends(get_current_user_with_permissions), 

4514) -> A2AAgentRead: 

4515 """ 

4516 Retrieves an A2A agent by its ID. 

4517 

4518 Args: 

4519 agent_id (str): The ID of the agent to retrieve. 

4520 request (Request): The FastAPI request object for team_id retrieval. 

4521 db (Session): The database session used to interact with the data store. 

4522 user (str): The authenticated user making the request. 

4523 

4524 Returns: 

4525 A2AAgentRead: The agent object with the specified ID. 

4526 

4527 Raises: 

4528 HTTPException: If the agent is not found or user lacks access. 

4529 """ 

4530 try: 

4531 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested A2A agent with ID {agent_id}") 

4532 if a2a_service is None: 

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

4534 

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

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

4537 

4538 # Admin bypass - only when token has NO team restrictions 

4539 if is_admin and token_teams is None: 

4540 token_teams = None # Admin unrestricted 

4541 elif token_teams is None: 

4542 token_teams = [] # Non-admin without teams = public-only 

4543 

4544 return await a2a_service.get_agent( 

4545 db, 

4546 agent_id, 

4547 user_email=user_email, 

4548 token_teams=token_teams, 

4549 ) 

4550 except A2AAgentNotFoundError as e: 

4551 raise HTTPException(status_code=404, detail=str(e)) 

4552 

4553 

4554@a2a_router.post("", response_model=A2AAgentRead, status_code=201) 

4555@a2a_router.post("/", response_model=A2AAgentRead, status_code=201) 

4556@require_permission("a2a.create") 

4557async def create_a2a_agent( 

4558 agent: A2AAgentCreate, 

4559 request: Request, 

4560 team_id: Optional[str] = Body(None, description="Team ID to assign agent to"), 

4561 visibility: Optional[str] = Body("public", description="Agent visibility: private, team, public"), 

4562 db: Session = Depends(get_db), 

4563 user=Depends(get_current_user_with_permissions), 

4564) -> A2AAgentRead: 

4565 """ 

4566 Creates a new A2A agent. 

4567 

4568 Args: 

4569 agent (A2AAgentCreate): The data for the new agent. 

4570 request (Request): The FastAPI request object for metadata extraction. 

4571 team_id (Optional[str]): Team ID to assign the agent to. 

4572 visibility (str): Agent visibility level (private, team, public). 

4573 db (Session): The database session used to interact with the data store. 

4574 user (str): The authenticated user making the request. 

4575 

4576 Returns: 

4577 A2AAgentRead: The created agent object. 

4578 

4579 Raises: 

4580 HTTPException: If there is a conflict with the agent name or other errors. 

4581 """ 

4582 try: 

4583 # Extract metadata from request 

4584 metadata = MetadataCapture.extract_creation_metadata(request, user) 

4585 

4586 # Get user email and handle team assignment 

4587 user_email = get_user_email(user) 

4588 

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

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

4591 

4592 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources 

4593 is_public_only_token = token_teams is not None and len(token_teams) == 0 

4594 if is_public_only_token and visibility in ("team", "private"): 

4595 return ORJSONResponse( 

4596 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."}, 

4597 status_code=status.HTTP_403_FORBIDDEN, 

4598 ) 

4599 

4600 # Check for team ID mismatch (only for non-public-only tokens) 

4601 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: 

4602 return ORJSONResponse( 

4603 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

4604 status_code=status.HTTP_403_FORBIDDEN, 

4605 ) 

4606 

4607 # Determine final team ID (public-only tokens get no team) 

4608 if is_public_only_token: 

4609 team_id = None 

4610 else: 

4611 team_id = team_id or token_team_id 

4612 

4613 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new A2A agent for team {team_id}") 

4614 if a2a_service is None: 

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

4616 return await a2a_service.register_agent( 

4617 db, 

4618 agent, 

4619 created_by=metadata["created_by"], 

4620 created_from_ip=metadata["created_from_ip"], 

4621 created_via=metadata["created_via"], 

4622 created_user_agent=metadata["created_user_agent"], 

4623 import_batch_id=metadata["import_batch_id"], 

4624 federation_source=metadata["federation_source"], 

4625 team_id=team_id, 

4626 owner_email=user_email, 

4627 visibility=visibility, 

4628 ) 

4629 except A2AAgentNameConflictError as e: 

4630 raise HTTPException(status_code=409, detail=str(e)) 

4631 except A2AAgentError as e: 

4632 raise HTTPException(status_code=400, detail=str(e)) 

4633 except ValidationError as e: 

4634 logger.error(f"Validation error while creating A2A agent: {e}") 

4635 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e)) 

4636 except IntegrityError as e: 

4637 logger.error(f"Integrity error while creating A2A agent: {e}") 

4638 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e)) 

4639 

4640 

4641@a2a_router.put("/{agent_id}", response_model=A2AAgentRead) 

4642@require_permission("a2a.update") 

4643async def update_a2a_agent( 

4644 agent_id: str, 

4645 agent: A2AAgentUpdate, 

4646 request: Request, 

4647 db: Session = Depends(get_db), 

4648 user=Depends(get_current_user_with_permissions), 

4649) -> A2AAgentRead: 

4650 """ 

4651 Updates the information of an existing A2A agent. 

4652 

4653 Args: 

4654 agent_id (str): The ID of the agent to update. 

4655 agent (A2AAgentUpdate): The updated agent data. 

4656 request (Request): The FastAPI request object for metadata extraction. 

4657 db (Session): The database session used to interact with the data store. 

4658 user (str): The authenticated user making the request. 

4659 

4660 Returns: 

4661 A2AAgentRead: The updated agent object. 

4662 

4663 Raises: 

4664 HTTPException: If the agent is not found, there is a name conflict, or other errors. 

4665 """ 

4666 try: 

4667 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is updating A2A agent with ID {agent_id}") 

4668 # Extract modification metadata 

4669 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service 

4670 

4671 if a2a_service is None: 

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

4673 user_email = user.get("email") if isinstance(user, dict) else str(user) 

4674 return await a2a_service.update_agent( 

4675 db, 

4676 agent_id, 

4677 agent, 

4678 modified_by=mod_metadata["modified_by"], 

4679 modified_from_ip=mod_metadata["modified_from_ip"], 

4680 modified_via=mod_metadata["modified_via"], 

4681 modified_user_agent=mod_metadata["modified_user_agent"], 

4682 user_email=user_email, 

4683 ) 

4684 except PermissionError as e: 

4685 raise HTTPException(status_code=403, detail=str(e)) 

4686 except A2AAgentNotFoundError as e: 

4687 raise HTTPException(status_code=404, detail=str(e)) 

4688 except A2AAgentNameConflictError as e: 

4689 raise HTTPException(status_code=409, detail=str(e)) 

4690 except A2AAgentError as e: 

4691 raise HTTPException(status_code=400, detail=str(e)) 

4692 except ValidationError as e: 

4693 logger.error(f"Validation error while updating A2A agent {agent_id}: {e}") 

4694 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e)) 

4695 except IntegrityError as e: 

4696 logger.error(f"Integrity error while updating A2A agent {agent_id}: {e}") 

4697 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e)) 

4698 

4699 

4700@a2a_router.post("/{agent_id}/state", response_model=A2AAgentRead) 

4701@require_permission("a2a.update") 

4702async def set_a2a_agent_state( 

4703 agent_id: str, 

4704 activate: bool = True, 

4705 db: Session = Depends(get_db), 

4706 user=Depends(get_current_user_with_permissions), 

4707) -> A2AAgentRead: 

4708 """ 

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

4710 

4711 Args: 

4712 agent_id (str): The ID of the agent to update. 

4713 activate (bool): Whether to activate or deactivate the agent. 

4714 db (Session): The database session used to interact with the data store. 

4715 user (str): The authenticated user making the request. 

4716 

4717 Returns: 

4718 A2AAgentRead: The agent object after the status change. 

4719 

4720 Raises: 

4721 HTTPException: If the agent is not found or there is an error. 

4722 """ 

4723 try: 

4724 user_email = user.get("email") if isinstance(user, dict) else str(user) 

4725 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is toggling A2A agent with ID {agent_id} to {'active' if activate else 'inactive'}") 

4726 if a2a_service is None: 

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

4728 return await a2a_service.set_agent_state(db, agent_id, activate, user_email=user_email) 

4729 except PermissionError as e: 

4730 raise HTTPException(status_code=403, detail=str(e)) 

4731 except A2AAgentNotFoundError as e: 

4732 raise HTTPException(status_code=404, detail=str(e)) 

4733 except A2AAgentError as e: 

4734 raise HTTPException(status_code=400, detail=str(e)) 

4735 

4736 

4737@a2a_router.post("/{agent_id}/toggle", response_model=A2AAgentRead, deprecated=True) 

4738@require_permission("a2a.update") 

4739async def toggle_a2a_agent_status( 

4740 agent_id: str, 

4741 activate: bool = True, 

4742 db: Session = Depends(get_db), 

4743 user=Depends(get_current_user_with_permissions), 

4744) -> A2AAgentRead: 

4745 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release. 

4746 

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

4748 

4749 Args: 

4750 agent_id: The A2A agent ID. 

4751 activate: Whether to activate (True) or deactivate (False) the agent. 

4752 db: Database session. 

4753 user: Authenticated user context. 

4754 

4755 Returns: 

4756 The updated A2A agent. 

4757 """ 

4758 

4759 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2) 

4760 return await set_a2a_agent_state(agent_id, activate, db, user) 

4761 

4762 

4763@a2a_router.delete("/{agent_id}", response_model=Dict[str, str]) 

4764@require_permission("a2a.delete") 

4765async def delete_a2a_agent( 

4766 agent_id: str, 

4767 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this agent"), 

4768 db: Session = Depends(get_db), 

4769 user=Depends(get_current_user_with_permissions), 

4770) -> Dict[str, str]: 

4771 """ 

4772 Deletes an A2A agent by its ID. 

4773 

4774 Args: 

4775 agent_id (str): The ID of the agent to delete. 

4776 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this agent. 

4777 db (Session): The database session used to interact with the data store. 

4778 user (str): The authenticated user making the request. 

4779 

4780 Returns: 

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

4782 

4783 Raises: 

4784 HTTPException: If the agent is not found or there is an error. 

4785 """ 

4786 try: 

4787 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is deleting A2A agent with ID {agent_id}") 

4788 if a2a_service is None: 

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

4790 user_email = user.get("email") if isinstance(user, dict) else str(user) 

4791 await a2a_service.delete_agent(db, agent_id, user_email=user_email, purge_metrics=purge_metrics) 

4792 return { 

4793 "status": "success", 

4794 "message": f"A2A Agent {agent_id} deleted successfully", 

4795 } 

4796 except PermissionError as e: 

4797 raise HTTPException(status_code=403, detail=str(e)) 

4798 except A2AAgentNotFoundError as e: 

4799 raise HTTPException(status_code=404, detail=str(e)) 

4800 except A2AAgentError as e: 

4801 raise HTTPException(status_code=400, detail=str(e)) 

4802 

4803 

4804@a2a_router.post("/{agent_name}/invoke", response_model=Dict[str, Any]) 

4805@require_permission("a2a.invoke") 

4806async def invoke_a2a_agent( 

4807 agent_name: str, 

4808 request: Request, 

4809 parameters: Dict[str, Any] = Body(default_factory=dict), 

4810 interaction_type: str = Body(default="query"), 

4811 db: Session = Depends(get_db), 

4812 user=Depends(get_current_user_with_permissions), 

4813) -> Dict[str, Any]: 

4814 """ 

4815 Invokes an A2A agent with the specified parameters. 

4816 

4817 Args: 

4818 agent_name (str): The name of the agent to invoke. 

4819 request (Request): The FastAPI request object for team_id retrieval. 

4820 parameters (Dict[str, Any]): Parameters for the agent interaction. 

4821 interaction_type (str): Type of interaction (query, execute, etc.). 

4822 db (Session): The database session used to interact with the data store. 

4823 user (str): The authenticated user making the request. 

4824 

4825 Returns: 

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

4827 

4828 Raises: 

4829 HTTPException: If the agent is not found, user lacks access, or there is an error during invocation. 

4830 """ 

4831 try: 

4832 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is invoking A2A agent '{agent_name}' with type '{interaction_type}'") 

4833 if a2a_service is None: 

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

4835 

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

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

4838 

4839 # Admin bypass - only when token has NO team restrictions 

4840 if is_admin and token_teams is None: 

4841 token_teams = None # Admin unrestricted 

4842 elif token_teams is None: 

4843 token_teams = [] # Non-admin without teams = public-only 

4844 

4845 user_id = None 

4846 if isinstance(user, dict): 

4847 user_id = str(user.get("id") or user.get("sub") or user_email) 

4848 else: 

4849 user_id = str(user) 

4850 

4851 return await a2a_service.invoke_agent( 

4852 db, 

4853 agent_name, 

4854 parameters, 

4855 interaction_type, 

4856 user_id=user_id, 

4857 user_email=user_email, 

4858 token_teams=token_teams, 

4859 ) 

4860 except A2AAgentNotFoundError as e: 

4861 raise HTTPException(status_code=404, detail=str(e)) 

4862 except A2AAgentError as e: 

4863 raise HTTPException(status_code=400, detail=str(e)) 

4864 

4865 

4866############# 

4867# Tool APIs # 

4868############# 

4869@tool_router.get("", response_model=Union[List[ToolRead], CursorPaginatedToolsResponse]) 

4870@tool_router.get("/", response_model=Union[List[ToolRead], CursorPaginatedToolsResponse]) 

4871@require_permission("tools.read") 

4872async def list_tools( 

4873 request: Request, 

4874 cursor: Optional[str] = None, 

4875 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"), 

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

4877 include_inactive: bool = False, 

4878 tags: Optional[str] = None, 

4879 team_id: Optional[str] = Query(None, description="Filter by team ID"), 

4880 visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, public"), 

4881 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID"), 

4882 db: Session = Depends(get_db), 

4883 apijsonpath: Optional[str] = Query(None, description="Optional JSONPath modifier as JSON string"), 

4884 user=Depends(get_current_user_with_permissions), 

4885) -> ToolsResponse: 

4886 """List all registered tools with team-based filtering and pagination support. 

4887 

4888 Args: 

4889 request (Request): The FastAPI request object for team_id retrieval 

4890 cursor: Pagination cursor for fetching the next set of results 

4891 include_pagination: Whether to include cursor pagination metadata in the response 

4892 limit: Maximum number of tools to return. Use 0 for all tools (no limit). 

4893 If not specified, uses pagination_default_page_size (default: 50). 

4894 include_inactive: Whether to include inactive tools in the results 

4895 tags: Comma-separated list of tags to filter by (e.g., "api,data") 

4896 team_id: Optional team ID to filter tools by specific team 

4897 visibility: Optional visibility filter (private, team, public) 

4898 gateway_id: Optional gateway ID to filter tools by specific gateway 

4899 db: Database session 

4900 apijsonpath: Optional JSON-Path modifier supplied as URL-encoded query parameter. 

4901 Example: ?apijsonpath=%7B%22jsonpath%22%3A%22%24.name%22%7D 

4902 (decoded: {"jsonpath":"$.name"}) 

4903 Use to filter or transform the response via JSONPath expressions. 

4904 user: Authenticated user with permissions 

4905 

4906 Returns: 

4907 List of tools or modified result based on jsonpath 

4908 

4909 Raises: 

4910 HTTPException: If JSONPath modifier fails to process the tools list 

4911 """ 

4912 

4913 # Validate apijsonpath early — fail fast before the database query 

4914 parsed_apijsonpath = _parse_apijsonpath(apijsonpath) 

4915 

4916 # Parse tags parameter if provided 

4917 tags_list = None 

4918 if tags: 

4919 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] 

4920 

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

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

4923 # Capture original identity for header masking (before admin bypass modifies user_email) 

4924 _req_email, _req_is_admin = user_email, is_admin 

4925 

4926 # Admin bypass - only when token has NO team restrictions (token_teams is None) 

4927 # If token has explicit team scope (even for admins), respect it for least-privilege 

4928 if is_admin and token_teams is None: 

4929 user_email = None 

4930 token_teams = None # Admin unrestricted 

4931 elif token_teams is None: 

4932 token_teams = [] # Non-admin without teams = public-only (secure default) 

4933 

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

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

4936 

4937 # Check for team ID mismatch (only applies when both are specified and token has teams) 

4938 if team_id is not None and token_team_id is not None and team_id != token_team_id: 

4939 return ORJSONResponse( 

4940 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

4941 status_code=status.HTTP_403_FORBIDDEN, 

4942 ) 

4943 

4944 # For listing, only narrow by team_id when explicitly requested via query param. 

4945 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping. 

4946 

4947 # Use unified list_tools() with token-based team filtering 

4948 # Always apply visibility filtering based on token scope 

4949 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None 

4950 data, next_cursor = await tool_service.list_tools( 

4951 db=db, 

4952 cursor=cursor, 

4953 include_inactive=include_inactive, 

4954 tags=tags_list, 

4955 gateway_id=gateway_id, 

4956 limit=limit, 

4957 user_email=user_email, 

4958 team_id=team_id, 

4959 visibility=visibility, 

4960 token_teams=token_teams, 

4961 requesting_user_email=_req_email, 

4962 requesting_user_is_admin=_req_is_admin, 

4963 requesting_user_team_roles=_req_team_roles, 

4964 ) 

4965 # Release transaction before response serialization 

4966 db.commit() 

4967 db.close() 

4968 

4969 if parsed_apijsonpath is None: 

4970 if include_pagination: 

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

4972 return data 

4973 

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

4975 try: 

4976 result = jsonpath_modifier(tools_dict_list, parsed_apijsonpath.jsonpath, parsed_apijsonpath.mapping) 

4977 

4978 # If pagination is requested, wrap the result with cursor metadata. 

4979 # Use "nextCursor" to match the CursorPaginatedToolsResponse alias contract. 

4980 if include_pagination: 

4981 paginated_result = {"tools": result, "nextCursor": next_cursor} 

4982 return ORJSONResponse(content=paginated_result) 

4983 

4984 # Return ORJSONResponse to bypass FastAPI's response_model validation 

4985 return ORJSONResponse(content=result) 

4986 except HTTPException: 

4987 # Re-raise HTTPException as-is (preserves 400 from apijsonpath parsing) 

4988 raise 

4989 except Exception: 

4990 logger.exception("JSONPath modifier failed while processing tools list") 

4991 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="JSONPath modifier error") 

4992 

4993 

4994@tool_router.post("", response_model=ToolRead) 

4995@tool_router.post("/", response_model=ToolRead) 

4996@require_permission("tools.create") 

4997async def create_tool( 

4998 tool: ToolCreate, 

4999 request: Request, 

5000 team_id: Optional[str] = Body(None, description="Team ID to assign tool to"), 

5001 db: Session = Depends(get_db), 

5002 user=Depends(get_current_user_with_permissions), 

5003) -> ToolRead: 

5004 """ 

5005 Creates a new tool in the system with team assignment support. 

5006 

5007 Args: 

5008 tool (ToolCreate): The data needed to create the tool. 

5009 request (Request): The FastAPI request object for metadata extraction. 

5010 team_id (Optional[str]): Team ID to assign the tool to. 

5011 db (Session): The database session dependency. 

5012 user: The authenticated user making the request. 

5013 

5014 Returns: 

5015 ToolRead: The created tool data. 

5016 

5017 Raises: 

5018 HTTPException: If the tool name already exists or other validation errors occur. 

5019 """ 

5020 try: 

5021 # Extract metadata from request 

5022 metadata = MetadataCapture.extract_creation_metadata(request, user) 

5023 

5024 # Get user email and handle team assignment 

5025 user_email = get_user_email(user) 

5026 

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

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

5029 

5030 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources 

5031 is_public_only_token = token_teams is not None and len(token_teams) == 0 

5032 if is_public_only_token and tool.visibility in ("team", "private"): 

5033 return ORJSONResponse( 

5034 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."}, 

5035 status_code=status.HTTP_403_FORBIDDEN, 

5036 ) 

5037 

5038 # Check for team ID mismatch (only for non-public-only tokens) 

5039 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: 

5040 return ORJSONResponse( 

5041 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

5042 status_code=status.HTTP_403_FORBIDDEN, 

5043 ) 

5044 

5045 # Determine final team ID (public-only tokens get no team) 

5046 if is_public_only_token: 

5047 team_id = None 

5048 else: 

5049 team_id = team_id or token_team_id 

5050 

5051 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new tool for team {team_id}") 

5052 result = await tool_service.register_tool( 

5053 db, 

5054 tool, 

5055 created_by=metadata["created_by"], 

5056 created_from_ip=metadata["created_from_ip"], 

5057 created_via=metadata["created_via"], 

5058 created_user_agent=metadata["created_user_agent"], 

5059 import_batch_id=metadata["import_batch_id"], 

5060 federation_source=metadata["federation_source"], 

5061 team_id=team_id, 

5062 owner_email=user_email, 

5063 visibility=tool.visibility, 

5064 ) 

5065 db.commit() 

5066 db.close() 

5067 return result 

5068 except Exception as ex: 

5069 logger.error(f"Error while creating tool: {ex}") 

5070 if isinstance(ex, ToolNameConflictError): 

5071 if not ex.enabled and ex.tool_id: 

5072 raise HTTPException( 

5073 status_code=status.HTTP_409_CONFLICT, 

5074 detail=f"Tool name already exists but is inactive. Consider activating it with ID: {ex.tool_id}", 

5075 ) 

5076 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(ex)) 

5077 if isinstance(ex, (ValidationError, ValueError)): 

5078 logger.error(f"Validation error while creating tool: {ex}") 

5079 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(ex)) 

5080 if isinstance(ex, IntegrityError): 

5081 logger.error(f"Integrity error while creating tool: {ex}") 

5082 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(ex)) 

5083 if isinstance(ex, ToolError): 

5084 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex)) 

5085 logger.error(f"Unexpected error while creating tool: {ex}") 

5086 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while creating the tool") 

5087 

5088 

5089@tool_router.get("/{tool_id}", response_model=Union[ToolRead, Dict]) 

5090@require_permission("tools.read") 

5091async def get_tool( 

5092 tool_id: str, 

5093 request: Request, 

5094 db: Session = Depends(get_db), 

5095 user=Depends(get_current_user_with_permissions), 

5096 apijsonpath: Optional[str] = Query(None, description="Optional JSONPath modifier as JSON string"), 

5097) -> ToolResponse: 

5098 """ 

5099 Retrieve a tool by ID, optionally applying a JSONPath post-filter. 

5100 

5101 Args: 

5102 tool_id: The numeric ID of the tool. 

5103 request: The incoming HTTP request. 

5104 db: Active SQLAlchemy session (dependency). 

5105 user: Authenticated username (dependency). 

5106 apijsonpath: Optional JSON-Path modifier supplied as URL-encoded query parameter. 

5107 Example: ?apijsonpath=%7B%22jsonpath%22%3A%22%24.name%22%7D 

5108 (decoded: {"jsonpath":"$.name","mapping":null}) 

5109 Use to filter or transform the response via JSONPath expressions. 

5110 

5111 Returns: 

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

5113 a JSONPath filter/mapping was supplied, **or** an ``ORJSONResponse`` 

5114 when JSONPath modifiers are applied. 

5115 

5116 Raises: 

5117 HTTPException: If the tool does not exist or the transformation fails. 

5118 """ 

5119 try: 

5120 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is retrieving tool with ID {tool_id}") 

5121 _req_email, _, _req_is_admin = _get_rpc_filter_context(request, user) 

5122 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None 

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

5124 _enforce_scoped_resource_access(request, db, user, f"/tools/{tool_id}") 

5125 

5126 # Parse apijsonpath parameter (handles both string and JsonPathModifier inputs) 

5127 parsed_apijsonpath = _parse_apijsonpath(apijsonpath) 

5128 if parsed_apijsonpath is None: 

5129 return data 

5130 

5131 data_dict = data.to_dict(use_alias=True) 

5132 try: 

5133 result = jsonpath_modifier(data_dict, parsed_apijsonpath.jsonpath, parsed_apijsonpath.mapping) 

5134 # Return ORJSONResponse to bypass FastAPI's response_model validation 

5135 return ORJSONResponse(content=result) 

5136 except HTTPException: 

5137 # Re-raise HTTPException as-is (preserves 400 from apijsonpath parsing) 

5138 raise 

5139 except Exception: 

5140 logger.exception("JSONPath modifier failed while processing single tool") 

5141 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="JSONPath modifier error") 

5142 except HTTPException: 

5143 raise 

5144 except Exception as e: 

5145 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

5146 

5147 

5148@tool_router.put("/{tool_id}", response_model=ToolRead) 

5149@require_permission("tools.update") 

5150async def update_tool( 

5151 tool_id: str, 

5152 tool: ToolUpdate, 

5153 request: Request, 

5154 db: Session = Depends(get_db), 

5155 user=Depends(get_current_user_with_permissions), 

5156) -> ToolRead: 

5157 """ 

5158 Updates an existing tool with new data. 

5159 

5160 Args: 

5161 tool_id (str): The ID of the tool to update. 

5162 tool (ToolUpdate): The updated tool information. 

5163 request (Request): The FastAPI request object for metadata extraction. 

5164 db (Session): The database session dependency. 

5165 user (str): The authenticated user making the request. 

5166 

5167 Returns: 

5168 ToolRead: The updated tool data. 

5169 

5170 Raises: 

5171 HTTPException: If an error occurs during the update. 

5172 """ 

5173 try: 

5174 # Get current tool to extract current version 

5175 current_tool = db.get(DbTool, tool_id) 

5176 current_version = getattr(current_tool, "version", 0) if current_tool else 0 

5177 

5178 # Extract modification metadata 

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

5180 

5181 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is updating tool with ID {tool_id}") 

5182 user_email = user.get("email") if isinstance(user, dict) else str(user) 

5183 result = await tool_service.update_tool( 

5184 db, 

5185 tool_id, 

5186 tool, 

5187 modified_by=mod_metadata["modified_by"], 

5188 modified_from_ip=mod_metadata["modified_from_ip"], 

5189 modified_via=mod_metadata["modified_via"], 

5190 modified_user_agent=mod_metadata["modified_user_agent"], 

5191 user_email=user_email, 

5192 ) 

5193 db.commit() 

5194 db.close() 

5195 return result 

5196 except Exception as ex: 

5197 if isinstance(ex, PermissionError): 

5198 raise HTTPException(status_code=403, detail=str(ex)) 

5199 if isinstance(ex, ToolNotFoundError): 

5200 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ex)) 

5201 if isinstance(ex, ValidationError): 

5202 logger.error(f"Validation error while updating tool: {ex}") 

5203 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(ex)) 

5204 if isinstance(ex, IntegrityError): 

5205 logger.error(f"Integrity error while updating tool: {ex}") 

5206 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(ex)) 

5207 if isinstance(ex, ToolError): 

5208 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex)) 

5209 logger.error(f"Unexpected error while updating tool: {ex}") 

5210 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the tool") 

5211 

5212 

5213@tool_router.delete("/{tool_id}") 

5214@require_permission("tools.delete") 

5215async def delete_tool( 

5216 tool_id: str, 

5217 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this tool"), 

5218 db: Session = Depends(get_db), 

5219 user=Depends(get_current_user_with_permissions), 

5220) -> Dict[str, str]: 

5221 """ 

5222 Permanently deletes a tool by ID. 

5223 

5224 Args: 

5225 tool_id (str): The ID of the tool to delete. 

5226 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this tool. 

5227 db (Session): The database session dependency. 

5228 user (str): The authenticated user making the request. 

5229 

5230 Returns: 

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

5232 

5233 Raises: 

5234 HTTPException: If an error occurs during deletion. 

5235 """ 

5236 try: 

5237 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is deleting tool with ID {tool_id}") 

5238 user_email = user.get("email") if isinstance(user, dict) else str(user) 

5239 await tool_service.delete_tool(db, tool_id, user_email=user_email, purge_metrics=purge_metrics) 

5240 db.commit() 

5241 db.close() 

5242 return {"status": "success", "message": f"Tool {tool_id} permanently deleted"} 

5243 except PermissionError as e: 

5244 raise HTTPException(status_code=403, detail=str(e)) 

5245 except ToolNotFoundError as e: 

5246 raise HTTPException(status_code=404, detail=str(e)) 

5247 except Exception as e: 

5248 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

5249 

5250 

5251@tool_router.post("/{tool_id}/state") 

5252@require_permission("tools.update") 

5253async def set_tool_state( 

5254 tool_id: str, 

5255 activate: bool = True, 

5256 db: Session = Depends(get_db), 

5257 user=Depends(get_current_user_with_permissions), 

5258) -> Dict[str, Any]: 

5259 """ 

5260 Activates or deactivates a tool. 

5261 

5262 Args: 

5263 tool_id (str): The ID of the tool to update. 

5264 activate (bool): Whether to activate (`True`) or deactivate (`False`) the tool. 

5265 db (Session): The database session dependency. 

5266 user (str): The authenticated user making the request. 

5267 

5268 Returns: 

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

5270 

5271 Raises: 

5272 HTTPException: If an error occurs during state change. 

5273 """ 

5274 try: 

5275 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is setting tool state for ID {tool_id} to {'active' if activate else 'inactive'}") 

5276 user_email = user.get("email") if isinstance(user, dict) else str(user) 

5277 tool = await tool_service.set_tool_state(db, tool_id, activate, reachable=activate, user_email=user_email) 

5278 return { 

5279 "status": "success", 

5280 "message": f"Tool {tool_id} {'activated' if activate else 'deactivated'}", 

5281 "tool": tool.model_dump(), 

5282 } 

5283 except PermissionError as e: 

5284 raise HTTPException(status_code=403, detail=str(e)) 

5285 except ToolNotFoundError as e: 

5286 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

5287 except ToolLockConflictError as e: 

5288 raise HTTPException(status_code=409, detail=str(e)) 

5289 except Exception as e: 

5290 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

5291 

5292 

5293@tool_router.post("/{tool_id}/toggle", deprecated=True) 

5294@require_permission("tools.update") 

5295async def toggle_tool_status( 

5296 tool_id: str, 

5297 activate: bool = True, 

5298 db: Session = Depends(get_db), 

5299 user=Depends(get_current_user_with_permissions), 

5300) -> Dict[str, Any]: 

5301 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release. 

5302 

5303 Activates or deactivates a tool. 

5304 

5305 Args: 

5306 tool_id: The tool ID. 

5307 activate: Whether to activate (True) or deactivate (False) the tool. 

5308 db: Database session. 

5309 user: Authenticated user context. 

5310 

5311 Returns: 

5312 Status message with tool state. 

5313 """ 

5314 

5315 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2) 

5316 return await set_tool_state(tool_id, activate, db, user) 

5317 

5318 

5319################# 

5320# Resource APIs # 

5321################# 

5322# --- Resource templates endpoint - MUST come before variable paths --- 

5323@resource_router.get("/templates/list", response_model=ListResourceTemplatesResult) 

5324@require_permission("resources.read") 

5325async def list_resource_templates( 

5326 request: Request, 

5327 db: Session = Depends(get_db), 

5328 include_inactive: bool = False, 

5329 tags: Optional[str] = None, 

5330 visibility: Optional[str] = None, 

5331 user=Depends(get_current_user_with_permissions), 

5332) -> ListResourceTemplatesResult: 

5333 """ 

5334 List all available resource templates. 

5335 

5336 Args: 

5337 request (Request): The FastAPI request object for team_id retrieval. 

5338 db (Session): Database session. 

5339 user (str): Authenticated user. 

5340 include_inactive (bool): Whether to include inactive resources. 

5341 tags (Optional[str]): Comma-separated list of tags to filter by. 

5342 visibility (Optional[str]): Filter by visibility (private, team, public). 

5343 

5344 Returns: 

5345 ListResourceTemplatesResult: A paginated list of resource templates. 

5346 """ 

5347 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested resource templates") 

5348 

5349 # Parse tags parameter if provided 

5350 tags_list = None 

5351 if tags: 

5352 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] 

5353 

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

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

5356 

5357 # Admin bypass - only when token has NO team restrictions 

5358 if is_admin and token_teams is None: 

5359 token_teams = None # Admin unrestricted 

5360 elif token_teams is None: 

5361 token_teams = [] # Non-admin without teams = public-only 

5362 

5363 resource_templates = await resource_service.list_resource_templates( 

5364 db, 

5365 user_email=user_email, 

5366 token_teams=token_teams, 

5367 include_inactive=include_inactive, 

5368 tags=tags_list, 

5369 visibility=visibility, 

5370 ) 

5371 # For simplicity, we're not implementing real pagination here 

5372 return ListResourceTemplatesResult(_meta={}, resource_templates=resource_templates, next_cursor=None) # No pagination for now 

5373 

5374 

5375@resource_router.post("/{resource_id}/state") 

5376@require_permission("resources.update") 

5377async def set_resource_state( 

5378 resource_id: str, 

5379 activate: bool = True, 

5380 db: Session = Depends(get_db), 

5381 user=Depends(get_current_user_with_permissions), 

5382) -> Dict[str, Any]: 

5383 """ 

5384 Activate or deactivate a resource by its ID. 

5385 

5386 Args: 

5387 resource_id (str): The ID of the resource. 

5388 activate (bool): True to activate, False to deactivate. 

5389 db (Session): Database session. 

5390 user (str): Authenticated user. 

5391 

5392 Returns: 

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

5394 

5395 Raises: 

5396 HTTPException: If toggling fails. 

5397 """ 

5398 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is toggling resource with ID {resource_id} to {'active' if activate else 'inactive'}") 

5399 try: 

5400 user_email = user.get("email") if isinstance(user, dict) else str(user) 

5401 resource = await resource_service.set_resource_state(db, resource_id, activate, user_email=user_email) 

5402 return { 

5403 "status": "success", 

5404 "message": f"Resource {resource_id} {'activated' if activate else 'deactivated'}", 

5405 "resource": resource.model_dump(), 

5406 } 

5407 except PermissionError as e: 

5408 raise HTTPException(status_code=403, detail=str(e)) 

5409 except ResourceNotFoundError as e: 

5410 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

5411 except ResourceLockConflictError as e: 

5412 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) 

5413 except Exception as e: 

5414 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

5415 

5416 

5417@resource_router.post("/{resource_id}/toggle", deprecated=True) 

5418@require_permission("resources.update") 

5419async def toggle_resource_status( 

5420 resource_id: str, 

5421 activate: bool = True, 

5422 db: Session = Depends(get_db), 

5423 user=Depends(get_current_user_with_permissions), 

5424) -> Dict[str, Any]: 

5425 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release. 

5426 

5427 Activate or deactivate a resource by its ID. 

5428 

5429 Args: 

5430 resource_id: The resource ID. 

5431 activate: Whether to activate (True) or deactivate (False) the resource. 

5432 db: Database session. 

5433 user: Authenticated user context. 

5434 

5435 Returns: 

5436 Status message with resource state. 

5437 """ 

5438 

5439 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2) 

5440 return await set_resource_state(resource_id, activate, db, user) 

5441 

5442 

5443@resource_router.get("", response_model=Union[List[ResourceRead], CursorPaginatedResourcesResponse]) 

5444@resource_router.get("/", response_model=Union[List[ResourceRead], CursorPaginatedResourcesResponse]) 

5445@require_permission("resources.read") 

5446async def list_resources( 

5447 request: Request, 

5448 cursor: Optional[str] = Query(None, description="Cursor for pagination"), 

5449 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"), 

5450 limit: Optional[int] = Query(None, ge=0, description="Maximum number of resources to return"), 

5451 include_inactive: bool = False, 

5452 tags: Optional[str] = None, 

5453 team_id: Optional[str] = None, 

5454 visibility: Optional[str] = None, 

5455 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID"), 

5456 db: Session = Depends(get_db), 

5457 user=Depends(get_current_user_with_permissions), 

5458) -> Union[List[Dict[str, Any]], Dict[str, Any]]: 

5459 """ 

5460 Retrieve a list of resources accessible to the user, with team filtering and cursor pagination support. 

5461 

5462 Args: 

5463 request (Request): The FastAPI request object for team_id retrieval 

5464 cursor (Optional[str]): Cursor for pagination. 

5465 include_pagination (bool): Include cursor pagination metadata in response. 

5466 limit (Optional[int]): Maximum number of resources to return. 

5467 include_inactive (bool): Whether to include inactive resources. 

5468 tags (Optional[str]): Comma-separated list of tags to filter by. 

5469 team_id (Optional[str]): Filter by specific team ID. 

5470 visibility (Optional[str]): Filter by visibility (private, team, public). 

5471 gateway_id (Optional[str]): Filter by gateway ID. Use 'null' for resources without a gateway. 

5472 db (Session): Database session. 

5473 user (str): Authenticated user. 

5474 

5475 Returns: 

5476 Union[List[ResourceRead], Dict[str, Any]]: List of resources or paginated response with nextCursor. 

5477 """ 

5478 # Parse tags parameter if provided 

5479 tags_list = None 

5480 if tags: 

5481 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] 

5482 

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

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

5485 

5486 # Admin bypass - only when token has NO team restrictions (token_teams is None) 

5487 # If token has explicit team scope (even for admins), respect it for least-privilege 

5488 if is_admin and token_teams is None: 

5489 user_email = None 

5490 token_teams = None # Admin unrestricted 

5491 elif token_teams is None: 

5492 token_teams = [] # Non-admin without teams = public-only (secure default) 

5493 

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

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

5496 

5497 # Check for team ID mismatch (only applies when both are specified and token has teams) 

5498 if team_id is not None and token_team_id is not None and 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 # For listing, only narrow by team_id when explicitly requested via query param. 

5505 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping. 

5506 

5507 # Use unified list_resources() with token-based team filtering 

5508 # Always apply visibility filtering based on token scope 

5509 logger.debug( 

5510 f"User {SecurityValidator.sanitize_log_message(user_email)} requested resource list with cursor {cursor}, include_inactive={include_inactive}, tags={tags_list}, team_id={team_id}, visibility={visibility}, gateway_id={gateway_id}" 

5511 ) 

5512 data, next_cursor = await resource_service.list_resources( 

5513 db=db, 

5514 cursor=cursor, 

5515 limit=limit, 

5516 include_inactive=include_inactive, 

5517 tags=tags_list, 

5518 gateway_id=gateway_id, 

5519 user_email=user_email, 

5520 team_id=team_id, 

5521 visibility=visibility, 

5522 token_teams=token_teams, 

5523 ) 

5524 # Release transaction before response serialization 

5525 db.commit() 

5526 db.close() 

5527 

5528 if include_pagination: 

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

5530 return data 

5531 

5532 

5533@resource_router.post("", response_model=ResourceRead) 

5534@resource_router.post("/", response_model=ResourceRead) 

5535@require_permission("resources.create") 

5536async def create_resource( 

5537 resource: ResourceCreate, 

5538 request: Request, 

5539 team_id: Optional[str] = Body(None, description="Team ID to assign resource to"), 

5540 visibility: Optional[str] = Body("public", description="Resource visibility: private, team, public"), 

5541 db: Session = Depends(get_db), 

5542 user=Depends(get_current_user_with_permissions), 

5543) -> ResourceRead: 

5544 """ 

5545 Create a new resource. 

5546 

5547 Args: 

5548 resource (ResourceCreate): Data for the new resource. 

5549 request (Request): FastAPI request object for metadata extraction. 

5550 team_id (Optional[str]): Team ID to assign the resource to. 

5551 visibility (str): Resource visibility level (private, team, public). 

5552 db (Session): Database session. 

5553 user (str): Authenticated user. 

5554 

5555 Returns: 

5556 ResourceRead: The created resource. 

5557 

5558 Raises: 

5559 HTTPException: On conflict or validation errors or IntegrityError. 

5560 """ 

5561 try: 

5562 # Extract metadata from request 

5563 metadata = MetadataCapture.extract_creation_metadata(request, user) 

5564 

5565 # Get user email and handle team assignment 

5566 user_email = get_user_email(user) 

5567 

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

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

5570 

5571 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources 

5572 is_public_only_token = token_teams is not None and len(token_teams) == 0 

5573 if is_public_only_token and visibility in ("team", "private"): 

5574 return ORJSONResponse( 

5575 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."}, 

5576 status_code=status.HTTP_403_FORBIDDEN, 

5577 ) 

5578 

5579 # Check for team ID mismatch (only for non-public-only tokens) 

5580 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: 

5581 return ORJSONResponse( 

5582 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

5583 status_code=status.HTTP_403_FORBIDDEN, 

5584 ) 

5585 

5586 # Determine final team ID (public-only tokens get no team) 

5587 if is_public_only_token: 

5588 team_id = None 

5589 else: 

5590 team_id = team_id or token_team_id 

5591 

5592 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new resource for team {team_id}") 

5593 result = await resource_service.register_resource( 

5594 db, 

5595 resource, 

5596 created_by=metadata["created_by"], 

5597 created_from_ip=metadata["created_from_ip"], 

5598 created_via=metadata["created_via"], 

5599 created_user_agent=metadata["created_user_agent"], 

5600 import_batch_id=metadata["import_batch_id"], 

5601 federation_source=metadata["federation_source"], 

5602 team_id=team_id, 

5603 owner_email=user_email, 

5604 visibility=visibility, 

5605 ) 

5606 db.commit() 

5607 db.close() 

5608 return result 

5609 except ResourceURIConflictError as e: 

5610 raise HTTPException(status_code=409, detail=str(e)) 

5611 except ResourceError as e: 

5612 raise HTTPException(status_code=400, detail=str(e)) 

5613 except ValidationError as e: 

5614 # Handle validation errors from Pydantic 

5615 logger.error(f"Validation error while creating resource: {e}") 

5616 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e)) 

5617 except IntegrityError as e: 

5618 logger.error(f"Integrity error while creating resource: {e}") 

5619 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e)) 

5620 except ContentSizeError as e: 

5621 logger.error(f"Content size exceeded in creating resource: {e}") 

5622 raise HTTPException(status_code=413, detail={"error": f"{e.content_type} size limit exceeded", "message": str(e), "actual_size": e.actual_size, "max_size": e.max_size}) 

5623 except ContentTypeError as e: 

5624 logger.error(f"MIME type not allowed in creating resource: {e}") 

5625 raise HTTPException(status_code=415, detail={"error": "Unsupported Media Type", "message": str(e), "mime_type": e.mime_type, "allowed_types": e.allowed_types}) 

5626 

5627 

5628@resource_router.get("/{resource_id}") 

5629@require_permission("resources.read") 

5630async def read_resource(resource_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Any: 

5631 """ 

5632 Read a resource by its ID with plugin support. 

5633 

5634 Args: 

5635 resource_id (str): ID of the resource. 

5636 request (Request): FastAPI request object for context. 

5637 db (Session): Database session. 

5638 user (str): Authenticated user. 

5639 

5640 Returns: 

5641 Any: The content of the resource. 

5642 

5643 Raises: 

5644 HTTPException: If the resource cannot be found or read. 

5645 """ 

5646 # Get request ID from headers or generate one 

5647 request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) 

5648 server_id = request.headers.get("X-Server-ID") 

5649 

5650 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested resource with ID {resource_id} (request_id: {request_id})") 

5651 

5652 # NOTE: Removed endpoint-level cache to prevent authorization bypass 

5653 # The cache was checked before access control, allowing unauthorized users 

5654 # to access cached private resources. Service layer handles caching safely. 

5655 

5656 # Get plugin contexts from request.state for cross-hook sharing 

5657 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

5658 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

5659 

5660 try: 

5661 # Extract user email and admin status for authorization 

5662 user_email = get_user_email(user) 

5663 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False 

5664 

5665 # Admin bypass: pass user=None to trigger unrestricted access 

5666 # Non-admin: pass user_email and let service look up teams 

5667 auth_user_email = None if is_admin else user_email 

5668 

5669 # Call service with context for plugin support 

5670 content = await resource_service.read_resource( 

5671 db, 

5672 resource_id=resource_id, 

5673 request_id=request_id, 

5674 user=auth_user_email, 

5675 server_id=server_id, 

5676 token_teams=None, # Admin: bypass; Non-admin: lookup teams 

5677 plugin_context_table=plugin_context_table, 

5678 plugin_global_context=plugin_global_context, 

5679 ) 

5680 _enforce_scoped_resource_access(request, db, user, f"/resources/{resource_id}") 

5681 # Release transaction before response serialization 

5682 db.commit() 

5683 db.close() 

5684 except (ResourceNotFoundError, ResourceError) as exc: 

5685 # Translate to FastAPI HTTP error 

5686 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc 

5687 

5688 # NOTE: Removed cache.set() - see cache removal comment above 

5689 # Ensure a plain JSON-serializable structure 

5690 try: 

5691 # First-Party 

5692 from mcpgateway.common.models import ResourceContent, TextContent # pylint: disable=import-outside-toplevel 

5693 

5694 # If already a ResourceContent, serialize directly 

5695 if isinstance(content, ResourceContent): 

5696 return content.model_dump() 

5697 

5698 # If TextContent, wrap into resource envelope with text 

5699 if isinstance(content, TextContent): 

5700 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": content.text} 

5701 except Exception: 

5702 pass # nosec B110 - Intentionally continue with fallback resource content handling 

5703 

5704 if isinstance(content, bytes): 

5705 return {"type": "resource", "id": resource_id, "uri": content.uri, "blob": content.decode("utf-8", errors="ignore")} 

5706 if isinstance(content, str): 

5707 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": content} 

5708 

5709 # Objects with a 'text' attribute (e.g., mocks) – best-effort mapping 

5710 if hasattr(content, "text"): 

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

5712 

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

5714 

5715 

5716@resource_router.get("/{resource_id}/info", response_model=ResourceRead) 

5717@require_permission("resources.read") 

5718async def get_resource_info( 

5719 resource_id: str, 

5720 request: Request, 

5721 include_inactive: bool = Query(False, description="Include inactive resources"), 

5722 db: Session = Depends(get_db), 

5723 user=Depends(get_current_user_with_permissions), 

5724) -> ResourceRead: 

5725 """ 

5726 Get resource metadata by ID. 

5727 

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

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

5730 

5731 Args: 

5732 resource_id (str): ID of the resource. 

5733 request (Request): Incoming request context used for scope enforcement. 

5734 include_inactive (bool): Whether to include inactive resources. 

5735 db (Session): Database session. 

5736 user (str): Authenticated user. 

5737 

5738 Returns: 

5739 ResourceRead: The resource metadata including enabled status. 

5740 

5741 Raises: 

5742 HTTPException: If the resource is not found. 

5743 """ 

5744 try: 

5745 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested resource info for ID {resource_id}") 

5746 result = await resource_service.get_resource_by_id(db, resource_id, include_inactive=include_inactive) 

5747 _enforce_scoped_resource_access(request, db, user, f"/resources/{resource_id}") 

5748 return result 

5749 except ResourceNotFoundError as e: 

5750 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

5751 

5752 

5753@resource_router.put("/{resource_id}", response_model=ResourceRead) 

5754@require_permission("resources.update") 

5755async def update_resource( 

5756 resource_id: str, 

5757 resource: ResourceUpdate, 

5758 request: Request, 

5759 db: Session = Depends(get_db), 

5760 user=Depends(get_current_user_with_permissions), 

5761) -> ResourceRead: 

5762 """ 

5763 Update a resource identified by its ID. 

5764 

5765 Args: 

5766 resource_id (str): ID of the resource. 

5767 resource (ResourceUpdate): New resource data. 

5768 request (Request): The FastAPI request object for metadata extraction. 

5769 db (Session): Database session. 

5770 user (str): Authenticated user. 

5771 

5772 Returns: 

5773 ResourceRead: The updated resource. 

5774 

5775 Raises: 

5776 HTTPException: If the resource is not found or update fails. 

5777 """ 

5778 try: 

5779 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is updating resource with ID {resource_id}") 

5780 # Extract modification metadata 

5781 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service 

5782 

5783 user_email = user.get("email") if isinstance(user, dict) else str(user) 

5784 result = await resource_service.update_resource( 

5785 db, 

5786 resource_id, 

5787 resource, 

5788 modified_by=mod_metadata["modified_by"], 

5789 modified_from_ip=mod_metadata["modified_from_ip"], 

5790 modified_via=mod_metadata["modified_via"], 

5791 modified_user_agent=mod_metadata["modified_user_agent"], 

5792 user_email=user_email, 

5793 ) 

5794 except PermissionError as e: 

5795 raise HTTPException(status_code=403, detail=str(e)) 

5796 except ResourceNotFoundError as e: 

5797 raise HTTPException(status_code=404, detail=str(e)) 

5798 except ValidationError as e: 

5799 logger.error(f"Validation error while updating resource {resource_id}: {e}") 

5800 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e)) 

5801 except IntegrityError as e: 

5802 logger.error(f"Integrity error while updating resource {resource_id}: {e}") 

5803 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e)) 

5804 except ResourceURIConflictError as e: 

5805 raise HTTPException(status_code=409, detail=str(e)) 

5806 except ContentSizeError as e: 

5807 logger.error(f"Content size exceeded in updating resource: {e}") 

5808 raise HTTPException(status_code=413, detail={"error": f"{e.content_type} size limit exceeded", "message": str(e), "actual_size": e.actual_size, "max_size": e.max_size}) 

5809 except ContentTypeError as e: 

5810 logger.error(f"MIME type not allowed in updating resource: {e}") 

5811 raise HTTPException(status_code=415, detail={"error": "Unsupported Media Type", "message": str(e), "mime_type": e.mime_type, "allowed_types": e.allowed_types}) 

5812 db.commit() 

5813 db.close() 

5814 await invalidate_resource_cache(resource_id) 

5815 return result 

5816 

5817 

5818@resource_router.delete("/{resource_id}") 

5819@require_permission("resources.delete") 

5820async def delete_resource( 

5821 resource_id: str, 

5822 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this resource"), 

5823 db: Session = Depends(get_db), 

5824 user=Depends(get_current_user_with_permissions), 

5825) -> Dict[str, str]: 

5826 """ 

5827 Delete a resource by its ID. 

5828 

5829 Args: 

5830 resource_id (str): ID of the resource to delete. 

5831 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this resource. 

5832 db (Session): Database session. 

5833 user (str): Authenticated user. 

5834 

5835 Returns: 

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

5837 

5838 Raises: 

5839 HTTPException: If the resource is not found or deletion fails. 

5840 """ 

5841 try: 

5842 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is deleting resource with id {resource_id}") 

5843 user_email = user.get("email") if isinstance(user, dict) else str(user) 

5844 await resource_service.delete_resource(db, resource_id, user_email=user_email, purge_metrics=purge_metrics) 

5845 db.commit() 

5846 db.close() 

5847 await invalidate_resource_cache(resource_id) 

5848 return {"status": "success", "message": f"Resource {resource_id} deleted"} 

5849 except PermissionError as e: 

5850 raise HTTPException(status_code=403, detail=str(e)) 

5851 except ResourceNotFoundError as e: 

5852 raise HTTPException(status_code=404, detail=str(e)) 

5853 except ResourceError as e: 

5854 raise HTTPException(status_code=400, detail=str(e)) 

5855 

5856 

5857@resource_router.post("/subscribe") 

5858@require_permission("resources.read") 

5859async def subscribe_resource(request: Request, user=Depends(get_current_user_with_permissions)) -> StreamingResponse: 

5860 """ 

5861 Subscribe to server-sent events (SSE) for a specific resource. 

5862 

5863 Args: 

5864 request (Request): Incoming HTTP request. 

5865 user (str): Authenticated user. 

5866 

5867 Returns: 

5868 StreamingResponse: A streaming response with event updates. 

5869 """ 

5870 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is subscribing to resource") 

5871 user_email, token_teams = _get_scoped_resource_access_context(request, user) 

5872 

5873 async def sse_generator(): 

5874 """Generate SSE-formatted events from resource subscription changes. 

5875 

5876 Yields: 

5877 str: SSE-formatted event data. 

5878 """ 

5879 async for event in resource_service.subscribe_events(user_email=user_email, token_teams=token_teams): 

5880 yield f"data: {orjson.dumps(event).decode()}\n\n" 

5881 

5882 return StreamingResponse(sse_generator(), media_type="text/event-stream") 

5883 

5884 

5885############### 

5886# Prompt APIs # 

5887############### 

5888@prompt_router.post("/{prompt_id}/state") 

5889@require_permission("prompts.update") 

5890async def set_prompt_state( 

5891 prompt_id: str, 

5892 activate: bool = True, 

5893 db: Session = Depends(get_db), 

5894 user=Depends(get_current_user_with_permissions), 

5895) -> Dict[str, Any]: 

5896 """ 

5897 Set the activation status of a prompt. 

5898 

5899 Args: 

5900 prompt_id: ID of the prompt to update. 

5901 activate: True to activate, False to deactivate. 

5902 db: Database session. 

5903 user: Authenticated user. 

5904 

5905 Returns: 

5906 Status message and updated prompt details. 

5907 

5908 Raises: 

5909 HTTPException: If the state change fails (e.g., prompt not found or database error); emitted with *400 Bad Request* status and an error message. 

5910 """ 

5911 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested state change for prompt {prompt_id}, activate={activate}") 

5912 try: 

5913 user_email = user.get("email") if isinstance(user, dict) else str(user) 

5914 prompt = await prompt_service.set_prompt_state(db, prompt_id, activate, user_email=user_email) 

5915 return { 

5916 "status": "success", 

5917 "message": f"Prompt {prompt_id} {'activated' if activate else 'deactivated'}", 

5918 "prompt": prompt.model_dump(), 

5919 } 

5920 except PermissionError as e: 

5921 raise HTTPException(status_code=403, detail=str(e)) 

5922 except PromptNotFoundError as e: 

5923 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

5924 except PromptLockConflictError as e: 

5925 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) 

5926 except Exception as e: 

5927 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

5928 

5929 

5930@prompt_router.post("/{prompt_id}/toggle", deprecated=True) 

5931@require_permission("prompts.update") 

5932async def toggle_prompt_status( 

5933 prompt_id: str, 

5934 activate: bool = True, 

5935 db: Session = Depends(get_db), 

5936 user=Depends(get_current_user_with_permissions), 

5937) -> Dict[str, Any]: 

5938 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release. 

5939 

5940 Set the activation status of a prompt. 

5941 

5942 Args: 

5943 prompt_id: The prompt ID. 

5944 activate: Whether to activate (True) or deactivate (False) the prompt. 

5945 db: Database session. 

5946 user: Authenticated user context. 

5947 

5948 Returns: 

5949 Status message with prompt state. 

5950 """ 

5951 

5952 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2) 

5953 return await set_prompt_state(prompt_id, activate, db, user) 

5954 

5955 

5956@prompt_router.get("", response_model=Union[List[PromptRead], CursorPaginatedPromptsResponse]) 

5957@prompt_router.get("/", response_model=Union[List[PromptRead], CursorPaginatedPromptsResponse]) 

5958@require_permission("prompts.read") 

5959async def list_prompts( 

5960 request: Request, 

5961 cursor: Optional[str] = Query(None, description="Cursor for pagination"), 

5962 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"), 

5963 limit: Optional[int] = Query(None, ge=0, description="Maximum number of prompts to return"), 

5964 include_inactive: bool = False, 

5965 tags: Optional[str] = None, 

5966 team_id: Optional[str] = None, 

5967 visibility: Optional[str] = None, 

5968 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID"), 

5969 db: Session = Depends(get_db), 

5970 user=Depends(get_current_user_with_permissions), 

5971) -> Union[List[Dict[str, Any]], Dict[str, Any]]: 

5972 """ 

5973 List prompts accessible to the user, with team filtering and cursor pagination support. 

5974 

5975 Args: 

5976 request (Request): The FastAPI request object for team_id retrieval 

5977 cursor (Optional[str]): Cursor for pagination. 

5978 include_pagination (bool): Include cursor pagination metadata in response. 

5979 limit (Optional[int]): Maximum number of prompts to return. 

5980 include_inactive: Include inactive prompts. 

5981 tags: Comma-separated list of tags to filter by. 

5982 team_id: Filter by specific team ID. 

5983 visibility: Filter by visibility (private, team, public). 

5984 gateway_id: Filter by gateway ID. Use 'null' for prompts without a gateway. 

5985 db: Database session. 

5986 user: Authenticated user. 

5987 

5988 Returns: 

5989 Union[List[Dict[str, Any]], Dict[str, Any]]: List of prompt records or paginated response with nextCursor. 

5990 """ 

5991 # Parse tags parameter if provided 

5992 tags_list = None 

5993 if tags: 

5994 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] 

5995 

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

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

5998 

5999 # Admin bypass - only when token has NO team restrictions (token_teams is None) 

6000 # If token has explicit team scope (even for admins), respect it for least-privilege 

6001 if is_admin and token_teams is None: 

6002 user_email = None 

6003 token_teams = None # Admin unrestricted 

6004 elif token_teams is None: 

6005 token_teams = [] # Non-admin without teams = public-only (secure default) 

6006 

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

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

6009 

6010 # Check for team ID mismatch (only applies when both are specified and token has teams) 

6011 if team_id is not None and token_team_id is not None and team_id != token_team_id: 

6012 return ORJSONResponse( 

6013 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

6014 status_code=status.HTTP_403_FORBIDDEN, 

6015 ) 

6016 

6017 # For listing, only narrow by team_id when explicitly requested via query param. 

6018 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping. 

6019 

6020 # Use consolidated prompt listing with token-based team filtering 

6021 # Always apply visibility filtering based on token scope 

6022 logger.debug( 

6023 f"User: {SecurityValidator.sanitize_log_message(user_email)} requested prompt list with include_inactive={include_inactive}, cursor={cursor}, tags={tags_list}, team_id={team_id}, visibility={visibility}, gateway_id={gateway_id}" 

6024 ) 

6025 data, next_cursor = await prompt_service.list_prompts( 

6026 db=db, 

6027 cursor=cursor, 

6028 limit=limit, 

6029 include_inactive=include_inactive, 

6030 tags=tags_list, 

6031 gateway_id=gateway_id, 

6032 user_email=user_email, 

6033 team_id=team_id, 

6034 visibility=visibility, 

6035 token_teams=token_teams, 

6036 ) 

6037 # Release transaction before response serialization 

6038 db.commit() 

6039 db.close() 

6040 

6041 if include_pagination: 

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

6043 return data 

6044 

6045 

6046@prompt_router.post("", response_model=PromptRead) 

6047@prompt_router.post("/", response_model=PromptRead) 

6048@require_permission("prompts.create") 

6049async def create_prompt( 

6050 prompt: PromptCreate, 

6051 request: Request, 

6052 team_id: Optional[str] = Body(None, description="Team ID to assign prompt to"), 

6053 visibility: Optional[str] = Body("public", description="Prompt visibility: private, team, public"), 

6054 db: Session = Depends(get_db), 

6055 user=Depends(get_current_user_with_permissions), 

6056) -> PromptRead: 

6057 """ 

6058 Create a new prompt. 

6059 

6060 Args: 

6061 prompt (PromptCreate): Payload describing the prompt to create. 

6062 request (Request): The FastAPI request object for metadata extraction. 

6063 team_id (Optional[str]): Team ID to assign the prompt to. 

6064 visibility (str): Prompt visibility level (private, team, public). 

6065 db (Session): Active SQLAlchemy session. 

6066 user (str): Authenticated username. 

6067 

6068 Returns: 

6069 PromptRead: The newly-created prompt. 

6070 

6071 Raises: 

6072 HTTPException: * **409 Conflict** - another prompt with the same name already exists. 

6073 * **400 Bad Request** - validation or persistence error raised 

6074 by :pyclass:`~mcpgateway.services.prompt_service.PromptService`. 

6075 """ 

6076 try: 

6077 # Extract metadata from request 

6078 metadata = MetadataCapture.extract_creation_metadata(request, user) 

6079 

6080 # Get user email and handle team assignment 

6081 user_email = get_user_email(user) 

6082 

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

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

6085 

6086 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources 

6087 is_public_only_token = token_teams is not None and len(token_teams) == 0 

6088 if is_public_only_token and visibility in ("team", "private"): 

6089 return ORJSONResponse( 

6090 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."}, 

6091 status_code=status.HTTP_403_FORBIDDEN, 

6092 ) 

6093 

6094 # Check for team ID mismatch (only for non-public-only tokens) 

6095 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: 

6096 return ORJSONResponse( 

6097 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

6098 status_code=status.HTTP_403_FORBIDDEN, 

6099 ) 

6100 

6101 # Determine final team ID (public-only tokens get no team) 

6102 if is_public_only_token: 

6103 team_id = None 

6104 else: 

6105 team_id = team_id or token_team_id 

6106 

6107 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new prompt for team {team_id}") 

6108 result = await prompt_service.register_prompt( 

6109 db, 

6110 prompt, 

6111 created_by=metadata["created_by"], 

6112 created_from_ip=metadata["created_from_ip"], 

6113 created_via=metadata["created_via"], 

6114 created_user_agent=metadata["created_user_agent"], 

6115 import_batch_id=metadata["import_batch_id"], 

6116 federation_source=metadata["federation_source"], 

6117 team_id=team_id, 

6118 owner_email=user_email, 

6119 visibility=visibility, 

6120 ) 

6121 db.commit() 

6122 db.close() 

6123 return result 

6124 except Exception as e: 

6125 if isinstance(e, PromptNameConflictError): 

6126 # If the prompt name already exists, return a 409 Conflict error 

6127 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) 

6128 if isinstance(e, PromptError): 

6129 # If there is a general prompt error, return a 400 Bad Request error 

6130 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

6131 if isinstance(e, ValidationError): 

6132 # If there is a validation error, return a 422 Unprocessable Entity error 

6133 logger.error(f"Validation error while creating prompt: {e}") 

6134 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(e)) 

6135 if isinstance(e, IntegrityError): 

6136 # If there is an integrity error, return a 409 Conflict error 

6137 logger.error(f"Integrity error while creating prompt: {e}") 

6138 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(e)) 

6139 if isinstance(e, ContentSizeError): 

6140 logger.error(f"Content size exceeded in creating prompt: {e}") 

6141 raise HTTPException(status_code=413, detail={"error": f"{e.content_type} size limit exceeded", "message": str(e), "actual_size": e.actual_size, "max_size": e.max_size}) 

6142 # For any other unexpected errors, return a 500 Internal Server Error 

6143 logger.error(f"Unexpected error while creating prompt: {e}") 

6144 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while creating the prompt") 

6145 

6146 

6147@prompt_router.post("/{prompt_id}") 

6148@require_permission("prompts.read") 

6149async def get_prompt( 

6150 request: Request, 

6151 prompt_id: str, 

6152 args: Dict[str, str] = Body({}), 

6153 db: Session = Depends(get_db), 

6154 user=Depends(get_current_user_with_permissions), 

6155) -> Any: 

6156 """Get a prompt by prompt_id with arguments. 

6157 

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

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

6160 

6161 

6162 Args: 

6163 request: FastAPI request object. 

6164 prompt_id: ID of the prompt. 

6165 args: Template arguments. 

6166 db: Database session. 

6167 user: Authenticated user. 

6168 

6169 Returns: 

6170 Rendered prompt or metadata. 

6171 

6172 Raises: 

6173 Exception: Re-raised if not a handled exception type. 

6174 """ 

6175 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested prompt: {prompt_id} with args={args}") 

6176 

6177 # Get plugin contexts from request.state for cross-hook sharing 

6178 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

6179 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

6180 

6181 # Extract user email, admin status, and server_id for authorization 

6182 user_email = get_user_email(user) 

6183 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False 

6184 server_id = request.headers.get("X-Server-ID") 

6185 

6186 # Admin bypass: pass user=None to trigger unrestricted access 

6187 # Non-admin: pass user_email and let service look up teams 

6188 auth_user_email = None if is_admin else user_email 

6189 

6190 try: 

6191 PromptExecuteArgs(args=args) 

6192 result = await prompt_service.get_prompt( 

6193 db, 

6194 prompt_id, 

6195 args, 

6196 user=auth_user_email, 

6197 server_id=server_id, 

6198 token_teams=None, # Admin: bypass; Non-admin: lookup teams 

6199 plugin_context_table=plugin_context_table, 

6200 plugin_global_context=plugin_global_context, 

6201 ) 

6202 logger.debug(f"Prompt execution successful for '{prompt_id}'") 

6203 except Exception as ex: 

6204 logger.error(f"Could not retrieve prompt {prompt_id}: {ex}") 

6205 if isinstance(ex, PluginViolationError): 

6206 # Return the actual plugin violation message 

6207 return ORJSONResponse(content={"message": ex.message, "details": str(ex.violation) if hasattr(ex, "violation") else None}, status_code=422) 

6208 if isinstance(ex, (ValueError, PromptError)): 

6209 # Return the actual error message 

6210 return ORJSONResponse(content={"message": str(ex)}, status_code=422) 

6211 raise 

6212 

6213 return result 

6214 

6215 

6216@prompt_router.get("/{prompt_id}") 

6217@require_permission("prompts.read") 

6218async def get_prompt_no_args( 

6219 request: Request, 

6220 prompt_id: str, 

6221 db: Session = Depends(get_db), 

6222 user=Depends(get_current_user_with_permissions), 

6223) -> Any: 

6224 """Get a prompt by ID without arguments. 

6225 

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

6227 

6228 Args: 

6229 request: FastAPI request object. 

6230 prompt_id: The ID of the prompt to retrieve 

6231 db: Database session 

6232 user: Authenticated user 

6233 

6234 Returns: 

6235 The prompt template information 

6236 

6237 Raises: 

6238 HTTPException: 404 if prompt not found, 403 if permission denied. 

6239 """ 

6240 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested prompt: {prompt_id} with no arguments") 

6241 

6242 # Get plugin contexts from request.state for cross-hook sharing 

6243 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

6244 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

6245 

6246 # Extract user email, admin status, and server_id for authorization 

6247 user_email = get_user_email(user) 

6248 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False 

6249 server_id = request.headers.get("X-Server-ID") 

6250 

6251 # Admin bypass: pass user=None to trigger unrestricted access 

6252 # Non-admin: pass user_email and let service look up teams 

6253 auth_user_email = None if is_admin else user_email 

6254 

6255 try: 

6256 return await prompt_service.get_prompt( 

6257 db, 

6258 prompt_id, 

6259 {}, 

6260 user=auth_user_email, 

6261 server_id=server_id, 

6262 token_teams=None, # Admin: bypass; Non-admin: lookup teams 

6263 plugin_context_table=plugin_context_table, 

6264 plugin_global_context=plugin_global_context, 

6265 ) 

6266 except PromptNotFoundError as e: 

6267 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6268 except PromptError as e: 

6269 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) 

6270 except PermissionError as e: 

6271 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) 

6272 

6273 

6274@prompt_router.put("/{prompt_id}", response_model=PromptRead) 

6275@require_permission("prompts.update") 

6276async def update_prompt( 

6277 prompt_id: str, 

6278 prompt: PromptUpdate, 

6279 request: Request, 

6280 db: Session = Depends(get_db), 

6281 user=Depends(get_current_user_with_permissions), 

6282) -> PromptRead: 

6283 """ 

6284 Update (overwrite) an existing prompt definition. 

6285 

6286 Args: 

6287 prompt_id (str): Identifier of the prompt to update. 

6288 prompt (PromptUpdate): New prompt content and metadata. 

6289 request (Request): The FastAPI request object for metadata extraction. 

6290 db (Session): Active SQLAlchemy session. 

6291 user (str): Authenticated username. 

6292 

6293 Returns: 

6294 PromptRead: The updated prompt object. 

6295 

6296 Raises: 

6297 HTTPException: * **409 Conflict** - a different prompt with the same *name* already exists and is still active. 

6298 * **400 Bad Request** - validation or persistence error raised by :pyclass:`~mcpgateway.services.prompt_service.PromptService`. 

6299 """ 

6300 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested to update prompt: {prompt_id} with data={prompt}") 

6301 try: 

6302 # Extract modification metadata 

6303 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service 

6304 

6305 user_email = user.get("email") if isinstance(user, dict) else str(user) 

6306 result = await prompt_service.update_prompt( 

6307 db, 

6308 prompt_id, 

6309 prompt, 

6310 modified_by=mod_metadata["modified_by"], 

6311 modified_from_ip=mod_metadata["modified_from_ip"], 

6312 modified_via=mod_metadata["modified_via"], 

6313 modified_user_agent=mod_metadata["modified_user_agent"], 

6314 user_email=user_email, 

6315 ) 

6316 db.commit() 

6317 db.close() 

6318 return result 

6319 except Exception as e: 

6320 if isinstance(e, PermissionError): 

6321 raise HTTPException(status_code=403, detail=str(e)) 

6322 if isinstance(e, PromptNotFoundError): 

6323 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6324 if isinstance(e, ValidationError): 

6325 logger.error(f"Validation error while updating prompt: {e}") 

6326 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(e)) 

6327 if isinstance(e, IntegrityError): 

6328 logger.error(f"Integrity error while updating prompt: {e}") 

6329 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(e)) 

6330 if isinstance(e, PromptNameConflictError): 

6331 # If the prompt name already exists, return a 409 Conflict error 

6332 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) 

6333 if isinstance(e, PromptError): 

6334 # If there is a general prompt error, return a 400 Bad Request error 

6335 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

6336 if isinstance(e, ContentSizeError): 

6337 logger.error(f"Content size exceeded in updating prompt: {e}") 

6338 raise HTTPException(status_code=413, detail={"error": f"{e.content_type} size limit exceeded", "message": str(e), "actual_size": e.actual_size, "max_size": e.max_size}) 

6339 # For any other unexpected errors, return a 500 Internal Server Error 

6340 logger.error(f"Unexpected error while updating prompt: {e}") 

6341 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the prompt") 

6342 

6343 

6344@prompt_router.delete("/{prompt_id}") 

6345@require_permission("prompts.delete") 

6346async def delete_prompt( 

6347 prompt_id: str, 

6348 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this prompt"), 

6349 db: Session = Depends(get_db), 

6350 user=Depends(get_current_user_with_permissions), 

6351) -> Dict[str, str]: 

6352 """ 

6353 Delete a prompt by ID. 

6354 

6355 Args: 

6356 prompt_id: ID of the prompt. 

6357 purge_metrics: Whether to delete raw + hourly rollup metrics for this prompt. 

6358 db: Database session. 

6359 user: Authenticated user. 

6360 

6361 Returns: 

6362 Status message. 

6363 

6364 Raises: 

6365 HTTPException: If the prompt is not found, a prompt error occurs, or an unexpected error occurs during deletion. 

6366 """ 

6367 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested deletion of prompt {prompt_id}") 

6368 try: 

6369 user_email = user.get("email") if isinstance(user, dict) else str(user) 

6370 await prompt_service.delete_prompt(db, prompt_id, user_email=user_email, purge_metrics=purge_metrics) 

6371 db.commit() 

6372 db.close() 

6373 return {"status": "success", "message": f"Prompt {prompt_id} deleted"} 

6374 except Exception as e: 

6375 if isinstance(e, PermissionError): 

6376 raise HTTPException(status_code=403, detail=str(e)) 

6377 if isinstance(e, PromptNotFoundError): 

6378 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6379 if isinstance(e, PromptError): 

6380 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

6381 logger.error(f"Unexpected error while deleting prompt {prompt_id}: {e}") 

6382 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while deleting the prompt") 

6383 

6384 # except PromptNotFoundError as e: 

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

6386 # except PromptError as e: 

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

6388 

6389 

6390################ 

6391# Gateway APIs # 

6392################ 

6393@gateway_router.post("/{gateway_id}/state") 

6394@require_permission("gateways.update") 

6395async def set_gateway_state( 

6396 gateway_id: str, 

6397 activate: bool = True, 

6398 db: Session = Depends(get_db), 

6399 user=Depends(get_current_user_with_permissions), 

6400) -> Dict[str, Any]: 

6401 """ 

6402 Set the activation status of a gateway. 

6403 

6404 Args: 

6405 gateway_id (str): String ID of the gateway to update. 

6406 activate (bool): ``True`` to activate, ``False`` to deactivate. 

6407 db (Session): Active SQLAlchemy session. 

6408 user (str): Authenticated username. 

6409 

6410 Returns: 

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

6412 

6413 Raises: 

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

6415 """ 

6416 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested state change for gateway {gateway_id}, activate={activate}") 

6417 try: 

6418 user_email = user.get("email") if isinstance(user, dict) else str(user) 

6419 gateway = await gateway_service.set_gateway_state( 

6420 db, 

6421 gateway_id, 

6422 activate, 

6423 user_email=user_email, 

6424 ) 

6425 return { 

6426 "status": "success", 

6427 "message": f"Gateway {gateway_id} {'activated' if activate else 'deactivated'}", 

6428 "gateway": gateway.model_dump(), 

6429 } 

6430 except PermissionError as e: 

6431 raise HTTPException(status_code=403, detail=str(e)) 

6432 except GatewayNotFoundError as e: 

6433 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6434 except Exception as e: 

6435 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

6436 

6437 

6438@gateway_router.post("/{gateway_id}/toggle", deprecated=True) 

6439@require_permission("gateways.update") 

6440async def toggle_gateway_status( 

6441 gateway_id: str, 

6442 activate: bool = True, 

6443 db: Session = Depends(get_db), 

6444 user=Depends(get_current_user_with_permissions), 

6445) -> Dict[str, Any]: 

6446 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release. 

6447 

6448 Set the activation status of a gateway. 

6449 

6450 Args: 

6451 gateway_id: The gateway ID. 

6452 activate: Whether to activate (True) or deactivate (False) the gateway. 

6453 db: Database session. 

6454 user: Authenticated user context. 

6455 

6456 Returns: 

6457 Status message with gateway state. 

6458 """ 

6459 

6460 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2) 

6461 return await set_gateway_state(gateway_id, activate, db, user) 

6462 

6463 

6464@gateway_router.get("", response_model=Union[List[GatewayRead], CursorPaginatedGatewaysResponse]) 

6465@gateway_router.get("/", response_model=Union[List[GatewayRead], CursorPaginatedGatewaysResponse]) 

6466@require_permission("gateways.read") 

6467async def list_gateways( 

6468 request: Request, 

6469 cursor: Optional[str] = Query(None, description="Cursor for pagination"), 

6470 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"), 

6471 limit: Optional[int] = Query(None, ge=0, description="Maximum number of gateways to return"), 

6472 include_inactive: bool = False, 

6473 team_id: Optional[str] = Query(None, description="Filter by team ID"), 

6474 visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, public"), 

6475 db: Session = Depends(get_db), 

6476 user=Depends(get_current_user_with_permissions), 

6477) -> Union[List[GatewayRead], Dict[str, Any]]: 

6478 """ 

6479 List all gateways with cursor pagination support. 

6480 

6481 Args: 

6482 request (Request): The FastAPI request object for team_id retrieval 

6483 cursor (Optional[str]): Cursor for pagination. 

6484 include_pagination (bool): Include cursor pagination metadata in response. 

6485 limit (Optional[int]): Maximum number of gateways to return. 

6486 include_inactive: Include inactive gateways. 

6487 team_id (Optional): Filter by specific team ID. 

6488 visibility (Optional): Filter by visibility (private, team, public). 

6489 db: Database session. 

6490 user: Authenticated user. 

6491 

6492 Returns: 

6493 Union[List[GatewayRead], Dict[str, Any]]: List of gateway records or paginated response with nextCursor. 

6494 """ 

6495 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested list of gateways with include_inactive={include_inactive}") 

6496 

6497 user_email = get_user_email(user) 

6498 

6499 # Check team_id from token 

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

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

6502 

6503 # Check for team ID mismatch 

6504 if team_id is not None and token_team_id is not None and team_id != token_team_id: 

6505 return ORJSONResponse( 

6506 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

6507 status_code=status.HTTP_403_FORBIDDEN, 

6508 ) 

6509 

6510 # For listing, only narrow by team_id when explicitly requested via query param. 

6511 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping. 

6512 

6513 # SECURITY: token_teams is normalized in auth.py: 

6514 # - None: admin bypass (is_admin=true with explicit null teams) - sees ALL resources 

6515 # - []: public-only (missing teams or explicit empty) - sees only public 

6516 # - [...]: team-scoped - sees public + teams + user's private 

6517 is_admin_bypass = token_teams is None 

6518 is_public_only_token = token_teams is not None and len(token_teams) == 0 

6519 

6520 # Use consolidated gateway listing with optional team filtering 

6521 # For admin bypass: pass user_email=None and token_teams=None to skip all filtering 

6522 logger.debug(f"User: {SecurityValidator.sanitize_log_message(user_email)} requested gateway list with include_inactive={include_inactive}, team_id={team_id}, visibility={visibility}") 

6523 data, next_cursor = await gateway_service.list_gateways( 

6524 db=db, 

6525 cursor=cursor, 

6526 limit=limit, 

6527 include_inactive=include_inactive, 

6528 user_email=None if is_admin_bypass else user_email, # Admin bypass: no user filtering 

6529 team_id=team_id, 

6530 visibility="public" if is_public_only_token and not visibility else visibility, 

6531 token_teams=token_teams, # None = admin bypass, [] = public-only, [...] = team-scoped 

6532 ) 

6533 # Release transaction before response serialization 

6534 db.commit() 

6535 db.close() 

6536 

6537 if include_pagination: 

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

6539 return data 

6540 

6541 

6542@gateway_router.post("", response_model=GatewayRead) 

6543@gateway_router.post("/", response_model=GatewayRead) 

6544@require_permission("gateways.create") 

6545async def register_gateway( 

6546 gateway: GatewayCreate, 

6547 request: Request, 

6548 db: Session = Depends(get_db), 

6549 user=Depends(get_current_user_with_permissions), 

6550) -> Union[GatewayRead, JSONResponse]: 

6551 """ 

6552 Register a new gateway. 

6553 

6554 Args: 

6555 gateway: Gateway creation data. 

6556 request: The FastAPI request object for metadata extraction. 

6557 db: Database session. 

6558 user: Authenticated user. 

6559 

6560 Returns: 

6561 Created gateway. 

6562 """ 

6563 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested to register gateway: {gateway}") 

6564 try: 

6565 # Extract metadata from request 

6566 metadata = MetadataCapture.extract_creation_metadata(request, user) 

6567 

6568 # Get user email and handle team assignment 

6569 user_email = get_user_email(user) 

6570 

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

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

6573 gateway_team_id = gateway.team_id 

6574 visibility = gateway.visibility 

6575 

6576 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources 

6577 is_public_only_token = token_teams is not None and len(token_teams) == 0 

6578 if is_public_only_token and visibility in ("team", "private"): 

6579 return ORJSONResponse( 

6580 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."}, 

6581 status_code=status.HTTP_403_FORBIDDEN, 

6582 ) 

6583 

6584 # Check for team ID mismatch (only for non-public-only tokens) 

6585 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: 

6586 return ORJSONResponse( 

6587 content={"message": "Access issue: This API token does not have the required permissions for this team."}, 

6588 status_code=status.HTTP_403_FORBIDDEN, 

6589 ) 

6590 

6591 # Determine final team ID (public-only tokens get no team) 

6592 if is_public_only_token: 

6593 team_id = None 

6594 else: 

6595 team_id = gateway_team_id or token_team_id 

6596 

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

6598 

6599 return await gateway_service.register_gateway( 

6600 db, 

6601 gateway, 

6602 created_by=metadata["created_by"], 

6603 created_from_ip=metadata["created_from_ip"], 

6604 created_via=metadata["created_via"], 

6605 created_user_agent=metadata["created_user_agent"], 

6606 team_id=team_id, 

6607 owner_email=user_email, 

6608 visibility=visibility, 

6609 ) 

6610 except Exception as ex: 

6611 if isinstance(ex, GatewayConnectionError): 

6612 return ORJSONResponse(content={"message": str(ex)}, status_code=status.HTTP_502_BAD_GATEWAY) 

6613 if isinstance(ex, ValueError): 

6614 return ORJSONResponse(content={"message": "Unable to process input"}, status_code=status.HTTP_400_BAD_REQUEST) 

6615 if isinstance(ex, GatewayNameConflictError): 

6616 return ORJSONResponse(content={"message": "Gateway name already exists"}, status_code=status.HTTP_409_CONFLICT) 

6617 if isinstance(ex, GatewayDuplicateConflictError): 

6618 return ORJSONResponse(content={"message": "Gateway already exists"}, status_code=status.HTTP_409_CONFLICT) 

6619 if isinstance(ex, RuntimeError): 

6620 return ORJSONResponse(content={"message": "Error during execution"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) 

6621 if isinstance(ex, ValidationError): 

6622 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=status.HTTP_422_UNPROCESSABLE_CONTENT) 

6623 if isinstance(ex, IntegrityError): 

6624 return ORJSONResponse(status_code=status.HTTP_409_CONFLICT, content=ErrorFormatter.format_database_error(ex)) 

6625 return ORJSONResponse(content={"message": "Unexpected error"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) 

6626 

6627 

6628@gateway_router.get("/{gateway_id}", response_model=GatewayRead) 

6629@require_permission("gateways.read") 

6630async def get_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Union[GatewayRead, JSONResponse]: 

6631 """ 

6632 Retrieve a gateway by ID. 

6633 

6634 Args: 

6635 gateway_id: ID of the gateway. 

6636 request: Incoming request used for scoped access validation. 

6637 db: Database session. 

6638 user: Authenticated user. 

6639 

6640 Returns: 

6641 Gateway data. 

6642 

6643 Raises: 

6644 HTTPException: 404 if gateway not found. 

6645 """ 

6646 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested gateway {gateway_id}") 

6647 try: 

6648 gateway = await gateway_service.get_gateway(db, gateway_id) 

6649 _enforce_scoped_resource_access(request, db, user, f"/gateways/{gateway_id}") 

6650 return gateway 

6651 except GatewayNotFoundError as e: 

6652 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6653 

6654 

6655@gateway_router.put("/{gateway_id}", response_model=GatewayRead) 

6656@require_permission("gateways.update") 

6657async def update_gateway( 

6658 gateway_id: str, 

6659 gateway: GatewayUpdate, 

6660 request: Request, 

6661 db: Session = Depends(get_db), 

6662 user=Depends(get_current_user_with_permissions), 

6663) -> Union[GatewayRead, JSONResponse]: 

6664 """ 

6665 Update a gateway. 

6666 

6667 Args: 

6668 gateway_id: Gateway ID. 

6669 gateway: Gateway update data. 

6670 request (Request): The FastAPI request object for metadata extraction. 

6671 db: Database session. 

6672 user: Authenticated user. 

6673 

6674 Returns: 

6675 Updated gateway. 

6676 """ 

6677 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested update on gateway {gateway_id} with data={gateway}") 

6678 try: 

6679 # Extract modification metadata 

6680 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service 

6681 

6682 user_email = user.get("email") if isinstance(user, dict) else str(user) 

6683 result = await gateway_service.update_gateway( 

6684 db, 

6685 gateway_id, 

6686 gateway, 

6687 modified_by=mod_metadata["modified_by"], 

6688 modified_from_ip=mod_metadata["modified_from_ip"], 

6689 modified_via=mod_metadata["modified_via"], 

6690 modified_user_agent=mod_metadata["modified_user_agent"], 

6691 user_email=user_email, 

6692 ) 

6693 db.commit() 

6694 db.close() 

6695 return result 

6696 except Exception as ex: 

6697 if isinstance(ex, PermissionError): 

6698 return ORJSONResponse(content={"message": str(ex)}, status_code=403) 

6699 if isinstance(ex, GatewayNotFoundError): 

6700 return ORJSONResponse(content={"message": "Gateway not found"}, status_code=status.HTTP_404_NOT_FOUND) 

6701 if isinstance(ex, GatewayConnectionError): 

6702 return ORJSONResponse(content={"message": str(ex)}, status_code=status.HTTP_502_BAD_GATEWAY) 

6703 if isinstance(ex, ValueError): 

6704 return ORJSONResponse(content={"message": "Unable to process input"}, status_code=status.HTTP_400_BAD_REQUEST) 

6705 if isinstance(ex, GatewayNameConflictError): 

6706 return ORJSONResponse(content={"message": "Gateway name already exists"}, status_code=status.HTTP_409_CONFLICT) 

6707 if isinstance(ex, GatewayDuplicateConflictError): 

6708 return ORJSONResponse(content={"message": "Gateway already exists"}, status_code=status.HTTP_409_CONFLICT) 

6709 if isinstance(ex, RuntimeError): 

6710 return ORJSONResponse(content={"message": "Error during execution"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) 

6711 if isinstance(ex, ValidationError): 

6712 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=status.HTTP_422_UNPROCESSABLE_CONTENT) 

6713 if isinstance(ex, IntegrityError): 

6714 return ORJSONResponse(status_code=status.HTTP_409_CONFLICT, content=ErrorFormatter.format_database_error(ex)) 

6715 return ORJSONResponse(content={"message": "Unexpected error"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) 

6716 

6717 

6718@gateway_router.delete("/{gateway_id}") 

6719@require_permission("gateways.delete") 

6720async def delete_gateway(gateway_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, str]: 

6721 """ 

6722 Delete a gateway by ID. 

6723 

6724 Args: 

6725 gateway_id: ID of the gateway. 

6726 db: Database session. 

6727 user: Authenticated user. 

6728 

6729 Returns: 

6730 Status message. 

6731 

6732 Raises: 

6733 HTTPException: If permission denied (403), gateway not found (404), or other gateway error (400). 

6734 """ 

6735 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested deletion of gateway {gateway_id}") 

6736 try: 

6737 user_email = user.get("email") if isinstance(user, dict) else str(user) 

6738 current = await gateway_service.get_gateway(db, gateway_id) 

6739 has_resources = bool(current.capabilities.get("resources")) 

6740 await gateway_service.delete_gateway(db, gateway_id, user_email=user_email) 

6741 

6742 # If the gateway had resources and was successfully deleted, invalidate 

6743 # the whole resource cache. This is needed since the cache holds both 

6744 # individual resources and the full listing which will also need to be 

6745 # invalidated. 

6746 if has_resources: 

6747 await invalidate_resource_cache() 

6748 

6749 db.commit() 

6750 db.close() 

6751 return {"status": "success", "message": f"Gateway {gateway_id} deleted"} 

6752 except PermissionError as e: 

6753 raise HTTPException(status_code=403, detail=str(e)) 

6754 except GatewayNotFoundError as e: 

6755 raise HTTPException(status_code=404, detail=str(e)) 

6756 except GatewayError as e: 

6757 raise HTTPException(status_code=400, detail=str(e)) 

6758 

6759 

6760@gateway_router.post("/{gateway_id}/tools/refresh", response_model=GatewayRefreshResponse) 

6761@require_permission("gateways.update") 

6762async def refresh_gateway_tools( 

6763 gateway_id: str, 

6764 request: Request, 

6765 include_resources: bool = Query(False, description="Include resources in refresh"), 

6766 include_prompts: bool = Query(False, description="Include prompts in refresh"), 

6767 db: Session = Depends(get_db), 

6768 user=Depends(get_current_user_with_permissions), 

6769) -> GatewayRefreshResponse: 

6770 """ 

6771 Manually trigger a refresh of tools/resources/prompts from a gateway's MCP server. 

6772 

6773 This endpoint forces an immediate re-discovery of tools, resources, and prompts 

6774 from the specified gateway. It returns counts of added, updated, and removed items, 

6775 along with any validation errors encountered. 

6776 

6777 Args: 

6778 gateway_id: ID of the gateway to refresh. 

6779 request: The FastAPI request object. 

6780 include_resources: Whether to include resources in the refresh. 

6781 include_prompts: Whether to include prompts in the refresh. 

6782 db: Database session used to validate gateway access. 

6783 user: Authenticated user. 

6784 

6785 Returns: 

6786 GatewayRefreshResponse with counts of changes and any validation errors. 

6787 

6788 Raises: 

6789 HTTPException: 404 if gateway not found, 409 if refresh already in progress. 

6790 """ 

6791 logger.info(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested manual refresh for gateway {gateway_id}") 

6792 try: 

6793 await gateway_service.get_gateway(db, gateway_id) 

6794 _enforce_scoped_resource_access(request, db, user, f"/gateways/{gateway_id}") 

6795 

6796 user_email = user.get("email") if isinstance(user, dict) else str(user) 

6797 result = await gateway_service.refresh_gateway_manually( 

6798 gateway_id=gateway_id, 

6799 include_resources=include_resources, 

6800 include_prompts=include_prompts, 

6801 user_email=user_email, 

6802 request_headers=dict(request.headers), 

6803 ) 

6804 return GatewayRefreshResponse(gateway_id=gateway_id, **result) 

6805 except GatewayNotFoundError as e: 

6806 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6807 except GatewayError as e: 

6808 # 409 Conflict for concurrent refresh attempts 

6809 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) 

6810 

6811 

6812############## 

6813# Root APIs # 

6814############## 

6815@root_router.get("", response_model=List[Root]) 

6816@root_router.get("/", response_model=List[Root]) 

6817@require_permission("admin.system_config") 

6818async def list_roots( 

6819 user=Depends(get_current_user_with_permissions), 

6820) -> List[Root]: 

6821 """ 

6822 Retrieve a list of all registered roots. 

6823 

6824 Args: 

6825 user: Authenticated user. 

6826 

6827 Returns: 

6828 List of Root objects. 

6829 """ 

6830 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested list of roots") 

6831 return await root_service.list_roots() 

6832 

6833 

6834@root_router.get("/export", response_model=Dict[str, Any]) 

6835@require_permission("admin.system_config") 

6836async def export_root( 

6837 uri: str, 

6838 user=Depends(get_current_user_with_permissions), 

6839) -> Dict[str, Any]: 

6840 """ 

6841 Export a single root configuration to JSON format. 

6842 

6843 Args: 

6844 uri: Root URI to export (query parameter) 

6845 user: Authenticated user 

6846 

6847 Returns: 

6848 Export data containing root information 

6849 

6850 Raises: 

6851 HTTPException: If root not found or export fails 

6852 """ 

6853 try: 

6854 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested root export for URI: {uri}") 

6855 

6856 # Extract username from user 

6857 username: Optional[str] = None 

6858 if hasattr(user, "email"): 

6859 username = getattr(user, "email", None) 

6860 elif isinstance(user, dict): 

6861 username = user.get("email", None) 

6862 else: 

6863 username = None 

6864 

6865 # Get the root by URI 

6866 root = await root_service.get_root_by_uri(uri) 

6867 

6868 # Create export data 

6869 export_data = { 

6870 "exported_at": datetime.now().isoformat(), 

6871 "exported_by": username or "unknown", 

6872 "export_type": "root", 

6873 "version": "1.0", 

6874 "root": { 

6875 "uri": str(root.uri), 

6876 "name": root.name, 

6877 }, 

6878 } 

6879 

6880 return export_data 

6881 

6882 except RootServiceNotFoundError as e: 

6883 logger.error(f"Root not found for export by user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

6884 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6885 except Exception as e: 

6886 logger.error(f"Unexpected root export error for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

6887 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Root export failed: {str(e)}") 

6888 

6889 

6890@root_router.get("/changes") 

6891@require_permission("admin.system_config") 

6892async def subscribe_roots_changes( 

6893 user=Depends(get_current_user_with_permissions), 

6894) -> StreamingResponse: 

6895 """ 

6896 Subscribe to real-time changes in root list via Server-Sent Events (SSE). 

6897 

6898 Args: 

6899 user: Authenticated user. 

6900 

6901 Returns: 

6902 StreamingResponse with event-stream media type. 

6903 """ 

6904 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' subscribed to root changes stream") 

6905 

6906 async def generate_events(): 

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

6908 

6909 Yields: 

6910 str: SSE-formatted event data. 

6911 """ 

6912 async for event in root_service.subscribe_changes(): 

6913 yield f"data: {orjson.dumps(event).decode()}\n\n" 

6914 

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

6916 

6917 

6918@root_router.get("/{root_uri:path}", response_model=Root) 

6919@require_permission("admin.system_config") 

6920async def get_root_by_uri( 

6921 root_uri: str, 

6922 user=Depends(get_current_user_with_permissions), 

6923) -> Root: 

6924 """ 

6925 Retrieve a specific root by its URI. 

6926 

6927 Args: 

6928 root_uri: URI of the root to retrieve. 

6929 user: Authenticated user. 

6930 

6931 Returns: 

6932 Root object. 

6933 

6934 Raises: 

6935 HTTPException: If the root is not found. 

6936 Exception: For any other unexpected errors. 

6937 """ 

6938 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested root with URI: {root_uri}") 

6939 try: 

6940 root = await root_service.get_root_by_uri(root_uri) 

6941 return root 

6942 except RootServiceNotFoundError as e: 

6943 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6944 except Exception as e: 

6945 logger.error(f"Error getting root {root_uri}: {e}") 

6946 raise e 

6947 

6948 

6949@root_router.post("", response_model=Root) 

6950@root_router.post("/", response_model=Root) 

6951@require_permission("admin.system_config") 

6952async def add_root( 

6953 root: Root, # Accept JSON body using the Root model from models.py 

6954 user=Depends(get_current_user_with_permissions), 

6955) -> Root: 

6956 """ 

6957 Add a new root. 

6958 

6959 Args: 

6960 root: Root object containing URI and name. 

6961 user: Authenticated user. 

6962 

6963 Returns: 

6964 The added Root object. 

6965 """ 

6966 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested to add root: {root}") 

6967 return await root_service.add_root(str(root.uri), root.name) 

6968 

6969 

6970@root_router.put("/{root_uri:path}", response_model=Root) 

6971@require_permission("admin.system_config") 

6972async def update_root( 

6973 root_uri: str, 

6974 root: Root, 

6975 user=Depends(get_current_user_with_permissions), 

6976) -> Root: 

6977 """ 

6978 Update a root by URI. 

6979 

6980 Args: 

6981 root_uri: URI of the root to update. 

6982 root: Root object with updated information. 

6983 user: Authenticated user. 

6984 

6985 Returns: 

6986 Updated Root object. 

6987 

6988 Raises: 

6989 HTTPException: If the root is not found. 

6990 Exception: For any other unexpected errors. 

6991 """ 

6992 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested to update root with URI: {root_uri}") 

6993 try: 

6994 root = await root_service.update_root(root_uri, root.name) 

6995 return root 

6996 except RootServiceNotFoundError as e: 

6997 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

6998 except Exception as e: 

6999 logger.error(f"Error updating root {root_uri}: {e}") 

7000 raise e 

7001 

7002 

7003@root_router.delete("/{uri:path}") 

7004@require_permission("admin.system_config") 

7005async def remove_root( 

7006 uri: str, 

7007 user=Depends(get_current_user_with_permissions), 

7008) -> Dict[str, str]: 

7009 """ 

7010 Remove a registered root by URI. 

7011 

7012 Args: 

7013 uri: URI of the root to remove. 

7014 user: Authenticated user. 

7015 

7016 Returns: 

7017 Status message indicating result. 

7018 """ 

7019 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested to remove root with URI: {uri}") 

7020 await root_service.remove_root(uri) 

7021 return {"status": "success", "message": f"Root {uri} removed"} 

7022 

7023 

7024################## 

7025# Utility Routes # 

7026################## 

7027@utility_router.post("/rpc/") 

7028@utility_router.post("/rpc") 

7029async def handle_rpc(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)): 

7030 """Handle authenticated public RPC requests. 

7031 

7032 Args: 

7033 request: Incoming public RPC request. 

7034 db: Database session provided by dependency injection. 

7035 user: Authenticated user payload with permissions. 

7036 

7037 Returns: 

7038 JSON-RPC response generated by the shared authenticated RPC dispatcher. 

7039 """ 

7040 return await _handle_rpc_authenticated(request, db=db, user=user) 

7041 

7042 

7043@utility_router.post("/_internal/mcp/authenticate/") 

7044@utility_router.post("/_internal/mcp/authenticate") 

7045async def handle_internal_mcp_authenticate(request: Request): 

7046 """Authenticate a public MCP request for direct Rust ingress. 

7047 

7048 Args: 

7049 request: Trusted internal request sent by the local Rust runtime. 

7050 

7051 Returns: 

7052 Auth context payload that Rust can forward on subsequent internal MCP calls. 

7053 

7054 Raises: 

7055 HTTPException: If the request is not trusted or the forwarded payload is invalid. 

7056 """ 

7057 if not _is_trusted_internal_mcp_runtime_request(request): 

7058 raise HTTPException(status_code=403, detail="Internal MCP authenticate is only available to the local Rust runtime") 

7059 

7060 payload = await request.json() 

7061 if not isinstance(payload, dict): 

7062 raise HTTPException(status_code=400, detail="Invalid internal MCP authenticate payload") 

7063 

7064 method = str(payload.get("method") or "GET").upper() 

7065 path = payload.get("path") 

7066 query_string = payload.get("queryString", "") 

7067 forwarded_headers = payload.get("headers", {}) 

7068 client_ip = payload.get("clientIp") 

7069 

7070 if not isinstance(path, str) or not path: 

7071 raise HTTPException(status_code=400, detail="Internal MCP authenticate payload requires path") 

7072 if not isinstance(query_string, str): 

7073 raise HTTPException(status_code=400, detail="Internal MCP authenticate payload queryString must be a string") 

7074 if not isinstance(forwarded_headers, dict) or not all(isinstance(name, str) and isinstance(value, str) for name, value in forwarded_headers.items()): 

7075 raise HTTPException(status_code=400, detail="Internal MCP authenticate payload headers must be a string map") 

7076 if client_ip is not None and not isinstance(client_ip, str): 

7077 raise HTTPException(status_code=400, detail="Internal MCP authenticate payload clientIp must be a string") 

7078 

7079 error_response, auth_context = await _run_internal_mcp_authentication( 

7080 method=method, 

7081 path=path, 

7082 query_string=query_string, 

7083 headers=forwarded_headers, 

7084 client_ip=client_ip, 

7085 ) 

7086 if error_response is not None: 

7087 return error_response 

7088 

7089 return ORJSONResponse(status_code=200, content={"authContext": auth_context}) 

7090 

7091 

7092@utility_router.post("/_internal/mcp/rpc/") 

7093@utility_router.post("/_internal/mcp/rpc") 

7094async def handle_internal_mcp_rpc(request: Request): 

7095 """Handle trusted MCP dispatch forwarded from the local Rust runtime. 

7096 

7097 Args: 

7098 request: Trusted internal MCP request from the Rust runtime. 

7099 

7100 Returns: 

7101 JSON-RPC response from the shared authenticated RPC dispatcher. 

7102 

7103 Raises: 

7104 Exception: Propagated after rolling back the local database session. 

7105 """ 

7106 user = _build_internal_mcp_forwarded_user(request) 

7107 db = SessionLocal() 

7108 try: 

7109 response = await _handle_rpc_authenticated(request, db=db, user=user) 

7110 if db.is_active and db.in_transaction() is not None: 

7111 db.commit() 

7112 return response 

7113 except Exception: 

7114 try: 

7115 db.rollback() 

7116 except Exception: 

7117 try: 

7118 db.invalidate() 

7119 except Exception: 

7120 pass # nosec B110 - Best effort cleanup on connection failure 

7121 raise 

7122 finally: 

7123 db.close() 

7124 

7125 

7126@utility_router.post("/_internal/mcp/initialize/") 

7127@utility_router.post("/_internal/mcp/initialize") 

7128async def handle_internal_mcp_initialize(request: Request): 

7129 """Handle trusted MCP initialize requests forwarded from the local Rust runtime. 

7130 

7131 Args: 

7132 request: Trusted internal MCP initialize request. 

7133 

7134 Returns: 

7135 JSON-RPC initialize response payload. 

7136 """ 

7137 user = _build_internal_mcp_forwarded_user(request) 

7138 req_id = None 

7139 try: 

7140 try: 

7141 body = orjson.loads(await request.body()) 

7142 except orjson.JSONDecodeError: 

7143 return ORJSONResponse( 

7144 status_code=400, 

7145 content={ 

7146 "jsonrpc": "2.0", 

7147 "error": {"code": -32700, "message": "Parse error"}, 

7148 "id": None, 

7149 }, 

7150 ) 

7151 

7152 req_id = body.get("id") 

7153 if req_id is None: 

7154 req_id = str(uuid.uuid4()) 

7155 

7156 if body.get("method") != "initialize": 

7157 return ORJSONResponse( 

7158 status_code=400, 

7159 content={ 

7160 "jsonrpc": "2.0", 

7161 "error": {"code": -32600, "message": "Invalid Request"}, 

7162 "id": req_id, 

7163 }, 

7164 ) 

7165 

7166 params = body.get("params", {}) 

7167 if not isinstance(params, dict): 

7168 params = {} 

7169 

7170 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7171 if server_id: 

7172 _enforce_internal_mcp_server_scope(request, server_id) 

7173 else: 

7174 server_id = params.get("server_id") 

7175 

7176 result = await _execute_rpc_initialize( 

7177 request, 

7178 user, 

7179 params=params, 

7180 server_id=server_id, 

7181 mcp_session_id=request.headers.get("mcp-session-id") or request.headers.get("x-mcp-session-id"), 

7182 ) 

7183 return ORJSONResponse(content={"jsonrpc": "2.0", "result": result, "id": req_id}) 

7184 except JSONRPCError as exc: 

7185 error = exc.to_dict() 

7186 return ORJSONResponse(content={"jsonrpc": "2.0", "error": error["error"], "id": req_id}) 

7187 except Exception as exc: 

7188 logger.error("Internal MCP initialize error: %s", exc) 

7189 return ORJSONResponse( 

7190 content={ 

7191 "jsonrpc": "2.0", 

7192 "error": {"code": -32000, "message": "Internal error", "data": str(exc)}, 

7193 "id": req_id, 

7194 } 

7195 ) 

7196 

7197 

7198@utility_router.delete("/_internal/mcp/session/") 

7199@utility_router.delete("/_internal/mcp/session") 

7200async def handle_internal_mcp_session_delete(request: Request): 

7201 """Handle trusted MCP session teardown forwarded from the local Rust runtime. 

7202 

7203 Args: 

7204 request: Trusted internal MCP session-delete request. 

7205 

7206 Returns: 

7207 Empty HTTP response indicating the session was removed. 

7208 """ 

7209 _build_internal_mcp_forwarded_user(request) 

7210 auth_context = _get_internal_mcp_auth_context(request) or {} 

7211 mcp_session_id = request.headers.get("mcp-session-id") or request.headers.get("x-mcp-session-id") 

7212 if not mcp_session_id: 

7213 return ORJSONResponse(status_code=400, content={"detail": "mcp-session-id header is required"}) 

7214 

7215 if auth_context.get("_rust_session_validated") is not True: 

7216 session_allowed, deny_status, deny_detail = await _validate_streamable_session_access( 

7217 mcp_session_id=mcp_session_id, 

7218 user_context=auth_context, 

7219 ) 

7220 if not session_allowed: 

7221 return ORJSONResponse(status_code=deny_status, content={"detail": deny_detail}) 

7222 

7223 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7224 if server_id: 

7225 _enforce_internal_mcp_server_scope(request, server_id) 

7226 

7227 await session_registry.remove_session(mcp_session_id) 

7228 

7229 if settings.mcpgateway_session_affinity_enabled: 

7230 try: 

7231 # First-Party 

7232 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool # pylint: disable=import-outside-toplevel 

7233 

7234 pool = get_mcp_session_pool() 

7235 await pool.cleanup_streamable_http_session_owner(mcp_session_id) 

7236 except RuntimeError: 

7237 pass 

7238 

7239 return Response(status_code=204) 

7240 

7241 

7242@utility_router.post("/_internal/mcp/notifications/initialized/") 

7243@utility_router.post("/_internal/mcp/notifications/initialized") 

7244async def handle_internal_mcp_notifications_initialized(request: Request): 

7245 """Handle trusted MCP notifications/initialized requests from the local Rust runtime. 

7246 

7247 Args: 

7248 request: Trusted internal MCP notification request. 

7249 

7250 Returns: 

7251 Empty HTTP response acknowledging the notification. 

7252 

7253 Raises: 

7254 HTTPException: If trusted server-scope validation fails. 

7255 """ 

7256 _build_internal_mcp_forwarded_user(request) 

7257 req_id = None 

7258 try: 

7259 try: 

7260 body = orjson.loads(await request.body()) 

7261 except orjson.JSONDecodeError: 

7262 return ORJSONResponse( 

7263 status_code=400, 

7264 content={ 

7265 "jsonrpc": "2.0", 

7266 "error": {"code": -32700, "message": "Parse error"}, 

7267 "id": None, 

7268 }, 

7269 ) 

7270 

7271 req_id = body.get("id") 

7272 if body.get("method") != "notifications/initialized": 

7273 return ORJSONResponse( 

7274 status_code=400, 

7275 content={ 

7276 "jsonrpc": "2.0", 

7277 "error": {"code": -32600, "message": "Invalid Request"}, 

7278 "id": req_id, 

7279 }, 

7280 ) 

7281 

7282 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7283 if server_id: 

7284 _enforce_internal_mcp_server_scope(request, server_id) 

7285 

7286 logger.info("Client initialized") 

7287 await logging_service.notify("Client initialized", LogLevel.INFO) 

7288 return Response(status_code=status.HTTP_204_NO_CONTENT) 

7289 except HTTPException: 

7290 raise 

7291 except Exception as exc: 

7292 logger.error("Internal MCP notifications/initialized error: %s", exc) 

7293 return ORJSONResponse( 

7294 content={ 

7295 "jsonrpc": "2.0", 

7296 "error": {"code": -32000, "message": "Internal error", "data": str(exc)}, 

7297 "id": req_id, 

7298 } 

7299 ) 

7300 

7301 

7302@utility_router.post("/_internal/mcp/notifications/message/") 

7303@utility_router.post("/_internal/mcp/notifications/message") 

7304async def handle_internal_mcp_notifications_message(request: Request): 

7305 """Handle trusted MCP notifications/message requests from the local Rust runtime. 

7306 

7307 Args: 

7308 request: Trusted internal MCP notification request. 

7309 

7310 Returns: 

7311 Empty HTTP response acknowledging the notification. 

7312 

7313 Raises: 

7314 HTTPException: If trusted server-scope validation fails. 

7315 """ 

7316 _build_internal_mcp_forwarded_user(request) 

7317 req_id = None 

7318 try: 

7319 try: 

7320 body = orjson.loads(await request.body()) 

7321 except orjson.JSONDecodeError: 

7322 return ORJSONResponse( 

7323 status_code=400, 

7324 content={ 

7325 "jsonrpc": "2.0", 

7326 "error": {"code": -32700, "message": "Parse error"}, 

7327 "id": None, 

7328 }, 

7329 ) 

7330 

7331 req_id = body.get("id") 

7332 if body.get("method") != "notifications/message": 

7333 return ORJSONResponse( 

7334 status_code=400, 

7335 content={ 

7336 "jsonrpc": "2.0", 

7337 "error": {"code": -32600, "message": "Invalid Request"}, 

7338 "id": req_id, 

7339 }, 

7340 ) 

7341 

7342 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7343 if server_id: 

7344 _enforce_internal_mcp_server_scope(request, server_id) 

7345 

7346 params = body.get("params", {}) 

7347 if not isinstance(params, dict): 

7348 params = {} 

7349 

7350 await logging_service.notify( 

7351 params.get("data"), 

7352 LogLevel(params.get("level", "info")), 

7353 params.get("logger"), 

7354 ) 

7355 return Response(status_code=status.HTTP_204_NO_CONTENT) 

7356 except HTTPException: 

7357 raise 

7358 except Exception as exc: 

7359 logger.error("Internal MCP notifications/message error: %s", exc) 

7360 return ORJSONResponse( 

7361 content={ 

7362 "jsonrpc": "2.0", 

7363 "error": {"code": -32000, "message": "Internal error", "data": str(exc)}, 

7364 "id": req_id, 

7365 } 

7366 ) 

7367 

7368 

7369@utility_router.post("/_internal/mcp/notifications/cancelled/") 

7370@utility_router.post("/_internal/mcp/notifications/cancelled") 

7371async def handle_internal_mcp_notifications_cancelled(request: Request): 

7372 """Handle trusted MCP notifications/cancelled requests from the local Rust runtime. 

7373 

7374 Args: 

7375 request: Trusted internal MCP cancellation notification. 

7376 

7377 Returns: 

7378 Empty HTTP response acknowledging the cancellation. 

7379 

7380 Raises: 

7381 HTTPException: If cancellation authorization or trusted scope validation fails. 

7382 """ 

7383 user = _build_internal_mcp_forwarded_user(request) 

7384 req_id = None 

7385 try: 

7386 try: 

7387 body = orjson.loads(await request.body()) 

7388 except orjson.JSONDecodeError: 

7389 return ORJSONResponse( 

7390 status_code=400, 

7391 content={ 

7392 "jsonrpc": "2.0", 

7393 "error": {"code": -32700, "message": "Parse error"}, 

7394 "id": None, 

7395 }, 

7396 ) 

7397 

7398 req_id = body.get("id") 

7399 if body.get("method") != "notifications/cancelled": 

7400 return ORJSONResponse( 

7401 status_code=400, 

7402 content={ 

7403 "jsonrpc": "2.0", 

7404 "error": {"code": -32600, "message": "Invalid Request"}, 

7405 "id": req_id, 

7406 }, 

7407 ) 

7408 

7409 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7410 if server_id: 

7411 _enforce_internal_mcp_server_scope(request, server_id) 

7412 

7413 params = body.get("params", {}) 

7414 if not isinstance(params, dict): 

7415 params = {} 

7416 

7417 raw_request_id = params.get("requestId") 

7418 request_id = str(raw_request_id) if raw_request_id is not None else None 

7419 reason = params.get("reason") 

7420 logger.info("Request cancelled: %s, reason: %s", request_id, reason) 

7421 if request_id is not None: 

7422 await _authorize_run_cancellation(request, user, request_id, as_jsonrpc_error=False) 

7423 await cancellation_service.cancel_run(request_id, reason=reason) 

7424 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO) 

7425 return Response(status_code=status.HTTP_204_NO_CONTENT) 

7426 except HTTPException: 

7427 raise 

7428 except Exception as exc: 

7429 logger.error("Internal MCP notifications/cancelled error: %s", exc) 

7430 return ORJSONResponse( 

7431 content={ 

7432 "jsonrpc": "2.0", 

7433 "error": {"code": -32000, "message": "Internal error", "data": str(exc)}, 

7434 "id": req_id, 

7435 } 

7436 ) 

7437 

7438 

7439@utility_router.post("/_internal/mcp/tools/list/") 

7440@utility_router.post("/_internal/mcp/tools/list") 

7441async def handle_internal_mcp_tools_list(request: Request): 

7442 """Handle trusted server-scoped tools/list requests forwarded from the Rust runtime. 

7443 

7444 Args: 

7445 request: Trusted internal MCP tools/list request. 

7446 

7447 Returns: 

7448 MCP tools/list response payload for the requested virtual server. 

7449 

7450 Raises: 

7451 HTTPException: If the trusted server scope is missing or invalid. 

7452 """ 

7453 server_id = request.headers.get("x-contextforge-server-id") 

7454 if not server_id: 

7455 raise HTTPException(status_code=400, detail="Missing trusted MCP server scope") 

7456 

7457 db = SessionLocal() 

7458 try: 

7459 user = await _authorize_internal_mcp_request( 

7460 request, 

7461 db, 

7462 permission="tools.read", 

7463 method="tools/list", 

7464 server_id=server_id, 

7465 ) 

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

7467 if is_admin and token_teams is None: 

7468 user_email = None 

7469 token_teams = None 

7470 elif token_teams is None: 

7471 token_teams = [] 

7472 

7473 tools = await tool_service.list_server_mcp_tool_definitions( 

7474 db, 

7475 server_id, 

7476 user_email=user_email, 

7477 token_teams=token_teams, 

7478 ) 

7479 return ORJSONResponse(content={"tools": tools}) 

7480 except HTTPException: 

7481 try: 

7482 db.rollback() 

7483 except Exception: 

7484 try: 

7485 db.invalidate() 

7486 except Exception: 

7487 pass # nosec B110 - Best effort cleanup on connection failure 

7488 raise 

7489 except JSONRPCError as exc: 

7490 return ORJSONResponse(status_code=403, content={"code": exc.code, "message": exc.message, "data": exc.data}) 

7491 except Exception as exc: 

7492 try: 

7493 db.rollback() 

7494 except Exception: 

7495 try: 

7496 db.invalidate() 

7497 except Exception: 

7498 pass # nosec B110 - Best effort cleanup on connection failure 

7499 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)}) 

7500 finally: 

7501 db.close() 

7502 

7503 

7504@utility_router.post("/_internal/mcp/resources/list/") 

7505@utility_router.post("/_internal/mcp/resources/list") 

7506async def handle_internal_mcp_resources_list(request: Request): 

7507 """Handle trusted resources/list requests forwarded from the Rust runtime. 

7508 

7509 Args: 

7510 request: Trusted internal MCP resources/list request. 

7511 

7512 Returns: 

7513 MCP resources/list response payload. 

7514 """ 

7515 db = SessionLocal() 

7516 req_id = None 

7517 try: 

7518 user = _build_internal_mcp_forwarded_user(request) 

7519 try: 

7520 body = orjson.loads(await request.body()) 

7521 except orjson.JSONDecodeError: 

7522 return ORJSONResponse( 

7523 status_code=400, 

7524 content={ 

7525 "jsonrpc": "2.0", 

7526 "error": {"code": -32700, "message": "Parse error"}, 

7527 "id": None, 

7528 }, 

7529 ) 

7530 

7531 req_id = body.get("id") if isinstance(body, dict) else None 

7532 if not isinstance(body, dict) or body.get("method") != "resources/list": 

7533 return ORJSONResponse( 

7534 status_code=400, 

7535 content={ 

7536 "jsonrpc": "2.0", 

7537 "error": {"code": -32600, "message": "Invalid Request"}, 

7538 "id": req_id, 

7539 }, 

7540 ) 

7541 

7542 params = body.get("params", {}) 

7543 if not isinstance(params, dict): 

7544 params = {} 

7545 

7546 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7547 if server_id: 

7548 _enforce_internal_mcp_server_scope(request, server_id) 

7549 else: 

7550 server_id = params.get("server_id") 

7551 cursor = params.get("cursor") 

7552 

7553 await _authorize_internal_mcp_request( 

7554 request, 

7555 db, 

7556 permission="resources.read", 

7557 method="resources/list", 

7558 server_id=server_id, 

7559 ) 

7560 

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

7562 if is_admin and token_teams is None: 

7563 user_email = None 

7564 token_teams = None 

7565 elif token_teams is None: 

7566 token_teams = [] 

7567 

7568 if server_id: 

7569 resources = await resource_service.list_server_resources( 

7570 db, 

7571 server_id, 

7572 user_email=user_email, 

7573 token_teams=token_teams, 

7574 ) 

7575 payload = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]} 

7576 else: 

7577 resources, next_cursor = await resource_service.list_resources( 

7578 db, 

7579 cursor=cursor, 

7580 limit=0, 

7581 user_email=user_email, 

7582 token_teams=token_teams, 

7583 ) 

7584 payload = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]} 

7585 if next_cursor: 

7586 payload["nextCursor"] = next_cursor 

7587 

7588 if db.is_active and db.in_transaction() is not None: 

7589 db.commit() 

7590 return ORJSONResponse(content=payload) 

7591 except JSONRPCError as exc: 

7592 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

7593 except Exception as exc: 

7594 try: 

7595 db.rollback() 

7596 except Exception: 

7597 try: 

7598 db.invalidate() 

7599 except Exception: 

7600 pass # nosec B110 - Best effort cleanup on connection failure 

7601 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)}) 

7602 finally: 

7603 db.close() 

7604 

7605 

7606@utility_router.post("/_internal/mcp/resources/read/") 

7607@utility_router.post("/_internal/mcp/resources/read") 

7608async def handle_internal_mcp_resources_read(request: Request): 

7609 """Handle trusted resources/read requests forwarded from the Rust runtime. 

7610 

7611 Args: 

7612 request: Trusted internal MCP resources/read request. 

7613 

7614 Returns: 

7615 MCP resources/read response payload. 

7616 """ 

7617 db = SessionLocal() 

7618 req_id = None 

7619 uri = None 

7620 try: 

7621 user = _build_internal_mcp_forwarded_user(request) 

7622 try: 

7623 body = orjson.loads(await request.body()) 

7624 except orjson.JSONDecodeError: 

7625 return ORJSONResponse( 

7626 status_code=400, 

7627 content={ 

7628 "jsonrpc": "2.0", 

7629 "error": {"code": -32700, "message": "Parse error"}, 

7630 "id": None, 

7631 }, 

7632 ) 

7633 

7634 req_id = body.get("id") if isinstance(body, dict) else None 

7635 if not isinstance(body, dict) or body.get("method") != "resources/read": 

7636 return ORJSONResponse( 

7637 status_code=400, 

7638 content={ 

7639 "jsonrpc": "2.0", 

7640 "error": {"code": -32600, "message": "Invalid Request"}, 

7641 "id": req_id, 

7642 }, 

7643 ) 

7644 

7645 params = body.get("params", {}) 

7646 if not isinstance(params, dict): 

7647 params = {} 

7648 

7649 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7650 if server_id: 

7651 _enforce_internal_mcp_server_scope(request, server_id) 

7652 else: 

7653 server_id = params.get("server_id") 

7654 

7655 await _authorize_internal_mcp_request( 

7656 request, 

7657 db, 

7658 permission="resources.read", 

7659 method="resources/read", 

7660 server_id=server_id, 

7661 ) 

7662 

7663 uri = params.get("uri") 

7664 request_id = params.get("requestId") 

7665 meta_data = params.get("_meta") 

7666 if not uri: 

7667 return ORJSONResponse( 

7668 status_code=400, 

7669 content={ 

7670 "code": -32602, 

7671 "message": "Missing resource URI in parameters", 

7672 "data": params, 

7673 }, 

7674 ) 

7675 

7676 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user) 

7677 if auth_is_admin and auth_token_teams is None: 

7678 auth_user_email = None 

7679 elif auth_token_teams is None: 

7680 auth_token_teams = [] 

7681 

7682 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

7683 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

7684 result = await resource_service.read_resource( 

7685 db, 

7686 resource_uri=uri, 

7687 request_id=request_id, 

7688 user=auth_user_email, 

7689 server_id=server_id, 

7690 token_teams=auth_token_teams, 

7691 plugin_context_table=plugin_context_table, 

7692 plugin_global_context=plugin_global_context, 

7693 meta_data=meta_data, 

7694 ) 

7695 # First-Party 

7696 from mcpgateway.common.models import ResourceContent # pylint: disable=import-outside-toplevel 

7697 

7698 if isinstance(result, ResourceContent): 

7699 normalized_content = {"uri": result.uri} 

7700 if result.mime_type: 

7701 normalized_content["mimeType"] = result.mime_type 

7702 if result.text is not None: 

7703 normalized_content["text"] = result.text 

7704 elif result.blob is not None: 

7705 normalized_content["blob"] = base64.b64encode(result.blob).decode("ascii") 

7706 payload = {"contents": [normalized_content]} 

7707 elif hasattr(result, "model_dump"): 

7708 payload = {"contents": [result.model_dump(by_alias=True, exclude_none=True)]} 

7709 else: 

7710 payload = {"contents": [result]} 

7711 

7712 if db.is_active and db.in_transaction() is not None: 

7713 db.commit() 

7714 return ORJSONResponse(content=payload) 

7715 except ResourceNotFoundError as exc: 

7716 return ORJSONResponse( 

7717 status_code=404, 

7718 content={ 

7719 "code": -32002, 

7720 "message": str(exc), 

7721 "data": {"uri": uri} if uri else None, 

7722 }, 

7723 ) 

7724 except ResourceError as exc: 

7725 return ORJSONResponse( 

7726 status_code=400, 

7727 content={ 

7728 "code": -32602, 

7729 "message": str(exc), 

7730 "data": {"uri": uri} if uri else None, 

7731 }, 

7732 ) 

7733 except JSONRPCError as exc: 

7734 status_code = 403 if exc.code == -32003 else 400 

7735 return ORJSONResponse(status_code=status_code, content=exc.to_dict()["error"]) 

7736 except Exception as exc: 

7737 try: 

7738 db.rollback() 

7739 except Exception: 

7740 try: 

7741 db.invalidate() 

7742 except Exception: 

7743 pass # nosec B110 - Best effort cleanup on connection failure 

7744 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)}) 

7745 finally: 

7746 db.close() 

7747 

7748 

7749@utility_router.post("/_internal/mcp/resources/subscribe/") 

7750@utility_router.post("/_internal/mcp/resources/subscribe") 

7751async def handle_internal_mcp_resources_subscribe(request: Request): 

7752 """Handle trusted resources/subscribe requests forwarded from the Rust runtime. 

7753 

7754 Args: 

7755 request: Trusted internal MCP resources/subscribe request. 

7756 

7757 Returns: 

7758 Empty JSON response confirming the subscription. 

7759 """ 

7760 db = SessionLocal() 

7761 req_id = None 

7762 try: 

7763 user = _build_internal_mcp_forwarded_user(request) 

7764 try: 

7765 body = orjson.loads(await request.body()) 

7766 except orjson.JSONDecodeError: 

7767 return ORJSONResponse( 

7768 status_code=400, 

7769 content={ 

7770 "jsonrpc": "2.0", 

7771 "error": {"code": -32700, "message": "Parse error"}, 

7772 "id": None, 

7773 }, 

7774 ) 

7775 

7776 req_id = body.get("id") if isinstance(body, dict) else None 

7777 if not isinstance(body, dict) or body.get("method") != "resources/subscribe": 

7778 return ORJSONResponse( 

7779 status_code=400, 

7780 content={ 

7781 "jsonrpc": "2.0", 

7782 "error": {"code": -32600, "message": "Invalid Request"}, 

7783 "id": req_id, 

7784 }, 

7785 ) 

7786 

7787 params = body.get("params", {}) 

7788 if not isinstance(params, dict): 

7789 params = {} 

7790 

7791 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7792 if server_id: 

7793 _enforce_internal_mcp_server_scope(request, server_id) 

7794 

7795 await _authorize_internal_mcp_request( 

7796 request, 

7797 db, 

7798 permission="resources.read", 

7799 method="resources/subscribe", 

7800 server_id=server_id, 

7801 ) 

7802 

7803 uri = params.get("uri") 

7804 if not uri: 

7805 return ORJSONResponse( 

7806 status_code=400, 

7807 content={ 

7808 "code": -32602, 

7809 "message": "Missing resource URI in parameters", 

7810 "data": params, 

7811 }, 

7812 ) 

7813 

7814 access_user_email, access_token_teams = _get_scoped_resource_access_context(request, user) 

7815 user_email = get_user_email(user) 

7816 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email) 

7817 await resource_service.subscribe_resource( 

7818 db, 

7819 subscription, 

7820 user_email=access_user_email, 

7821 token_teams=access_token_teams, 

7822 ) 

7823 if db.is_active and db.in_transaction() is not None: 

7824 db.commit() 

7825 return ORJSONResponse(content={}) 

7826 except ResourceNotFoundError as exc: 

7827 return ORJSONResponse( 

7828 status_code=404, 

7829 content={"code": -32002, "message": str(exc), "data": None}, 

7830 ) 

7831 except PermissionError: 

7832 return ORJSONResponse( 

7833 status_code=403, 

7834 content={"code": -32003, "message": _ACCESS_DENIED_MSG, "data": {"method": "resources/subscribe"}}, 

7835 ) 

7836 except JSONRPCError as exc: 

7837 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

7838 except Exception as exc: 

7839 try: 

7840 db.rollback() 

7841 except Exception: 

7842 try: 

7843 db.invalidate() 

7844 except Exception: 

7845 pass # nosec B110 - Best effort cleanup on connection failure 

7846 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)}) 

7847 finally: 

7848 db.close() 

7849 

7850 

7851@utility_router.post("/_internal/mcp/resources/unsubscribe/") 

7852@utility_router.post("/_internal/mcp/resources/unsubscribe") 

7853async def handle_internal_mcp_resources_unsubscribe(request: Request): 

7854 """Handle trusted resources/unsubscribe requests forwarded from the Rust runtime. 

7855 

7856 Args: 

7857 request: Trusted internal MCP resources/unsubscribe request. 

7858 

7859 Returns: 

7860 Empty JSON response confirming the unsubscription. 

7861 """ 

7862 db = SessionLocal() 

7863 req_id = None 

7864 try: 

7865 user = _build_internal_mcp_forwarded_user(request) 

7866 try: 

7867 body = orjson.loads(await request.body()) 

7868 except orjson.JSONDecodeError: 

7869 return ORJSONResponse( 

7870 status_code=400, 

7871 content={ 

7872 "jsonrpc": "2.0", 

7873 "error": {"code": -32700, "message": "Parse error"}, 

7874 "id": None, 

7875 }, 

7876 ) 

7877 

7878 req_id = body.get("id") if isinstance(body, dict) else None 

7879 if not isinstance(body, dict) or body.get("method") != "resources/unsubscribe": 

7880 return ORJSONResponse( 

7881 status_code=400, 

7882 content={ 

7883 "jsonrpc": "2.0", 

7884 "error": {"code": -32600, "message": "Invalid Request"}, 

7885 "id": req_id, 

7886 }, 

7887 ) 

7888 

7889 params = body.get("params", {}) 

7890 if not isinstance(params, dict): 

7891 params = {} 

7892 

7893 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7894 if server_id: 

7895 _enforce_internal_mcp_server_scope(request, server_id) 

7896 

7897 await _authorize_internal_mcp_request( 

7898 request, 

7899 db, 

7900 permission="resources.read", 

7901 method="resources/unsubscribe", 

7902 server_id=server_id, 

7903 ) 

7904 

7905 uri = params.get("uri") 

7906 if not uri: 

7907 return ORJSONResponse( 

7908 status_code=400, 

7909 content={ 

7910 "code": -32602, 

7911 "message": "Missing resource URI in parameters", 

7912 "data": params, 

7913 }, 

7914 ) 

7915 

7916 user_email = get_user_email(user) 

7917 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email) 

7918 await resource_service.unsubscribe_resource(db, subscription) 

7919 if db.is_active and db.in_transaction() is not None: 

7920 db.commit() 

7921 return ORJSONResponse(content={}) 

7922 except JSONRPCError as exc: 

7923 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

7924 except Exception as exc: 

7925 try: 

7926 db.rollback() 

7927 except Exception: 

7928 try: 

7929 db.invalidate() 

7930 except Exception: 

7931 pass # nosec B110 - Best effort cleanup on connection failure 

7932 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)}) 

7933 finally: 

7934 db.close() 

7935 

7936 

7937@utility_router.post("/_internal/mcp/resources/templates/list/") 

7938@utility_router.post("/_internal/mcp/resources/templates/list") 

7939async def handle_internal_mcp_resource_templates_list(request: Request): 

7940 """Handle trusted resources/templates/list requests forwarded from the Rust runtime. 

7941 

7942 Args: 

7943 request: Trusted internal MCP resources/templates/list request. 

7944 

7945 Returns: 

7946 MCP resources/templates/list response payload. 

7947 

7948 Raises: 

7949 Exception: Propagated after best-effort rollback when unexpected failures occur. 

7950 """ 

7951 db = SessionLocal() 

7952 req_id = None 

7953 try: 

7954 user = _build_internal_mcp_forwarded_user(request) 

7955 try: 

7956 body = orjson.loads(await request.body()) 

7957 except orjson.JSONDecodeError: 

7958 return ORJSONResponse( 

7959 status_code=400, 

7960 content={ 

7961 "jsonrpc": "2.0", 

7962 "error": {"code": -32700, "message": "Parse error"}, 

7963 "id": None, 

7964 }, 

7965 ) 

7966 

7967 req_id = body.get("id") if isinstance(body, dict) else None 

7968 if not isinstance(body, dict) or body.get("method") != "resources/templates/list": 

7969 return ORJSONResponse( 

7970 status_code=400, 

7971 content={ 

7972 "jsonrpc": "2.0", 

7973 "error": {"code": -32600, "message": "Invalid Request"}, 

7974 "id": req_id, 

7975 }, 

7976 ) 

7977 

7978 params = body.get("params", {}) 

7979 if not isinstance(params, dict): 

7980 params = {} 

7981 

7982 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

7983 if server_id: 

7984 _enforce_internal_mcp_server_scope(request, server_id) 

7985 else: 

7986 server_id = params.get("server_id") 

7987 

7988 await _authorize_internal_mcp_request( 

7989 request, 

7990 db, 

7991 permission="resources.read", 

7992 method="resources/templates/list", 

7993 server_id=server_id, 

7994 ) 

7995 

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

7997 if is_admin and token_teams is None: 

7998 token_teams = None 

7999 elif token_teams is None: 

8000 token_teams = [] 

8001 

8002 resource_templates = await resource_service.list_resource_templates( 

8003 db, 

8004 user_email=user_email, 

8005 token_teams=token_teams, 

8006 server_id=server_id, 

8007 ) 

8008 payload = {"resourceTemplates": [rt.model_dump(by_alias=True, exclude_none=True) for rt in resource_templates]} 

8009 

8010 if db.is_active and db.in_transaction() is not None: 

8011 db.commit() 

8012 return ORJSONResponse(content=payload) 

8013 except JSONRPCError as exc: 

8014 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

8015 except Exception: 

8016 try: 

8017 db.rollback() 

8018 except Exception: 

8019 try: 

8020 db.invalidate() 

8021 except Exception: 

8022 pass # nosec B110 - Best effort cleanup on connection failure 

8023 raise 

8024 finally: 

8025 db.close() 

8026 

8027 

8028@utility_router.post("/_internal/mcp/roots/list/") 

8029@utility_router.post("/_internal/mcp/roots/list") 

8030async def handle_internal_mcp_roots_list(request: Request): 

8031 """Handle trusted roots/list requests forwarded from the Rust runtime. 

8032 

8033 Args: 

8034 request: Trusted internal MCP roots/list request. 

8035 

8036 Returns: 

8037 MCP roots/list response payload. 

8038 

8039 Raises: 

8040 Exception: Propagated after best-effort rollback when unexpected failures occur. 

8041 """ 

8042 db = SessionLocal() 

8043 req_id = None 

8044 try: 

8045 _build_internal_mcp_forwarded_user(request) 

8046 try: 

8047 body = orjson.loads(await request.body()) 

8048 except orjson.JSONDecodeError: 

8049 return ORJSONResponse( 

8050 status_code=400, 

8051 content={ 

8052 "jsonrpc": "2.0", 

8053 "error": {"code": -32700, "message": "Parse error"}, 

8054 "id": None, 

8055 }, 

8056 ) 

8057 

8058 req_id = body.get("id") if isinstance(body, dict) else None 

8059 if not isinstance(body, dict) or body.get("method") != "roots/list": 

8060 return ORJSONResponse( 

8061 status_code=400, 

8062 content={ 

8063 "jsonrpc": "2.0", 

8064 "error": {"code": -32600, "message": "Invalid Request"}, 

8065 "id": req_id, 

8066 }, 

8067 ) 

8068 

8069 await _authorize_internal_mcp_request( 

8070 request, 

8071 db, 

8072 permission="admin.system_config", 

8073 method="roots/list", 

8074 server_id=None, 

8075 ) 

8076 roots = await root_service.list_roots() 

8077 payload = {"roots": [r.model_dump(by_alias=True, exclude_none=True) for r in roots]} 

8078 if db.is_active and db.in_transaction() is not None: 

8079 db.commit() 

8080 return ORJSONResponse(content=payload) 

8081 except JSONRPCError as exc: 

8082 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

8083 except Exception: 

8084 try: 

8085 db.rollback() 

8086 except Exception: 

8087 try: 

8088 db.invalidate() 

8089 except Exception: 

8090 pass # nosec B110 - Best effort cleanup on connection failure 

8091 raise 

8092 finally: 

8093 db.close() 

8094 

8095 

8096@utility_router.post("/_internal/mcp/completion/complete/") 

8097@utility_router.post("/_internal/mcp/completion/complete") 

8098async def handle_internal_mcp_completion_complete(request: Request): 

8099 """Handle trusted completion/complete requests forwarded from the Rust runtime. 

8100 

8101 Args: 

8102 request: Trusted internal MCP completion/complete request. 

8103 

8104 Returns: 

8105 MCP completion response payload. 

8106 """ 

8107 db = SessionLocal() 

8108 req_id = None 

8109 try: 

8110 user = _build_internal_mcp_forwarded_user(request) 

8111 try: 

8112 body = orjson.loads(await request.body()) 

8113 except orjson.JSONDecodeError: 

8114 return ORJSONResponse( 

8115 status_code=400, 

8116 content={ 

8117 "jsonrpc": "2.0", 

8118 "error": {"code": -32700, "message": "Parse error"}, 

8119 "id": None, 

8120 }, 

8121 ) 

8122 

8123 req_id = body.get("id") if isinstance(body, dict) else None 

8124 if not isinstance(body, dict) or body.get("method") != "completion/complete": 

8125 return ORJSONResponse( 

8126 status_code=400, 

8127 content={ 

8128 "jsonrpc": "2.0", 

8129 "error": {"code": -32600, "message": "Invalid Request"}, 

8130 "id": req_id, 

8131 }, 

8132 ) 

8133 

8134 params = body.get("params", {}) 

8135 if not isinstance(params, dict): 

8136 params = {} 

8137 

8138 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

8139 if server_id: 

8140 _enforce_internal_mcp_server_scope(request, server_id) 

8141 else: 

8142 server_id = params.get("server_id") 

8143 

8144 await _authorize_internal_mcp_request( 

8145 request, 

8146 db, 

8147 permission="tools.read", 

8148 method="completion/complete", 

8149 server_id=server_id, 

8150 ) 

8151 

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

8153 if is_admin and token_teams is None: 

8154 user_email = None 

8155 token_teams = None 

8156 elif token_teams is None: 

8157 token_teams = [] 

8158 

8159 payload = await completion_service.handle_completion( 

8160 db, 

8161 params, 

8162 user_email=user_email, 

8163 token_teams=token_teams, 

8164 ) 

8165 if db.is_active and db.in_transaction() is not None: 

8166 db.commit() 

8167 return ORJSONResponse(content=payload) 

8168 except JSONRPCError as exc: 

8169 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

8170 except Exception as exc: 

8171 try: 

8172 db.rollback() 

8173 except Exception: 

8174 try: 

8175 db.invalidate() 

8176 except Exception: 

8177 pass # nosec B110 - Best effort cleanup on connection failure 

8178 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)}) 

8179 finally: 

8180 db.close() 

8181 

8182 

8183@utility_router.post("/_internal/mcp/sampling/createMessage/") 

8184@utility_router.post("/_internal/mcp/sampling/createMessage") 

8185async def handle_internal_mcp_sampling_create_message(request: Request): 

8186 """Handle trusted sampling/createMessage requests forwarded from the Rust runtime. 

8187 

8188 Args: 

8189 request: Trusted internal MCP sampling/createMessage request. 

8190 

8191 Returns: 

8192 MCP sampling/createMessage response payload. 

8193 """ 

8194 db = SessionLocal() 

8195 req_id = None 

8196 try: 

8197 _build_internal_mcp_forwarded_user(request) 

8198 try: 

8199 body = orjson.loads(await request.body()) 

8200 except orjson.JSONDecodeError: 

8201 return ORJSONResponse( 

8202 status_code=400, 

8203 content={ 

8204 "jsonrpc": "2.0", 

8205 "error": {"code": -32700, "message": "Parse error"}, 

8206 "id": None, 

8207 }, 

8208 ) 

8209 

8210 req_id = body.get("id") if isinstance(body, dict) else None 

8211 if not isinstance(body, dict) or body.get("method") != "sampling/createMessage": 

8212 return ORJSONResponse( 

8213 status_code=400, 

8214 content={ 

8215 "jsonrpc": "2.0", 

8216 "error": {"code": -32600, "message": "Invalid Request"}, 

8217 "id": req_id, 

8218 }, 

8219 ) 

8220 

8221 if request.headers.get("x-contextforge-mcp-runtime") == "rust": 

8222 server_id = request.headers.get("x-contextforge-server-id") 

8223 if server_id: 

8224 _enforce_internal_mcp_server_scope(request, server_id) 

8225 

8226 params = body.get("params", {}) 

8227 if not isinstance(params, dict): 

8228 params = {} 

8229 

8230 payload = await sampling_handler.create_message(db, params) 

8231 if db.is_active and db.in_transaction() is not None: 

8232 db.commit() 

8233 return ORJSONResponse(content=payload) 

8234 except JSONRPCError as exc: 

8235 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

8236 except Exception as exc: 

8237 try: 

8238 db.rollback() 

8239 except Exception: 

8240 try: 

8241 db.invalidate() 

8242 except Exception: 

8243 pass # nosec B110 - Best effort cleanup on connection failure 

8244 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)}) 

8245 finally: 

8246 db.close() 

8247 

8248 

8249@utility_router.post("/_internal/mcp/logging/setLevel/") 

8250@utility_router.post("/_internal/mcp/logging/setLevel") 

8251async def handle_internal_mcp_logging_set_level(request: Request): 

8252 """Handle trusted logging/setLevel requests forwarded from the Rust runtime. 

8253 

8254 Args: 

8255 request: Trusted internal MCP logging/setLevel request. 

8256 

8257 Returns: 

8258 Empty JSON response confirming the new log level. 

8259 """ 

8260 db = SessionLocal() 

8261 req_id = None 

8262 try: 

8263 _build_internal_mcp_forwarded_user(request) 

8264 try: 

8265 body = orjson.loads(await request.body()) 

8266 except orjson.JSONDecodeError: 

8267 return ORJSONResponse( 

8268 status_code=400, 

8269 content={ 

8270 "jsonrpc": "2.0", 

8271 "error": {"code": -32700, "message": "Parse error"}, 

8272 "id": None, 

8273 }, 

8274 ) 

8275 

8276 req_id = body.get("id") if isinstance(body, dict) else None 

8277 if not isinstance(body, dict) or body.get("method") != "logging/setLevel": 

8278 return ORJSONResponse( 

8279 status_code=400, 

8280 content={ 

8281 "jsonrpc": "2.0", 

8282 "error": {"code": -32600, "message": "Invalid Request"}, 

8283 "id": req_id, 

8284 }, 

8285 ) 

8286 

8287 await _authorize_internal_mcp_request( 

8288 request, 

8289 db, 

8290 permission="admin.system_config", 

8291 method="logging/setLevel", 

8292 server_id=None, 

8293 ) 

8294 

8295 params = body.get("params", {}) 

8296 if not isinstance(params, dict): 

8297 params = {} 

8298 

8299 level = LogLevel(params.get("level")) 

8300 await logging_service.set_level(level) 

8301 if db.is_active and db.in_transaction() is not None: 

8302 db.commit() 

8303 return ORJSONResponse(content={}) 

8304 except JSONRPCError as exc: 

8305 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

8306 except Exception as exc: 

8307 try: 

8308 db.rollback() 

8309 except Exception: 

8310 try: 

8311 db.invalidate() 

8312 except Exception: 

8313 pass # nosec B110 - Best effort cleanup on connection failure 

8314 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)}) 

8315 finally: 

8316 db.close() 

8317 

8318 

8319@utility_router.post("/_internal/mcp/prompts/list/") 

8320@utility_router.post("/_internal/mcp/prompts/list") 

8321async def handle_internal_mcp_prompts_list(request: Request): 

8322 """Handle trusted prompts/list requests forwarded from the Rust runtime. 

8323 

8324 Args: 

8325 request: Trusted internal MCP prompts/list request. 

8326 

8327 Returns: 

8328 MCP prompts/list response payload. 

8329 

8330 Raises: 

8331 Exception: Propagated after best-effort rollback when unexpected failures occur. 

8332 """ 

8333 db = SessionLocal() 

8334 req_id = None 

8335 try: 

8336 user = _build_internal_mcp_forwarded_user(request) 

8337 try: 

8338 body = orjson.loads(await request.body()) 

8339 except orjson.JSONDecodeError: 

8340 return ORJSONResponse( 

8341 status_code=400, 

8342 content={ 

8343 "jsonrpc": "2.0", 

8344 "error": {"code": -32700, "message": "Parse error"}, 

8345 "id": None, 

8346 }, 

8347 ) 

8348 

8349 req_id = body.get("id") if isinstance(body, dict) else None 

8350 if not isinstance(body, dict) or body.get("method") != "prompts/list": 

8351 return ORJSONResponse( 

8352 status_code=400, 

8353 content={ 

8354 "jsonrpc": "2.0", 

8355 "error": {"code": -32600, "message": "Invalid Request"}, 

8356 "id": req_id, 

8357 }, 

8358 ) 

8359 

8360 params = body.get("params", {}) 

8361 if not isinstance(params, dict): 

8362 params = {} 

8363 

8364 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

8365 if server_id: 

8366 _enforce_internal_mcp_server_scope(request, server_id) 

8367 else: 

8368 server_id = params.get("server_id") 

8369 cursor = params.get("cursor") 

8370 

8371 await _authorize_internal_mcp_request( 

8372 request, 

8373 db, 

8374 permission="prompts.read", 

8375 method="prompts/list", 

8376 server_id=server_id, 

8377 ) 

8378 

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

8380 if is_admin and token_teams is None: 

8381 user_email = None 

8382 token_teams = None 

8383 elif token_teams is None: 

8384 token_teams = [] 

8385 

8386 if server_id: 

8387 prompts = await prompt_service.list_server_prompts( 

8388 db, 

8389 server_id, 

8390 cursor=cursor, 

8391 user_email=user_email, 

8392 token_teams=token_teams, 

8393 ) 

8394 payload = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]} 

8395 else: 

8396 prompts, next_cursor = await prompt_service.list_prompts( 

8397 db, 

8398 cursor=cursor, 

8399 limit=0, 

8400 user_email=user_email, 

8401 token_teams=token_teams, 

8402 ) 

8403 payload = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]} 

8404 if next_cursor: 

8405 payload["nextCursor"] = next_cursor 

8406 

8407 if db.is_active and db.in_transaction() is not None: 

8408 db.commit() 

8409 return ORJSONResponse(content=payload) 

8410 except JSONRPCError as exc: 

8411 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"]) 

8412 except Exception: 

8413 try: 

8414 db.rollback() 

8415 except Exception: 

8416 try: 

8417 db.invalidate() 

8418 except Exception: 

8419 pass # nosec B110 - Best effort cleanup on connection failure 

8420 raise 

8421 finally: 

8422 db.close() 

8423 

8424 

8425@utility_router.post("/_internal/mcp/prompts/get/") 

8426@utility_router.post("/_internal/mcp/prompts/get") 

8427async def handle_internal_mcp_prompts_get(request: Request): 

8428 """Handle trusted prompts/get requests forwarded from the Rust runtime. 

8429 

8430 Args: 

8431 request: Trusted internal MCP prompts/get request. 

8432 

8433 Returns: 

8434 MCP prompts/get response payload. 

8435 

8436 Raises: 

8437 Exception: Propagated after best-effort rollback when unexpected failures occur. 

8438 """ 

8439 db = SessionLocal() 

8440 req_id = None 

8441 name = None 

8442 try: 

8443 user = _build_internal_mcp_forwarded_user(request) 

8444 try: 

8445 body = orjson.loads(await request.body()) 

8446 except orjson.JSONDecodeError: 

8447 return ORJSONResponse( 

8448 status_code=400, 

8449 content={ 

8450 "jsonrpc": "2.0", 

8451 "error": {"code": -32700, "message": "Parse error"}, 

8452 "id": None, 

8453 }, 

8454 ) 

8455 

8456 req_id = body.get("id") if isinstance(body, dict) else None 

8457 if not isinstance(body, dict) or body.get("method") != "prompts/get": 

8458 return ORJSONResponse( 

8459 status_code=400, 

8460 content={ 

8461 "jsonrpc": "2.0", 

8462 "error": {"code": -32600, "message": "Invalid Request"}, 

8463 "id": req_id, 

8464 }, 

8465 ) 

8466 

8467 params = body.get("params", {}) 

8468 if not isinstance(params, dict): 

8469 params = {} 

8470 

8471 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None 

8472 if server_id: 

8473 _enforce_internal_mcp_server_scope(request, server_id) 

8474 else: 

8475 server_id = params.get("server_id") 

8476 

8477 await _authorize_internal_mcp_request( 

8478 request, 

8479 db, 

8480 permission="prompts.read", 

8481 method="prompts/get", 

8482 server_id=server_id, 

8483 ) 

8484 

8485 name = params.get("name") 

8486 arguments = params.get("arguments", {}) 

8487 meta_data = params.get("_meta") 

8488 if not name: 

8489 return ORJSONResponse( 

8490 status_code=400, 

8491 content={ 

8492 "code": -32602, 

8493 "message": "Missing prompt name in parameters", 

8494 "data": params, 

8495 }, 

8496 ) 

8497 

8498 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user) 

8499 if auth_is_admin and auth_token_teams is None: 

8500 auth_user_email = None 

8501 elif auth_token_teams is None: 

8502 auth_token_teams = [] 

8503 

8504 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

8505 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

8506 result = await prompt_service.get_prompt( 

8507 db, 

8508 name, 

8509 arguments, 

8510 user=auth_user_email, 

8511 server_id=server_id, 

8512 token_teams=auth_token_teams, 

8513 plugin_context_table=plugin_context_table, 

8514 plugin_global_context=plugin_global_context, 

8515 _meta_data=meta_data, 

8516 ) 

8517 payload = result.model_dump(by_alias=True, exclude_none=True) if hasattr(result, "model_dump") else result 

8518 

8519 if db.is_active and db.in_transaction() is not None: 

8520 db.commit() 

8521 return ORJSONResponse(content=payload) 

8522 except PromptNotFoundError as exc: 

8523 return ORJSONResponse( 

8524 status_code=404, 

8525 content={ 

8526 "code": -32002, 

8527 "message": str(exc), 

8528 "data": {"name": name} if name else None, 

8529 }, 

8530 ) 

8531 except PromptError as exc: 

8532 try: 

8533 if db.is_active and db.in_transaction() is not None: 

8534 db.rollback() 

8535 except Exception: 

8536 try: 

8537 db.invalidate() 

8538 except Exception: 

8539 pass # nosec B110 - Best effort cleanup on connection failure 

8540 return ORJSONResponse( 

8541 status_code=422, 

8542 content={ 

8543 "code": -32000, 

8544 "message": str(exc), 

8545 "data": {"name": name} if name else None, 

8546 }, 

8547 ) 

8548 except JSONRPCError as exc: 

8549 status_code = 403 if exc.code == -32003 else 400 

8550 return ORJSONResponse(status_code=status_code, content=exc.to_dict()["error"]) 

8551 except Exception: 

8552 try: 

8553 db.rollback() 

8554 except Exception: 

8555 try: 

8556 db.invalidate() 

8557 except Exception: 

8558 pass # nosec B110 - Best effort cleanup on connection failure 

8559 raise 

8560 finally: 

8561 db.close() 

8562 

8563 

8564@utility_router.post("/_internal/mcp/tools/list/authz/") 

8565@utility_router.post("/_internal/mcp/tools/list/authz") 

8566async def handle_internal_mcp_tools_list_authz(request: Request): 

8567 """Authorize trusted server-scoped tools/list requests for the Rust direct-DB path. 

8568 

8569 Args: 

8570 request: Trusted internal MCP authz request. 

8571 

8572 Returns: 

8573 Empty success response when the request is authorized. 

8574 """ 

8575 return await _authorize_internal_mcp_server_scoped_method( 

8576 request, 

8577 permission="tools.read", 

8578 method="tools/list", 

8579 ) 

8580 

8581 

8582async def _authorize_internal_mcp_server_scoped_method( 

8583 request: Request, 

8584 *, 

8585 permission: str, 

8586 method: str, 

8587) -> Response: 

8588 """Authorize a trusted server-scoped MCP method for Rust direct-path execution. 

8589 

8590 Args: 

8591 request: Trusted internal MCP authz request. 

8592 permission: Permission required for the target method. 

8593 method: MCP method name being authorized. 

8594 

8595 Returns: 

8596 Empty success response when the method is authorized and remains eligible 

8597 for Rust direct execution, or a JSON success payload instructing Rust to 

8598 forward the request to Python when plugin hooks require Python 

8599 execution. Returns a JSON error response when authorization fails. 

8600 

8601 Raises: 

8602 HTTPException: If the trusted server scope header is missing. 

8603 Exception: Propagated after best-effort rollback when unexpected failures occur. 

8604 """ 

8605 server_id = request.headers.get("x-contextforge-server-id") 

8606 if not server_id: 

8607 raise HTTPException(status_code=400, detail="Missing trusted MCP server scope") 

8608 

8609 db = SessionLocal() 

8610 try: 

8611 await _authorize_internal_mcp_request( 

8612 request, 

8613 db, 

8614 permission=permission, 

8615 method=method, 

8616 server_id=server_id, 

8617 ) 

8618 if db.is_active and db.in_transaction() is not None: 

8619 db.commit() 

8620 fallback_reason = _server_scoped_direct_execution_fallback_reason(method) 

8621 if fallback_reason: 

8622 return ORJSONResponse( 

8623 status_code=status.HTTP_200_OK, 

8624 content={ 

8625 "directExecutionEligible": False, 

8626 "fallbackReason": fallback_reason, 

8627 }, 

8628 ) 

8629 return Response(status_code=status.HTTP_204_NO_CONTENT) 

8630 except JSONRPCError as exc: 

8631 return ORJSONResponse(status_code=403, content={"code": exc.code, "message": exc.message, "data": exc.data}) 

8632 except Exception: 

8633 try: 

8634 db.rollback() 

8635 except Exception: 

8636 try: 

8637 db.invalidate() 

8638 except Exception: 

8639 pass # nosec B110 - Best effort cleanup on connection failure 

8640 raise 

8641 finally: 

8642 db.close() 

8643 

8644 

8645def _server_scoped_direct_execution_fallback_reason(method: str) -> Optional[str]: 

8646 """Return a direct-execution fallback reason for server-scoped Rust MCP calls. 

8647 

8648 This fail-closed helper lets Python remain the source of truth for plugin 

8649 semantics. Rust can safely execute DB-direct reads only when no relevant 

8650 prompt/resource hooks are configured. 

8651 

8652 Args: 

8653 method: MCP method name being considered for Rust direct execution. 

8654 

8655 Returns: 

8656 A stable fallback reason when Python must handle the request to preserve 

8657 plugin semantics, otherwise ``None``. 

8658 """ 

8659 if not plugin_manager: 

8660 return None 

8661 

8662 if method == "resources/read": 

8663 if plugin_manager.has_hooks_for(ResourceHookType.RESOURCE_PRE_FETCH) or plugin_manager.has_hooks_for(ResourceHookType.RESOURCE_POST_FETCH): 

8664 return "resource-hooks-configured" 

8665 if method == "prompts/get": 

8666 if plugin_manager.has_hooks_for(PromptHookType.PROMPT_PRE_FETCH) or plugin_manager.has_hooks_for(PromptHookType.PROMPT_POST_FETCH): 

8667 return "prompt-hooks-configured" 

8668 return None 

8669 

8670 

8671@utility_router.post("/_internal/mcp/resources/list/authz/") 

8672@utility_router.post("/_internal/mcp/resources/list/authz") 

8673async def handle_internal_mcp_resources_list_authz(request: Request): 

8674 """Authorize trusted server-scoped resources/list requests for Rust direct-path execution. 

8675 

8676 Args: 

8677 request: Trusted internal MCP authz request. 

8678 

8679 Returns: 

8680 Empty success response when the request is authorized. 

8681 """ 

8682 return await _authorize_internal_mcp_server_scoped_method( 

8683 request, 

8684 permission="resources.read", 

8685 method="resources/list", 

8686 ) 

8687 

8688 

8689@utility_router.post("/_internal/mcp/resources/read/authz/") 

8690@utility_router.post("/_internal/mcp/resources/read/authz") 

8691async def handle_internal_mcp_resources_read_authz(request: Request): 

8692 """Authorize trusted server-scoped resources/read requests for Rust direct-path execution. 

8693 

8694 Args: 

8695 request: Trusted internal MCP authz request. 

8696 

8697 Returns: 

8698 Empty success response when the request is authorized. 

8699 """ 

8700 return await _authorize_internal_mcp_server_scoped_method( 

8701 request, 

8702 permission="resources.read", 

8703 method="resources/read", 

8704 ) 

8705 

8706 

8707@utility_router.post("/_internal/mcp/resources/templates/list/authz/") 

8708@utility_router.post("/_internal/mcp/resources/templates/list/authz") 

8709async def handle_internal_mcp_resource_templates_list_authz(request: Request): 

8710 """Authorize trusted server-scoped resources/templates/list requests for Rust direct-path execution. 

8711 

8712 Args: 

8713 request: Trusted internal MCP authz request. 

8714 

8715 Returns: 

8716 Empty success response when the request is authorized. 

8717 """ 

8718 return await _authorize_internal_mcp_server_scoped_method( 

8719 request, 

8720 permission="resources.read", 

8721 method="resources/templates/list", 

8722 ) 

8723 

8724 

8725@utility_router.post("/_internal/mcp/prompts/list/authz/") 

8726@utility_router.post("/_internal/mcp/prompts/list/authz") 

8727async def handle_internal_mcp_prompts_list_authz(request: Request): 

8728 """Authorize trusted server-scoped prompts/list requests for Rust direct-path execution. 

8729 

8730 Args: 

8731 request: Trusted internal MCP authz request. 

8732 

8733 Returns: 

8734 Empty success response when the request is authorized. 

8735 """ 

8736 return await _authorize_internal_mcp_server_scoped_method( 

8737 request, 

8738 permission="prompts.read", 

8739 method="prompts/list", 

8740 ) 

8741 

8742 

8743@utility_router.post("/_internal/mcp/prompts/get/authz/") 

8744@utility_router.post("/_internal/mcp/prompts/get/authz") 

8745async def handle_internal_mcp_prompts_get_authz(request: Request): 

8746 """Authorize trusted server-scoped prompts/get requests for Rust direct-path execution. 

8747 

8748 Args: 

8749 request: Trusted internal MCP authz request. 

8750 

8751 Returns: 

8752 Empty success response when the request is authorized. 

8753 """ 

8754 return await _authorize_internal_mcp_server_scoped_method( 

8755 request, 

8756 permission="prompts.read", 

8757 method="prompts/get", 

8758 ) 

8759 

8760 

8761async def _maybe_forward_affinitized_rpc_request( 

8762 request: Request, 

8763 *, 

8764 method: str, 

8765 params: Dict[str, Any], 

8766 req_id: Any, 

8767 lowered_request_headers: Dict[str, str], 

8768) -> Optional[Dict[str, Any]]: 

8769 """Forward an MCP request to the owning worker when session affinity requires it. 

8770 

8771 Args: 

8772 request: Incoming RPC request. 

8773 method: MCP method name being executed. 

8774 params: Parsed JSON-RPC params payload. 

8775 req_id: JSON-RPC request identifier. 

8776 lowered_request_headers: Lower-cased request headers used for forwarding. 

8777 

8778 Returns: 

8779 Forwarded JSON-RPC response payload when affinity forwarding handled the 

8780 request, otherwise ``None`` so local execution can continue. 

8781 """ 

8782 request_headers = request.headers 

8783 rpc_client_host = getattr(getattr(request, "client", None), "host", None) 

8784 rpc_from_loopback = rpc_client_host in ("127.0.0.1", "::1") if rpc_client_host else False 

8785 mcp_session_id = request_headers.get("mcp-session-id") or request_headers.get("x-mcp-session-id") 

8786 is_internally_forwarded = rpc_from_loopback and request_headers.get("x-forwarded-internally") == "true" 

8787 

8788 if settings.mcpgateway_session_affinity_enabled and mcp_session_id and method != "initialize" and not is_internally_forwarded: 

8789 # First-Party 

8790 from mcpgateway.services.mcp_session_pool import MCPSessionPool, WORKER_ID # pylint: disable=import-outside-toplevel 

8791 

8792 if not MCPSessionPool.is_valid_mcp_session_id(mcp_session_id): 

8793 logger.debug("Invalid MCP session id for affinity forwarding, executing locally") 

8794 return None 

8795 

8796 session_short = mcp_session_id[:8] if len(mcp_session_id) >= 8 else mcp_session_id 

8797 logger.debug("[AFFINITY] Worker %s | Session %s... | Method: %s | RPC request received, checking affinity", WORKER_ID, session_short, method) 

8798 try: 

8799 # First-Party 

8800 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool # pylint: disable=import-outside-toplevel 

8801 

8802 pool = get_mcp_session_pool() 

8803 forwarded_response = await pool.forward_request_to_owner( 

8804 mcp_session_id, 

8805 {"method": method, "params": params, "headers": lowered_request_headers, "req_id": req_id}, 

8806 ) 

8807 if forwarded_response is not None: 

8808 logger.info("[AFFINITY] Worker %s | Session %s... | Method: %s | Forwarded response received", WORKER_ID, session_short, method) 

8809 if "error" in forwarded_response: 

8810 return {"jsonrpc": "2.0", "error": forwarded_response["error"], "id": req_id} 

8811 return {"jsonrpc": "2.0", "result": forwarded_response.get("result", {}), "id": req_id} 

8812 except RuntimeError: 

8813 logger.debug("[AFFINITY] Worker %s | Session %s... | Method: %s | Pool not initialized, executing locally", WORKER_ID, session_short, method) 

8814 return None 

8815 

8816 if is_internally_forwarded and mcp_session_id: 

8817 # First-Party 

8818 from mcpgateway.services.mcp_session_pool import WORKER_ID # pylint: disable=import-outside-toplevel 

8819 

8820 session_short = mcp_session_id[:8] if len(mcp_session_id) >= 8 else mcp_session_id 

8821 logger.debug("[AFFINITY] Worker %s | Session %s... | Method: %s | Internally forwarded request, executing locally", WORKER_ID, session_short, method) 

8822 

8823 return None 

8824 

8825 

8826async def _execute_rpc_initialize( 

8827 request: Request, 

8828 user, 

8829 *, 

8830 params: Dict[str, Any], 

8831 server_id: Optional[str], 

8832 mcp_session_id: Optional[str], 

8833): 

8834 """Execute the MCP initialize handshake while preserving session ownership semantics. 

8835 

8836 Args: 

8837 request: Incoming RPC request. 

8838 user: Authenticated user payload. 

8839 params: Initialize params payload. 

8840 server_id: Optional virtual server identifier. 

8841 mcp_session_id: Session id from the transport headers, when present. 

8842 

8843 Returns: 

8844 Serialized initialize result payload. 

8845 

8846 Raises: 

8847 JSONRPCError: If session ownership cannot be claimed or validated. 

8848 """ 

8849 init_session_id = params.get("session_id") or params.get("sessionId") or request.query_params.get("session_id") 

8850 requester_email, requester_is_admin = _get_request_identity(request, user) 

8851 

8852 if init_session_id: 

8853 effective_owner = await session_registry.claim_session_owner(init_session_id, requester_email) 

8854 if effective_owner is None: 

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

8856 

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

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

8859 

8860 result = await session_registry.handle_initialize_logic(params, session_id=init_session_id, server_id=server_id) 

8861 if hasattr(result, "model_dump"): 

8862 result = result.model_dump(by_alias=True, exclude_none=True) 

8863 

8864 if settings.mcpgateway_session_affinity_enabled and mcp_session_id and mcp_session_id != "not-provided": 

8865 try: 

8866 # First-Party 

8867 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool, WORKER_ID # pylint: disable=import-outside-toplevel 

8868 

8869 pool = get_mcp_session_pool() 

8870 await pool.register_pool_session_owner(mcp_session_id) 

8871 logger.debug("[AFFINITY_INIT] Worker %s | Session %s... | Registered ownership after initialize", WORKER_ID, mcp_session_id[:8]) 

8872 except Exception as e: 

8873 logger.warning("[AFFINITY_INIT] Failed to register session ownership: %s", e) 

8874 

8875 return result 

8876 

8877 

8878async def _execute_rpc_tools_call( 

8879 request: Request, 

8880 db: Session, 

8881 user, 

8882 *, 

8883 req_id: Any, 

8884 params: Dict[str, Any], 

8885 lowered_request_headers: Dict[str, str], 

8886 server_id: Optional[str], 

8887 skip_pre_invoke: bool = False, 

8888): 

8889 """Execute the hot-path ``tools/call`` branch without the generic RPC method switch. 

8890 

8891 Args: 

8892 request: Incoming RPC request. 

8893 db: Active database session. 

8894 user: Authenticated user payload. 

8895 req_id: JSON-RPC request identifier. 

8896 params: Parsed tools/call params payload. 

8897 lowered_request_headers: Lower-cased request headers used for passthrough. 

8898 server_id: Optional virtual server identifier. 

8899 skip_pre_invoke: When True, skip TOOL_PRE_INVOKE hooks (used by trusted Rust fallback path). 

8900 

8901 Returns: 

8902 Serialized MCP tools/call result payload. 

8903 

8904 Raises: 

8905 JSONRPCError: If the tool name is missing, execution is cancelled, or the 

8906 downstream tool branch reports a JSON-RPC-visible failure. 

8907 """ 

8908 name = params.get("name") 

8909 arguments = params.get("arguments", {}) 

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

8911 if not name: 

8912 raise JSONRPCError(-32602, "Missing tool name in parameters", params) 

8913 

8914 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user) 

8915 run_owner_email = auth_user_email 

8916 run_owner_team_ids = [] if auth_token_teams is None else list(auth_token_teams) 

8917 if auth_is_admin and auth_token_teams is None: 

8918 auth_user_email = None 

8919 elif auth_token_teams is None: 

8920 auth_token_teams = [] 

8921 

8922 oauth_user_email = get_user_email(user) 

8923 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

8924 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

8925 

8926 run_id = str(req_id) if req_id is not None else None 

8927 tool_task: Optional[asyncio.Task] = None 

8928 

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

8930 """Cancel the active tool execution task when cancellation is requested. 

8931 

8932 Args: 

8933 reason: Optional human-readable cancellation reason. 

8934 """ 

8935 if tool_task and not tool_task.done(): 

8936 logger.info("Cancelling tool task for run_id=%s, reason=%s", run_id, reason) 

8937 tool_task.cancel() 

8938 

8939 if settings.mcpgateway_tool_cancellation_enabled and run_id: 

8940 await cancellation_service.register_run( 

8941 run_id, 

8942 name=f"tool:{name}", 

8943 cancel_callback=cancel_tool_task, 

8944 owner_email=run_owner_email, 

8945 owner_team_ids=run_owner_team_ids, 

8946 ) 

8947 

8948 try: 

8949 if settings.mcpgateway_tool_cancellation_enabled and run_id: 

8950 run_status = await cancellation_service.get_status(run_id) 

8951 if run_status and run_status.get("cancelled"): 

8952 raise JSONRPCError(-32800, f"Tool execution cancelled: {name}", {"requestId": run_id}) 

8953 

8954 async def execute_tool(): 

8955 """Execute the tool invocation using the existing Python service layer. 

8956 

8957 Returns: 

8958 Result returned by the Python tool service. 

8959 

8960 Raises: 

8961 JSONRPCError: If the requested tool cannot be found. 

8962 """ 

8963 try: 

8964 return await tool_service.invoke_tool( 

8965 db=db, 

8966 name=name, 

8967 arguments=arguments, 

8968 request_headers=lowered_request_headers, 

8969 app_user_email=oauth_user_email, 

8970 user_email=auth_user_email, 

8971 token_teams=auth_token_teams, 

8972 server_id=server_id, 

8973 plugin_context_table=plugin_context_table, 

8974 plugin_global_context=plugin_global_context, 

8975 meta_data=meta_data, 

8976 skip_pre_invoke=skip_pre_invoke, 

8977 ) 

8978 except (ToolNotFoundError, ValueError): 

8979 logger.error("Tool not found: %s", name) 

8980 raise JSONRPCError(-32601, f"Tool not found: {name}", None) 

8981 

8982 tool_task = asyncio.create_task(execute_tool()) 

8983 

8984 if settings.mcpgateway_tool_cancellation_enabled and run_id: 

8985 run_status = await cancellation_service.get_status(run_id) 

8986 if run_status and run_status.get("cancelled"): 

8987 tool_task.cancel() 

8988 

8989 try: 

8990 result = await tool_task 

8991 if hasattr(result, "model_dump"): 

8992 result = result.model_dump(by_alias=True, exclude_none=True) 

8993 return result 

8994 except asyncio.CancelledError as exc: 

8995 logger.info("Tool execution cancelled for run_id=%s, tool=%s", run_id, name) 

8996 raise JSONRPCError(-32800, f"Tool execution cancelled: {name}", {"requestId": run_id, "partial": False}) from exc 

8997 finally: 

8998 if settings.mcpgateway_tool_cancellation_enabled and run_id: 

8999 await cancellation_service.unregister_run(run_id) 

9000 

9001 

9002@utility_router.post("/_internal/mcp/tools/call/") 

9003@utility_router.post("/_internal/mcp/tools/call") 

9004async def handle_internal_mcp_tools_call(request: Request): 

9005 """Handle trusted tools/call requests forwarded from the local Rust runtime. 

9006 

9007 Args: 

9008 request: Trusted internal MCP tools/call request. 

9009 

9010 Returns: 

9011 JSON-RPC response payload for the tools/call request. 

9012 

9013 Raises: 

9014 PluginError: Re-raised so plugin middleware can preserve existing behavior. 

9015 PluginViolationError: Re-raised so plugin middleware can preserve existing behavior. 

9016 Exception: Propagated after best-effort rollback when unexpected failures occur. 

9017 """ 

9018 req_id = None 

9019 db = SessionLocal() 

9020 try: 

9021 user = _build_internal_mcp_forwarded_user(request) 

9022 try: 

9023 body = orjson.loads(await request.body()) 

9024 except orjson.JSONDecodeError: 

9025 return ORJSONResponse( 

9026 status_code=400, 

9027 content={ 

9028 "jsonrpc": "2.0", 

9029 "error": {"code": -32700, "message": "Parse error"}, 

9030 "id": None, 

9031 }, 

9032 ) 

9033 

9034 if not isinstance(body, dict) or body.get("method") != "tools/call": 

9035 return ORJSONResponse( 

9036 status_code=400, 

9037 content={ 

9038 "jsonrpc": "2.0", 

9039 "error": {"code": -32600, "message": "Invalid Request"}, 

9040 "id": body.get("id") if isinstance(body, dict) else None, 

9041 }, 

9042 ) 

9043 

9044 req_id = body.get("id") 

9045 if req_id is None: 

9046 req_id = str(uuid.uuid4()) 

9047 params = body.get("params", {}) 

9048 if not isinstance(params, dict): 

9049 params = {} 

9050 

9051 server_id = request.headers.get("x-contextforge-server-id") or params.get("server_id") 

9052 if server_id: 

9053 _enforce_internal_mcp_server_scope(request, server_id) 

9054 

9055 lowered_request_headers = {k.lower(): v for k, v in request.headers.items()} 

9056 forwarded_response = await _maybe_forward_affinitized_rpc_request( 

9057 request, 

9058 method="tools/call", 

9059 params=params, 

9060 req_id=req_id, 

9061 lowered_request_headers=lowered_request_headers, 

9062 ) 

9063 if forwarded_response is not None: 

9064 return forwarded_response 

9065 

9066 if (_get_internal_mcp_auth_context(request) or {}).get("is_authenticated", True) is True: 

9067 await _ensure_rpc_permission(user, db, "tools.execute", "tools/call", request=request) 

9068 

9069 # Trust the pre-invoke-ran marker only on this internal endpoint 

9070 # (authenticated via x-contextforge-mcp-runtime-auth shared secret). 

9071 # External clients cannot reach this path. 

9072 pre_invoke_ran = lowered_request_headers.get("x-contextforge-pre-invoke-ran") == "true" 

9073 

9074 try: 

9075 result = await _execute_rpc_tools_call( 

9076 request, 

9077 db, 

9078 user, 

9079 req_id=req_id, 

9080 params=params, 

9081 lowered_request_headers=lowered_request_headers, 

9082 server_id=server_id, 

9083 skip_pre_invoke=pre_invoke_ran, 

9084 ) 

9085 finally: 

9086 if db.is_active and db.in_transaction() is not None: 

9087 db.commit() 

9088 db.close() 

9089 

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

9091 except (PluginError, PluginViolationError): 

9092 raise 

9093 except JSONRPCError as e: 

9094 error = e.to_dict() 

9095 return {"jsonrpc": "2.0", "error": error["error"], "id": req_id} 

9096 except Exception: 

9097 try: 

9098 db.rollback() 

9099 except Exception: 

9100 try: 

9101 db.invalidate() 

9102 except Exception: 

9103 pass # nosec B110 - Best effort cleanup on connection failure 

9104 raise 

9105 finally: 

9106 try: 

9107 db.close() 

9108 except Exception: 

9109 pass # nosec B110 - Best effort cleanup on connection failure 

9110 

9111 

9112@utility_router.post("/_internal/mcp/tools/call/resolve/") 

9113@utility_router.post("/_internal/mcp/tools/call/resolve") 

9114async def handle_internal_mcp_tools_call_resolve(request: Request): 

9115 """Resolve a Rust-direct MCP tools/call execution plan without executing the tool. 

9116 

9117 Args: 

9118 request: Trusted internal MCP tools/call resolve request. 

9119 

9120 Returns: 

9121 JSON response containing either an execution plan or a JSON-RPC-visible error. 

9122 

9123 Raises: 

9124 PluginError: Re-raised so plugin middleware can preserve existing behavior. 

9125 PluginViolationError: Re-raised so plugin middleware can preserve existing behavior. 

9126 Exception: Propagated after best-effort rollback when unexpected failures occur. 

9127 """ 

9128 db = SessionLocal() 

9129 try: 

9130 user = _build_internal_mcp_forwarded_user(request) 

9131 try: 

9132 body = orjson.loads(await request.body()) 

9133 except orjson.JSONDecodeError: 

9134 return ORJSONResponse( 

9135 status_code=400, 

9136 content={ 

9137 "jsonrpc": "2.0", 

9138 "error": {"code": -32700, "message": "Parse error"}, 

9139 "id": None, 

9140 }, 

9141 ) 

9142 

9143 if not isinstance(body, dict) or body.get("method") != "tools/call": 

9144 return ORJSONResponse( 

9145 status_code=400, 

9146 content={ 

9147 "jsonrpc": "2.0", 

9148 "error": {"code": -32600, "message": "Invalid Request"}, 

9149 "id": body.get("id") if isinstance(body, dict) else None, 

9150 }, 

9151 ) 

9152 

9153 params = body.get("params", {}) 

9154 if not isinstance(params, dict): 

9155 params = {} 

9156 

9157 name = params.get("name") 

9158 if not name: 

9159 return ORJSONResponse( 

9160 status_code=400, 

9161 content={ 

9162 "jsonrpc": "2.0", 

9163 "error": {"code": -32602, "message": "Missing tool name in parameters"}, 

9164 "id": body.get("id"), 

9165 }, 

9166 ) 

9167 

9168 server_id = request.headers.get("x-contextforge-server-id") or params.get("server_id") 

9169 if server_id: 

9170 _enforce_internal_mcp_server_scope(request, server_id) 

9171 

9172 if (_get_internal_mcp_auth_context(request) or {}).get("is_authenticated", True) is True: 

9173 await _ensure_rpc_permission(user, db, "tools.execute", "tools/call", request=request) 

9174 

9175 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user) 

9176 if auth_is_admin and auth_token_teams is None: 

9177 auth_user_email = None 

9178 elif auth_token_teams is None: 

9179 auth_token_teams = [] 

9180 

9181 arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} 

9182 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

9183 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

9184 plan = await tool_service.prepare_rust_mcp_tool_execution( 

9185 db=db, 

9186 name=name, 

9187 arguments=arguments, 

9188 request_headers={k.lower(): v for k, v in request.headers.items()}, 

9189 app_user_email=get_user_email(user), 

9190 user_email=auth_user_email, 

9191 token_teams=auth_token_teams, 

9192 server_id=server_id, 

9193 plugin_global_context=plugin_global_context, 

9194 plugin_context_table=plugin_context_table, 

9195 ) 

9196 

9197 if db.is_active and db.in_transaction() is not None: 

9198 db.commit() 

9199 return ORJSONResponse(content=plan) 

9200 except ToolNotFoundError as exc: 

9201 request_id = body.get("id") if isinstance(body, dict) else None 

9202 return ORJSONResponse( 

9203 status_code=404, 

9204 content={ 

9205 "jsonrpc": "2.0", 

9206 "error": {"code": -32601, "message": str(exc)}, 

9207 "id": request_id, 

9208 }, 

9209 ) 

9210 except ToolError as exc: 

9211 request_id = body.get("id") if isinstance(body, dict) else None 

9212 return ORJSONResponse( 

9213 status_code=400, 

9214 content={ 

9215 "jsonrpc": "2.0", 

9216 "error": {"code": -32000, "message": str(exc)}, 

9217 "id": request_id, 

9218 }, 

9219 ) 

9220 except (PluginError, PluginViolationError): 

9221 raise 

9222 except JSONRPCError as exc: 

9223 request_id = body.get("id") if isinstance(body, dict) else None 

9224 return ORJSONResponse( 

9225 status_code=403, 

9226 content={ 

9227 "jsonrpc": "2.0", 

9228 "error": {"code": exc.code, "message": exc.message, **({"data": exc.data} if exc.data is not None else {})}, 

9229 "id": exc.request_id if exc.request_id is not None else request_id, 

9230 }, 

9231 ) 

9232 except Exception: 

9233 try: 

9234 db.rollback() 

9235 except Exception: 

9236 try: 

9237 db.invalidate() 

9238 except Exception: 

9239 pass # nosec B110 - Best effort cleanup on connection failure 

9240 raise 

9241 finally: 

9242 try: 

9243 db.close() 

9244 except Exception: 

9245 pass # nosec B110 - Best effort cleanup on connection failure 

9246 

9247 

9248@utility_router.post("/_internal/mcp/tools/call/metric/") 

9249@utility_router.post("/_internal/mcp/tools/call/metric") 

9250async def handle_internal_mcp_tools_call_metric(request: Request): 

9251 """Record buffered tool/server metrics for a Rust-direct `tools/call`. 

9252 

9253 Args: 

9254 request: Trusted internal metrics writeback request. 

9255 

9256 Returns: 

9257 ORJSONResponse acknowledging the buffered metric writeback. 

9258 """ 

9259 _build_internal_mcp_forwarded_user(request) 

9260 try: 

9261 body = orjson.loads(await request.body()) 

9262 except orjson.JSONDecodeError: 

9263 return ORJSONResponse(status_code=400, content={"detail": "Invalid JSON body"}) 

9264 

9265 if not isinstance(body, dict): 

9266 return ORJSONResponse(status_code=400, content={"detail": "Invalid metrics payload"}) 

9267 

9268 tool_id = body.get("toolId") 

9269 duration_ms = body.get("durationMs") 

9270 success = body.get("success") 

9271 server_id = body.get("serverId") 

9272 error_message = body.get("errorMessage") 

9273 

9274 if not isinstance(tool_id, str) or not tool_id.strip(): 

9275 return ORJSONResponse(status_code=400, content={"detail": "Missing toolId"}) 

9276 if not isinstance(duration_ms, (int, float)) or duration_ms < 0: 

9277 return ORJSONResponse(status_code=400, content={"detail": "Invalid durationMs"}) 

9278 if not isinstance(success, bool): 

9279 return ORJSONResponse(status_code=400, content={"detail": "Invalid success flag"}) 

9280 if server_id is not None and (not isinstance(server_id, str) or not server_id.strip()): 

9281 return ORJSONResponse(status_code=400, content={"detail": "Invalid serverId"}) 

9282 if error_message is not None and not isinstance(error_message, str): 

9283 return ORJSONResponse(status_code=400, content={"detail": "Invalid errorMessage"}) 

9284 

9285 request_server_id = request.headers.get("x-contextforge-server-id") 

9286 if request_server_id: 

9287 _enforce_internal_mcp_server_scope(request, request_server_id) 

9288 if server_id and server_id != request_server_id: 

9289 return ORJSONResponse(status_code=400, content={"detail": "serverId does not match forwarded server scope"}) 

9290 server_id = request_server_id 

9291 

9292 # First-Party 

9293 from mcpgateway.services.metrics_buffer_service import get_metrics_buffer_service # pylint: disable=import-outside-toplevel 

9294 

9295 metrics_buffer = get_metrics_buffer_service() 

9296 response_time = float(duration_ms) / 1000.0 

9297 metrics_buffer.record_tool_metric_with_duration( 

9298 tool_id=tool_id, 

9299 response_time=response_time, 

9300 success=success, 

9301 error_message=error_message, 

9302 ) 

9303 if server_id: 

9304 metrics_buffer.record_server_metric_with_duration( 

9305 server_id=server_id, 

9306 response_time=response_time, 

9307 success=success, 

9308 error_message=error_message, 

9309 ) 

9310 

9311 return ORJSONResponse(content={"status": "ok"}) 

9312 

9313 

9314async def _handle_rpc_authenticated(request: Request, db: Session, user): 

9315 """Handle RPC requests. 

9316 

9317 Args: 

9318 request (Request): The incoming FastAPI request. 

9319 db (Session): Database session. 

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

9321 

9322 Returns: 

9323 Response with the RPC result or error. 

9324 

9325 Raises: 

9326 PluginError: If encounters issue with plugin 

9327 PluginViolationError: If plugin violated the request. Example - In case of OPA plugin, if the request is denied by policy. 

9328 """ 

9329 req_id = None 

9330 try: 

9331 # Extract user identifier from either RBAC user object or JWT payload 

9332 if hasattr(user, "email"): 

9333 user_id = getattr(user, "email", None) # RBAC user object 

9334 elif isinstance(user, dict): 

9335 user_id = user.get("sub") or user.get("email") or user.get("username", "unknown") # JWT payload 

9336 else: 

9337 user_id = str(user) # String username from basic auth 

9338 

9339 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user_id))} made an RPC request") 

9340 try: 

9341 body = orjson.loads(await request.body()) 

9342 except orjson.JSONDecodeError: 

9343 return ORJSONResponse( 

9344 status_code=400, 

9345 content={ 

9346 "jsonrpc": "2.0", 

9347 "error": {"code": -32700, "message": "Parse error"}, 

9348 "id": None, 

9349 }, 

9350 ) 

9351 request_headers = request.headers 

9352 lowered_headers: Optional[Dict[str, str]] = None 

9353 

9354 def _lowered_request_headers() -> Dict[str, str]: 

9355 """Return a cached lower-cased copy of the incoming request headers. 

9356 

9357 Returns: 

9358 Dict[str, str]: Lower-cased request headers cached for repeated access. 

9359 """ 

9360 nonlocal lowered_headers 

9361 if lowered_headers is None: 

9362 lowered_headers = {k.lower(): v for k, v in request_headers.items()} 

9363 return lowered_headers 

9364 

9365 _trusted_internal_mcp_dispatch = _get_internal_mcp_auth_context(request) is not None 

9366 _internal_runtime_server_id = request_headers.get("x-contextforge-server-id") if request_headers.get("x-contextforge-mcp-runtime") == "rust" else None 

9367 

9368 method = body["method"] 

9369 req_id = body.get("id") 

9370 if req_id is None: 

9371 req_id = str(uuid.uuid4()) 

9372 params = body.get("params", {}) 

9373 if not isinstance(params, dict): 

9374 params = {} 

9375 if _internal_runtime_server_id: 

9376 params["server_id"] = _internal_runtime_server_id 

9377 server_id = params.get("server_id", None) 

9378 cursor = params.get("cursor") # Extract cursor parameter 

9379 mcp_session_id = request_headers.get("mcp-session-id") or request_headers.get("x-mcp-session-id") 

9380 

9381 # RBAC: Enforce server_id scoping for server-scoped tokens. 

9382 # Extract token scopes once, then: 

9383 # 1. If request supplies server_id, validate it matches the token scope. 

9384 # 2. If request omits server_id but token is server-scoped, auto-inject the 

9385 # token's server_id so list operations stay properly scoped (parity with 

9386 # the REST middleware which denies /tools for server-scoped tokens). 

9387 _cached = getattr(request.state, "_jwt_verified_payload", None) 

9388 _jwt_payload = _cached[1] if (isinstance(_cached, tuple) and len(_cached) == 2 and isinstance(_cached[1], dict)) else None 

9389 _token_scopes = _jwt_payload.get("scopes", {}) if _jwt_payload else {} 

9390 _internal_auth_context = _get_internal_mcp_auth_context(request) 

9391 if (not _token_scopes) and isinstance(_internal_auth_context, dict): 

9392 _scoped_server_id = _internal_auth_context.get("scoped_server_id") 

9393 if isinstance(_scoped_server_id, str) and _scoped_server_id: 

9394 _token_scopes = {"server_id": _scoped_server_id} 

9395 _token_server_id = _token_scopes.get("server_id") if _token_scopes else None 

9396 

9397 if server_id: 

9398 if not validate_server_access(_token_scopes, server_id): 

9399 return ORJSONResponse( 

9400 status_code=403, 

9401 content={ 

9402 "jsonrpc": "2.0", 

9403 "error": {"code": -32003, "message": f"Token not authorized for server: {server_id}"}, 

9404 "id": req_id, 

9405 }, 

9406 ) 

9407 elif _token_server_id is not None: 

9408 server_id = _token_server_id 

9409 

9410 if not _trusted_internal_mcp_dispatch: 

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

9412 

9413 forwarded_response = await _maybe_forward_affinitized_rpc_request( 

9414 request, 

9415 method=method, 

9416 params=params, 

9417 req_id=req_id, 

9418 lowered_request_headers=_lowered_request_headers(), 

9419 ) 

9420 if forwarded_response is not None: 

9421 return forwarded_response 

9422 

9423 if method == "initialize": 

9424 result = await _execute_rpc_initialize( 

9425 request, 

9426 user, 

9427 params=params, 

9428 server_id=server_id, 

9429 mcp_session_id=mcp_session_id, 

9430 ) 

9431 elif method == "tools/list": 

9432 await _ensure_rpc_permission(user, db, "tools.read", method, request=request) 

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

9434 _req_email, _req_is_admin = user_email, is_admin 

9435 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None 

9436 # Admin bypass - only when token has NO team restrictions 

9437 if is_admin and token_teams is None: 

9438 user_email = None 

9439 token_teams = None # Admin unrestricted 

9440 elif token_teams is None: 

9441 token_teams = [] # Non-admin without teams = public-only (secure default) 

9442 if server_id: 

9443 tools = await tool_service.list_server_tools( 

9444 db, 

9445 server_id, 

9446 cursor=cursor, 

9447 user_email=user_email, 

9448 token_teams=token_teams, 

9449 requesting_user_email=_req_email, 

9450 requesting_user_is_admin=_req_is_admin, 

9451 requesting_user_team_roles=_req_team_roles, 

9452 ) 

9453 # Release DB connection early to prevent idle-in-transaction under load 

9454 db.commit() 

9455 db.close() 

9456 result = {"tools": _serialize_mcp_tool_definitions(tools)} 

9457 else: 

9458 tools, next_cursor = await tool_service.list_tools( 

9459 db, 

9460 cursor=cursor, 

9461 limit=0, 

9462 user_email=user_email, 

9463 token_teams=token_teams, 

9464 requesting_user_email=_req_email, 

9465 requesting_user_is_admin=_req_is_admin, 

9466 requesting_user_team_roles=_req_team_roles, 

9467 ) 

9468 # Release DB connection early to prevent idle-in-transaction under load 

9469 db.commit() 

9470 db.close() 

9471 result = {"tools": _serialize_mcp_tool_definitions(tools)} 

9472 if next_cursor: 

9473 result["nextCursor"] = next_cursor 

9474 elif method == "list_tools": # Legacy endpoint 

9475 await _ensure_rpc_permission(user, db, "tools.read", method, request=request) 

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

9477 _req_email, _req_is_admin = user_email, is_admin 

9478 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None 

9479 # Admin bypass - only when token has NO team restrictions (token_teams is None) 

9480 # If token has explicit team scope (even empty [] for public-only), respect it 

9481 if is_admin and token_teams is None: 

9482 user_email = None 

9483 token_teams = None # Admin unrestricted 

9484 elif token_teams is None: 

9485 token_teams = [] # Non-admin without teams = public-only (secure default) 

9486 if server_id: 

9487 tools = await tool_service.list_server_tools( 

9488 db, 

9489 server_id, 

9490 cursor=cursor, 

9491 user_email=user_email, 

9492 token_teams=token_teams, 

9493 requesting_user_email=_req_email, 

9494 requesting_user_is_admin=_req_is_admin, 

9495 requesting_user_team_roles=_req_team_roles, 

9496 ) 

9497 db.commit() 

9498 db.close() 

9499 result = {"tools": _serialize_legacy_tool_payloads(tools)} 

9500 else: 

9501 tools, next_cursor = await tool_service.list_tools( 

9502 db, 

9503 cursor=cursor, 

9504 limit=0, 

9505 user_email=user_email, 

9506 token_teams=token_teams, 

9507 requesting_user_email=_req_email, 

9508 requesting_user_is_admin=_req_is_admin, 

9509 requesting_user_team_roles=_req_team_roles, 

9510 ) 

9511 db.commit() 

9512 db.close() 

9513 result = {"tools": _serialize_legacy_tool_payloads(tools)} 

9514 if next_cursor: 

9515 result["nextCursor"] = next_cursor 

9516 elif method == "list_gateways": 

9517 await _ensure_rpc_permission(user, db, "gateways.read", method, request=request) 

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

9519 # Admin bypass - only when token has NO team restrictions 

9520 if is_admin and token_teams is None: 

9521 user_email = None 

9522 token_teams = None # Admin unrestricted 

9523 elif token_teams is None: 

9524 token_teams = [] # Non-admin without teams = public-only (secure default) 

9525 gateways, next_cursor = await gateway_service.list_gateways(db, include_inactive=False, user_email=user_email, token_teams=token_teams) 

9526 db.commit() 

9527 db.close() 

9528 result = {"gateways": [g.model_dump(by_alias=True, exclude_none=True) for g in gateways]} 

9529 if next_cursor: 

9530 result["nextCursor"] = next_cursor 

9531 elif method == "list_roots": 

9532 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request) 

9533 roots = await root_service.list_roots() 

9534 result = {"roots": [r.model_dump(by_alias=True, exclude_none=True) for r in roots]} 

9535 elif method == "resources/list": 

9536 await _ensure_rpc_permission(user, db, "resources.read", method, request=request) 

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

9538 # Admin bypass - only when token has NO team restrictions 

9539 if is_admin and token_teams is None: 

9540 user_email = None 

9541 token_teams = None # Admin unrestricted 

9542 elif token_teams is None: 

9543 token_teams = [] # Non-admin without teams = public-only (secure default) 

9544 if server_id: 

9545 resources = await resource_service.list_server_resources(db, server_id, user_email=user_email, token_teams=token_teams) 

9546 db.commit() 

9547 db.close() 

9548 result = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]} 

9549 else: 

9550 resources, next_cursor = await resource_service.list_resources(db, cursor=cursor, limit=0, user_email=user_email, token_teams=token_teams) 

9551 db.commit() 

9552 db.close() 

9553 result = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]} 

9554 if next_cursor: 

9555 result["nextCursor"] = next_cursor 

9556 elif method == "resources/read": 

9557 await _ensure_rpc_permission(user, db, "resources.read", method, request=request) 

9558 uri = params.get("uri") 

9559 request_id = params.get("requestId", None) 

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

9561 if not uri: 

9562 raise JSONRPCError(-32602, "Missing resource URI in parameters", params) 

9563 

9564 # Get authorization context (same as resources/list) 

9565 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user) 

9566 if auth_is_admin and auth_token_teams is None: 

9567 auth_user_email = None 

9568 # auth_token_teams stays None (unrestricted) 

9569 elif auth_token_teams is None: 

9570 auth_token_teams = [] # Non-admin without teams = public-only 

9571 

9572 # Get user email for OAuth token selection 

9573 oauth_user_email = get_user_email(user) 

9574 # Get plugin contexts from request.state for cross-hook sharing 

9575 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

9576 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

9577 try: 

9578 result = await resource_service.read_resource( 

9579 db, 

9580 resource_uri=uri, 

9581 request_id=request_id, 

9582 user=auth_user_email, 

9583 server_id=server_id, 

9584 token_teams=auth_token_teams, 

9585 plugin_context_table=plugin_context_table, 

9586 plugin_global_context=plugin_global_context, 

9587 meta_data=meta_data, 

9588 ) 

9589 if hasattr(result, "model_dump"): 

9590 result = {"contents": [result.model_dump(by_alias=True, exclude_none=True)]} 

9591 else: 

9592 result = {"contents": [result]} 

9593 except (ValueError, ResourceNotFoundError): 

9594 # Resource not found in the gateway 

9595 logger.error("Resource not found: %s", uri) 

9596 raise JSONRPCError(-32002, f"Resource not found: {uri}", {"uri": uri}) 

9597 # Release transaction after resources/read completes 

9598 db.commit() 

9599 db.close() 

9600 elif method == "resources/subscribe": 

9601 await _ensure_rpc_permission(user, db, "resources.read", method, request=request) 

9602 # MCP spec-compliant resource subscription endpoint 

9603 uri = params.get("uri") 

9604 if not uri: 

9605 raise JSONRPCError(-32602, "Missing resource URI in parameters", params) 

9606 access_user_email, access_token_teams = _get_scoped_resource_access_context(request, user) 

9607 # Get user email for subscriber ID 

9608 user_email = get_user_email(user) 

9609 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email) 

9610 try: 

9611 await resource_service.subscribe_resource(db, subscription, user_email=access_user_email, token_teams=access_token_teams) 

9612 except PermissionError: 

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

9614 db.commit() 

9615 db.close() 

9616 result = {} 

9617 elif method == "resources/unsubscribe": 

9618 await _ensure_rpc_permission(user, db, "resources.read", method, request=request) 

9619 # MCP spec-compliant resource unsubscription endpoint 

9620 uri = params.get("uri") 

9621 if not uri: 

9622 raise JSONRPCError(-32602, "Missing resource URI in parameters", params) 

9623 # Get user email for subscriber ID 

9624 user_email = get_user_email(user) 

9625 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email) 

9626 await resource_service.unsubscribe_resource(db, subscription) 

9627 db.commit() 

9628 db.close() 

9629 result = {} 

9630 elif method == "prompts/list": 

9631 await _ensure_rpc_permission(user, db, "prompts.read", method, request=request) 

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

9633 # Admin bypass - only when token has NO team restrictions 

9634 if is_admin and token_teams is None: 

9635 user_email = None 

9636 token_teams = None # Admin unrestricted 

9637 elif token_teams is None: 

9638 token_teams = [] # Non-admin without teams = public-only (secure default) 

9639 if server_id: 

9640 prompts = await prompt_service.list_server_prompts(db, server_id, cursor=cursor, user_email=user_email, token_teams=token_teams) 

9641 db.commit() 

9642 db.close() 

9643 result = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]} 

9644 else: 

9645 prompts, next_cursor = await prompt_service.list_prompts(db, cursor=cursor, limit=0, user_email=user_email, token_teams=token_teams) 

9646 db.commit() 

9647 db.close() 

9648 result = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]} 

9649 if next_cursor: 

9650 result["nextCursor"] = next_cursor 

9651 elif method == "prompts/get": 

9652 await _ensure_rpc_permission(user, db, "prompts.read", method, request=request) 

9653 name = params.get("name") 

9654 arguments = params.get("arguments", {}) 

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

9656 if not name: 

9657 raise JSONRPCError(-32602, "Missing prompt name in parameters", params) 

9658 

9659 # Get authorization context (same as prompts/list) 

9660 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user) 

9661 if auth_is_admin and auth_token_teams is None: 

9662 auth_user_email = None 

9663 # auth_token_teams stays None (unrestricted) 

9664 elif auth_token_teams is None: 

9665 auth_token_teams = [] # Non-admin without teams = public-only 

9666 

9667 # Get plugin contexts from request.state for cross-hook sharing 

9668 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

9669 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

9670 result = await prompt_service.get_prompt( 

9671 db, 

9672 name, 

9673 arguments, 

9674 user=auth_user_email, 

9675 server_id=server_id, 

9676 token_teams=auth_token_teams, 

9677 plugin_context_table=plugin_context_table, 

9678 plugin_global_context=plugin_global_context, 

9679 _meta_data=meta_data, 

9680 ) 

9681 if hasattr(result, "model_dump"): 

9682 result = result.model_dump(by_alias=True, exclude_none=True) 

9683 # Release transaction after prompts/get completes 

9684 db.commit() 

9685 db.close() 

9686 elif method == "ping": 

9687 # Per the MCP spec, a ping returns an empty result. 

9688 result = {} 

9689 elif method == "tools/call": # pylint: disable=too-many-nested-blocks 

9690 await _ensure_rpc_permission(user, db, "tools.execute", method, request=request) 

9691 # Note: Multi-worker session affinity forwarding is handled earlier 

9692 # (before method routing) to apply to ALL methods, not just tools/call 

9693 try: 

9694 result = await _execute_rpc_tools_call( 

9695 request, 

9696 db, 

9697 user, 

9698 req_id=req_id, 

9699 params=params, 

9700 lowered_request_headers=_lowered_request_headers(), 

9701 server_id=server_id, 

9702 ) 

9703 finally: 

9704 # Release transaction after tools/call completes 

9705 db.commit() 

9706 db.close() 

9707 # TODO: Implement methods # pylint: disable=fixme 

9708 elif method == "resources/templates/list": 

9709 await _ensure_rpc_permission(user, db, "resources.read", method, request=request) 

9710 # MCP spec-compliant resource templates list endpoint 

9711 # Use _get_rpc_filter_context - same pattern as tools/list 

9712 user_email_rpc, token_teams_rpc, is_admin_rpc = _get_rpc_filter_context(request, user) 

9713 

9714 # Admin bypass - only when token has NO team restrictions 

9715 if is_admin_rpc and token_teams_rpc is None: 

9716 token_teams_rpc = None # Admin unrestricted 

9717 elif token_teams_rpc is None: 

9718 token_teams_rpc = [] # Non-admin without teams = public-only 

9719 

9720 resource_templates = await resource_service.list_resource_templates( 

9721 db, 

9722 user_email=user_email_rpc, 

9723 token_teams=token_teams_rpc, 

9724 server_id=server_id, 

9725 ) 

9726 db.commit() 

9727 db.close() 

9728 result = {"resourceTemplates": [rt.model_dump(by_alias=True, exclude_none=True) for rt in resource_templates]} 

9729 elif method == "roots/list": 

9730 # MCP spec-compliant method name 

9731 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request) 

9732 roots = await root_service.list_roots() 

9733 result = {"roots": [r.model_dump(by_alias=True, exclude_none=True) for r in roots]} 

9734 elif method.startswith("roots/"): 

9735 # Catch-all for other roots/* methods (currently unsupported) 

9736 result = {} 

9737 elif method == "notifications/initialized": 

9738 # MCP spec-compliant notification: client initialized 

9739 logger.info("Client initialized") 

9740 await logging_service.notify("Client initialized", LogLevel.INFO) 

9741 result = {} 

9742 elif method == "notifications/cancelled": 

9743 # MCP spec-compliant notification: request cancelled 

9744 # Note: requestId can be 0 (valid per JSON-RPC), so use 'is not None' and normalize to string 

9745 raw_request_id = params.get("requestId") 

9746 request_id = str(raw_request_id) if raw_request_id is not None else None 

9747 reason = params.get("reason") 

9748 logger.info("Request cancelled: %s, reason: %s", request_id, reason) 

9749 # Attempt local cancellation per MCP spec 

9750 if request_id is not None: 

9751 await _authorize_run_cancellation(request, user, request_id, as_jsonrpc_error=True) 

9752 await cancellation_service.cancel_run(request_id, reason=reason) 

9753 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO) 

9754 result = {} 

9755 elif method == "notifications/message": 

9756 # MCP spec-compliant notification: log message 

9757 await logging_service.notify( 

9758 params.get("data"), 

9759 LogLevel(params.get("level", "info")), 

9760 params.get("logger"), 

9761 ) 

9762 result = {} 

9763 elif method.startswith("notifications/"): 

9764 # Catch-all for other notifications/* methods (currently unsupported) 

9765 result = {} 

9766 elif method == "sampling/createMessage": 

9767 # MCP spec-compliant sampling endpoint 

9768 result = await sampling_handler.create_message(db, params) 

9769 elif method.startswith("sampling/"): 

9770 # Catch-all for other sampling/* methods (currently unsupported) 

9771 result = {} 

9772 elif method == "elicitation/create": 

9773 # MCP spec 2025-06-18: Elicitation support (server-to-client requests) 

9774 # Elicitation allows servers to request structured user input through clients 

9775 

9776 # Check if elicitation is enabled 

9777 if not settings.mcpgateway_elicitation_enabled: 

9778 raise JSONRPCError(-32601, "Elicitation feature is disabled", {"feature": "elicitation", "config": "MCPGATEWAY_ELICITATION_ENABLED=false"}) 

9779 

9780 # Validate params 

9781 # First-Party 

9782 from mcpgateway.common.models import ElicitRequestParams # pylint: disable=import-outside-toplevel 

9783 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel 

9784 

9785 try: 

9786 elicit_params = ElicitRequestParams(**params) 

9787 except Exception as e: 

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

9789 

9790 # Get target session (from params or find elicitation-capable session) 

9791 target_session_id = params.get("session_id") or params.get("sessionId") 

9792 if not target_session_id: 

9793 # Find an elicitation-capable session 

9794 capable_sessions = await session_registry.get_elicitation_capable_sessions() 

9795 if not capable_sessions: 

9796 raise JSONRPCError(-32000, "No elicitation-capable clients available", {"message": elicit_params.message}) 

9797 target_session_id = capable_sessions[0] 

9798 logger.debug("Selected session %s for elicitation", target_session_id) 

9799 

9800 # Verify session has elicitation capability 

9801 if not await session_registry.has_elicitation_capability(target_session_id): 

9802 raise JSONRPCError(-32000, f"Session {target_session_id} does not support elicitation", {"session_id": target_session_id}) 

9803 

9804 # Get elicitation service and create request 

9805 elicitation_service = get_elicitation_service() 

9806 

9807 # Extract timeout from params or use default 

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

9809 

9810 try: 

9811 # Create elicitation request - this stores it and waits for response 

9812 # For now, use dummy upstream_session_id - in full bidirectional proxy, 

9813 # this would be the session that initiated the request 

9814 upstream_session_id = "gateway" 

9815 

9816 # Start the elicitation (creates pending request and future) 

9817 elicitation_task = asyncio.create_task( 

9818 elicitation_service.create_elicitation( 

9819 upstream_session_id=upstream_session_id, downstream_session_id=target_session_id, message=elicit_params.message, requested_schema=elicit_params.requestedSchema, timeout=timeout 

9820 ) 

9821 ) 

9822 

9823 # Get the pending elicitation to extract request_id 

9824 # Wait a moment for it to be created 

9825 await asyncio.sleep(0.01) 

9826 pending_elicitations = [e for e in elicitation_service._pending.values() if e.downstream_session_id == target_session_id] # pylint: disable=protected-access 

9827 if not pending_elicitations: 

9828 raise JSONRPCError(-32000, "Failed to create elicitation request", {}) 

9829 

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

9831 

9832 # Send elicitation request to client via broadcast 

9833 elicitation_request = { 

9834 "jsonrpc": "2.0", 

9835 "id": pending.request_id, 

9836 "method": "elicitation/create", 

9837 "params": {"message": elicit_params.message, "requestedSchema": elicit_params.requestedSchema}, 

9838 } 

9839 

9840 await session_registry.broadcast(target_session_id, elicitation_request) 

9841 logger.debug("Sent elicitation request %s to session %s", pending.request_id, target_session_id) 

9842 

9843 # Wait for response 

9844 elicit_result = await elicitation_task 

9845 

9846 # Return result 

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

9848 

9849 except asyncio.TimeoutError: 

9850 raise JSONRPCError(-32000, f"Elicitation timed out after {timeout}s", {"message": elicit_params.message, "timeout": timeout}) 

9851 except ValueError as e: 

9852 raise JSONRPCError(-32000, str(e), {"message": elicit_params.message}) 

9853 elif method.startswith("elicitation/"): 

9854 # Catch-all for other elicitation/* methods 

9855 result = {} 

9856 elif method == "completion/complete": 

9857 await _ensure_rpc_permission(user, db, "tools.read", method, request=request) 

9858 # MCP spec-compliant completion endpoint 

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

9860 if is_admin and token_teams is None: 

9861 user_email = None 

9862 elif token_teams is None: 

9863 token_teams = [] 

9864 result = await completion_service.handle_completion(db, params, user_email=user_email, token_teams=token_teams) 

9865 elif method.startswith("completion/"): 

9866 # Catch-all for other completion/* methods (currently unsupported) 

9867 result = {} 

9868 elif method == "logging/setLevel": 

9869 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request) 

9870 level = LogLevel(params.get("level")) 

9871 await logging_service.set_level(level) 

9872 result = {} 

9873 elif method.startswith("logging/"): 

9874 # Catch-all for other logging/* methods (currently unsupported) 

9875 result = {} 

9876 else: 

9877 # Backward compatibility: Try to invoke as a tool directly 

9878 # This allows both old format (method=tool_name) and new format (method=tools/call) 

9879 await _ensure_rpc_permission(user, db, "tools.execute", method, request=request) 

9880 

9881 # Get authorization context (same as tools/call) 

9882 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user) 

9883 if auth_is_admin and auth_token_teams is None: 

9884 auth_user_email = None 

9885 # auth_token_teams stays None (unrestricted) 

9886 elif auth_token_teams is None: 

9887 auth_token_teams = [] # Non-admin without teams = public-only 

9888 

9889 # Get user email for OAuth token selection 

9890 oauth_user_email = get_user_email(user) 

9891 # Get server_id from params if provided 

9892 server_id = params.get("server_id") 

9893 # Get plugin contexts from request.state for cross-hook sharing 

9894 plugin_context_table = getattr(request.state, "plugin_context_table", None) 

9895 plugin_global_context = getattr(request.state, "plugin_global_context", None) 

9896 

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

9898 

9899 try: 

9900 result = await tool_service.invoke_tool( 

9901 db=db, 

9902 name=method, 

9903 arguments=params, 

9904 request_headers=_lowered_request_headers(), 

9905 app_user_email=oauth_user_email, 

9906 user_email=auth_user_email, 

9907 token_teams=auth_token_teams, 

9908 server_id=server_id, 

9909 plugin_context_table=plugin_context_table, 

9910 plugin_global_context=plugin_global_context, 

9911 meta_data=meta_data, 

9912 ) 

9913 if hasattr(result, "model_dump"): 

9914 result = result.model_dump(by_alias=True, exclude_none=True) 

9915 except (PluginError, PluginViolationError): 

9916 raise 

9917 except Exception: 

9918 # Log error and return invalid method 

9919 logger.error("Method not found: %s", method) 

9920 raise JSONRPCError(-32000, "Invalid method", params) 

9921 

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

9923 

9924 except (PluginError, PluginViolationError): 

9925 raise 

9926 except JSONRPCError as e: 

9927 error = e.to_dict() 

9928 return {"jsonrpc": "2.0", "error": error["error"], "id": req_id} 

9929 except Exception as e: 

9930 if isinstance(e, ValueError): 

9931 return ORJSONResponse(content={"message": "Method invalid"}, status_code=422) 

9932 logger.error(f"RPC error: {str(e)}") 

9933 return { 

9934 "jsonrpc": "2.0", 

9935 "error": {"code": -32000, "message": "Internal error", "data": str(e)}, 

9936 "id": req_id, 

9937 } 

9938 

9939 

9940_WS_RELAY_REQUIRED_PERMISSIONS = [ 

9941 "tools.read", 

9942 "tools.execute", 

9943 "resources.read", 

9944 "prompts.read", 

9945 "servers.use", 

9946 "a2a.read", 

9947] 

9948 

9949 

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

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

9952 

9953 Args: 

9954 websocket: Incoming WebSocket connection. 

9955 

9956 Returns: 

9957 Bearer token value when present, otherwise None. 

9958 """ 

9959 return extract_websocket_bearer_token( 

9960 getattr(websocket, "query_params", {}), 

9961 getattr(websocket, "headers", {}), 

9962 query_param_warning="WebSocket authentication token passed via query parameter", 

9963 ) 

9964 

9965 

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

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

9968 

9969 Args: 

9970 websocket: Incoming WebSocket connection. 

9971 

9972 Returns: 

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

9974 

9975 Raises: 

9976 HTTPException: If authentication fails or required permissions are missing. 

9977 """ 

9978 auth_required = settings.mcp_client_auth_enabled or settings.auth_required 

9979 auth_token = _get_websocket_bearer_token(websocket) 

9980 proxy_user: Optional[str] = None 

9981 user_context: Optional[dict[str, Any]] = None 

9982 

9983 # JWT authentication path 

9984 if auth_token: 

9985 credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=auth_token) 

9986 try: 

9987 user = await get_current_user(credentials, request=websocket) 

9988 except HTTPException: 

9989 raise 

9990 except Exception as exc: 

9991 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication failed") from exc 

9992 user_context = { 

9993 "email": user.email, 

9994 "full_name": user.full_name, 

9995 "is_admin": user.is_admin, 

9996 "ip_address": websocket.client.host if websocket.client else None, 

9997 "user_agent": websocket.headers.get("user-agent"), 

9998 "team_id": getattr(websocket.state, "team_id", None), 

9999 "token_teams": getattr(websocket.state, "token_teams", None), 

10000 "token_use": getattr(websocket.state, "token_use", None), 

10001 } 

10002 # Proxy authentication path (only valid when MCP client auth is disabled) 

10003 elif is_proxy_auth_trust_active(settings): 

10004 proxy_user = websocket.headers.get(settings.proxy_user_header) 

10005 if proxy_user: 

10006 user_context = { 

10007 "email": proxy_user, 

10008 "full_name": proxy_user, 

10009 "is_admin": False, 

10010 "ip_address": websocket.client.host if websocket.client else None, 

10011 "user_agent": websocket.headers.get("user-agent"), 

10012 } 

10013 elif auth_required: 

10014 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") 

10015 elif auth_required: 

10016 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") 

10017 

10018 # RBAC gate: require at least one MCP interaction permission before allowing WS relay access 

10019 if user_context: 

10020 checker = PermissionChecker(user_context) 

10021 if not await checker.has_any_permission(_WS_RELAY_REQUIRED_PERMISSIONS): 

10022 logger.warning("WebSocket relay permission denied: user=%s", user_context.get("email")) 

10023 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_ACCESS_DENIED_MSG) 

10024 

10025 return auth_token, proxy_user 

10026 

10027 

10028@utility_router.websocket("/ws") 

10029async def websocket_endpoint(websocket: WebSocket): 

10030 """ 

10031 Handle WebSocket connection to relay JSON-RPC requests to the internal RPC endpoint. 

10032 

10033 Accepts incoming text messages, parses them as JSON-RPC requests, sends them to /rpc, 

10034 and returns the result to the client over the same WebSocket. 

10035 

10036 Args: 

10037 websocket: The WebSocket connection instance. 

10038 """ 

10039 try: 

10040 if not settings.mcpgateway_ws_relay_enabled: 

10041 await websocket.close(code=1008, reason="WebSocket relay is disabled") 

10042 return 

10043 

10044 try: 

10045 auth_token, proxy_user = await _authenticate_websocket_user(websocket) 

10046 except HTTPException as e: 

10047 await websocket.close(code=1008, reason=str(e.detail)) 

10048 return 

10049 

10050 # Capture passthrough headers from the WebSocket handshake request. 

10051 # Without this, headers like X-Upstream-Authorization are silently dropped. See #3640. 

10052 # First-Party 

10053 from mcpgateway.utils.passthrough_headers import filter_loopback_skip_headers, safe_extract_headers_for_loopback # pylint: disable=import-outside-toplevel 

10054 

10055 ws_passthrough_headers = safe_extract_headers_for_loopback(dict(websocket.headers), "WebSocket") 

10056 

10057 await websocket.accept() 

10058 while True: 

10059 try: 

10060 data = await websocket.receive_text() 

10061 client_args = {"timeout": settings.federation_timeout, "verify": internal_loopback_verify()} 

10062 

10063 # Build headers for /rpc request - forward auth credentials 

10064 rpc_headers: Dict[str, str] = {"Content-Type": "application/json"} 

10065 if auth_token: 

10066 rpc_headers["Authorization"] = f"Bearer {auth_token}" 

10067 if proxy_user: 

10068 rpc_headers[settings.proxy_user_header] = proxy_user 

10069 # Forward passthrough headers captured from the WebSocket handshake (see #3640). 

10070 # Defense-in-depth: filter via filter_loopback_skip_headers() so passthrough 

10071 # can never override the gateway's internal auth, content-type, or session/routing headers. 

10072 if ws_passthrough_headers: 

10073 rpc_headers.update(filter_loopback_skip_headers(ws_passthrough_headers)) 

10074 

10075 async with ResilientHttpClient(client_args=client_args) as client: 

10076 response = await client.post( 

10077 f"{internal_loopback_base_url()}{settings.app_root_path}/rpc", 

10078 json=orjson.loads(data), 

10079 headers=rpc_headers, 

10080 ) 

10081 await websocket.send_text(response.text) 

10082 except JSONRPCError as e: 

10083 await websocket.send_text(orjson.dumps(e.to_dict()).decode()) 

10084 except orjson.JSONDecodeError: 

10085 await websocket.send_text( 

10086 orjson.dumps( 

10087 { 

10088 "jsonrpc": "2.0", 

10089 "error": {"code": -32700, "message": "Parse error"}, 

10090 "id": None, 

10091 } 

10092 ).decode() 

10093 ) 

10094 except Exception as e: 

10095 logger.error(f"WebSocket error: {str(e)}") 

10096 await websocket.close(code=1011) 

10097 break 

10098 except WebSocketDisconnect: 

10099 logger.info("WebSocket disconnected") 

10100 except Exception as e: 

10101 logger.error(f"WebSocket connection error: {str(e)}") 

10102 try: 

10103 await websocket.close(code=1011) 

10104 except Exception as er: 

10105 logger.error(f"Error while closing WebSocket: {er}") 

10106 

10107 

10108@utility_router.get("/sse") 

10109@require_permission("servers.use") 

10110async def utility_sse_endpoint(request: Request, user=Depends(get_current_user_with_permissions)): 

10111 """ 

10112 Establish a Server-Sent Events (SSE) connection for real-time updates. 

10113 

10114 Args: 

10115 request (Request): The incoming HTTP request. 

10116 user (str): Authenticated username. 

10117 

10118 Returns: 

10119 StreamingResponse: A streaming response that keeps the connection 

10120 open and pushes events to the client. 

10121 

10122 Raises: 

10123 HTTPException: Returned with **500 Internal Server Error** if the SSE connection cannot be established or an unexpected error occurs while creating the transport. 

10124 asyncio.CancelledError: If the request is cancelled during SSE setup. 

10125 """ 

10126 try: 

10127 logger.debug("User %s requested SSE connection", user) 

10128 base_url = update_url_protocol(request) 

10129 

10130 # SSE transport generates its own session_id - server-initiated, not client-provided 

10131 transport = SSETransport(base_url=base_url) 

10132 await transport.connect() 

10133 await session_registry.add_session(transport.session_id, transport) 

10134 await session_registry.set_session_owner(transport.session_id, get_user_email(user)) 

10135 

10136 # Defensive cleanup callback - runs immediately on client disconnect 

10137 async def on_disconnect_cleanup() -> None: 

10138 """Clean up session when SSE client disconnects.""" 

10139 try: 

10140 await session_registry.remove_session(transport.session_id) 

10141 logger.debug("Defensive session cleanup completed: %s", transport.session_id) 

10142 except Exception as e: 

10143 logger.warning("Defensive session cleanup failed for %s: %s", transport.session_id, e) 

10144 

10145 # Extract auth token from request (header OR cookie, like get_current_user_with_permissions) 

10146 auth_token = None 

10147 auth_header = request.headers.get("authorization", "") 

10148 if auth_header.lower().startswith("bearer "): 

10149 auth_token = auth_header[7:] 

10150 elif hasattr(request, "cookies") and request.cookies: 

10151 # Cookie auth (admin UI sessions) 

10152 auth_token = request.cookies.get("jwt_token") or request.cookies.get("access_token") 

10153 

10154 # Extract and normalize token teams 

10155 # Returns None if no JWT payload (non-JWT auth), or list if JWT exists 

10156 # SECURITY: Preserve None vs [] distinction for admin bypass: 

10157 # - None: unrestricted (admin keeps bypass, non-admin gets their accessible resources) 

10158 # - []: public-only (admin bypass disabled) 

10159 # - [...]: team-scoped access 

10160 token_teams = _get_token_teams_from_request(request) 

10161 

10162 # Preserve is_admin from user object (for cookie-authenticated admins) 

10163 is_admin = False 

10164 if hasattr(user, "is_admin"): 

10165 is_admin = getattr(user, "is_admin", False) 

10166 elif isinstance(user, dict): 

10167 is_admin = user.get("is_admin", False) or user.get("user", {}).get("is_admin", False) 

10168 

10169 # Create enriched user dict 

10170 user_with_token = dict(user) if isinstance(user, dict) else {"email": getattr(user, "email", str(user))} 

10171 user_with_token["auth_token"] = auth_token 

10172 user_with_token["token_teams"] = token_teams # None for unrestricted, [] for public-only, [...] for team-scoped 

10173 user_with_token["is_admin"] = is_admin # Preserve admin status for fallback token 

10174 

10175 # Capture passthrough headers from the original SSE request for loopback /rpc calls. 

10176 # Without this, headers like X-Upstream-Authorization are silently dropped. See #3640. 

10177 # First-Party 

10178 from mcpgateway.utils.passthrough_headers import safe_extract_headers_for_loopback # pylint: disable=import-outside-toplevel 

10179 

10180 user_with_token["_passthrough_headers"] = safe_extract_headers_for_loopback(dict(request.headers), "SSE") 

10181 

10182 # Create respond task and register for cancellation on disconnect 

10183 respond_task = asyncio.create_task(session_registry.respond(None, user_with_token, session_id=transport.session_id)) 

10184 session_registry.register_respond_task(transport.session_id, respond_task) 

10185 

10186 try: 

10187 response = await transport.create_sse_response(request, on_disconnect_callback=on_disconnect_cleanup) 

10188 except asyncio.CancelledError: 

10189 # Request cancelled - still need to clean up to prevent orphaned tasks 

10190 logger.debug("SSE request cancelled for %s, cleaning up", transport.session_id) 

10191 try: 

10192 await session_registry.remove_session(transport.session_id) 

10193 except Exception as cleanup_error: 

10194 logger.warning("Cleanup after SSE cancellation failed: %s", cleanup_error) 

10195 raise # Re-raise CancelledError 

10196 except Exception as sse_error: 

10197 # CRITICAL: Cleanup on failure - respond task and session would be orphaned otherwise 

10198 logger.error("create_sse_response failed for %s: %s", transport.session_id, sse_error) 

10199 try: 

10200 await session_registry.remove_session(transport.session_id) 

10201 except Exception as cleanup_error: 

10202 logger.warning("Cleanup after SSE failure also failed: %s", cleanup_error) 

10203 raise 

10204 

10205 tasks = BackgroundTasks() 

10206 tasks.add_task(session_registry.remove_session, transport.session_id) 

10207 response.background = tasks 

10208 logger.info("SSE connection established: %s", transport.session_id) 

10209 return response 

10210 except Exception as e: 

10211 logger.error("SSE connection error: %s", e) 

10212 raise HTTPException(status_code=500, detail="SSE connection failed") 

10213 

10214 

10215@utility_router.post("/message") 

10216@require_permission("tools.execute") 

10217async def utility_message_endpoint(request: Request, user=Depends(get_current_user_with_permissions)): 

10218 """ 

10219 Handle a JSON-RPC message directed to a specific SSE session. 

10220 

10221 Args: 

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

10223 user (str): Authenticated user. 

10224 

10225 Returns: 

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

10227 

10228 Raises: 

10229 HTTPException: * **400 Bad Request** - ``session_id`` query parameter is missing or the payload cannot be parsed as JSON. 

10230 * **500 Internal Server Error** - An unexpected error occurs while broadcasting the message. 

10231 """ 

10232 try: 

10233 logger.debug("User %s sent a message to SSE session", user) 

10234 

10235 session_id = request.query_params.get("session_id") 

10236 if not session_id: 

10237 logger.error("Missing session_id in message request") 

10238 raise HTTPException(status_code=400, detail="Missing session_id") 

10239 set_trace_session_id(session_id) 

10240 

10241 await _assert_session_owner_or_admin(request, user, session_id) 

10242 

10243 message = await _read_request_json(request) 

10244 

10245 await session_registry.broadcast( 

10246 session_id=session_id, 

10247 message=message, 

10248 ) 

10249 

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

10251 

10252 except ValueError as e: 

10253 logger.error("Invalid message format: %s", e) 

10254 raise HTTPException(status_code=400, detail=str(e)) 

10255 except HTTPException: 

10256 raise 

10257 except Exception as exc: 

10258 logger.error("Message handling error: %s", exc) 

10259 raise HTTPException(status_code=500, detail="Failed to process message") 

10260 

10261 

10262@utility_router.post("/logging/setLevel") 

10263@require_permission("admin.system_config") 

10264async def set_log_level(request: Request, user=Depends(get_current_user_with_permissions)) -> None: 

10265 """ 

10266 Update the server's log level at runtime. 

10267 

10268 Args: 

10269 request: HTTP request with log level JSON body. 

10270 user: Authenticated user. 

10271 """ 

10272 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested to set log level") 

10273 body = await _read_request_json(request) 

10274 level = LogLevel(body["level"]) 

10275 await logging_service.set_level(level) 

10276 

10277 

10278#################### 

10279# Metrics # 

10280#################### 

10281@metrics_router.get("", response_model=MetricsResponse) 

10282@require_permission("admin.metrics") 

10283async def get_metrics(db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> MetricsResponse: 

10284 """ 

10285 Retrieve aggregated metrics for all entity types (Tools, Resources, Servers, Prompts, A2A Agents). 

10286 

10287 Args: 

10288 db: Database session 

10289 user: Authenticated user 

10290 

10291 Returns: 

10292 A MetricsResponse with keys for each entity type and their aggregated metrics. 

10293 """ 

10294 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested aggregated metrics") 

10295 tool_metrics = await tool_service.aggregate_metrics(db) 

10296 resource_metrics = await resource_service.aggregate_metrics(db) 

10297 server_metrics = await server_service.aggregate_metrics(db) 

10298 prompt_metrics = await prompt_service.aggregate_metrics(db) 

10299 

10300 kwargs = { 

10301 "tools": tool_metrics, 

10302 "resources": resource_metrics, 

10303 "servers": server_metrics, 

10304 "prompts": prompt_metrics, 

10305 } 

10306 

10307 if a2a_service and settings.mcpgateway_a2a_metrics_enabled: 

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

10309 

10310 return MetricsResponse(**kwargs) 

10311 

10312 

10313@metrics_router.post("/reset", response_model=dict) 

10314@require_permission("admin.metrics") 

10315async 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: 

10316 """ 

10317 Reset metrics for a specific entity type and optionally a specific entity ID, 

10318 or perform a global reset if no entity is specified. 

10319 

10320 Args: 

10321 entity: One of "tool", "resource", "server", "prompt", "a2a_agent", or None for global reset. 

10322 entity_id: Specific entity ID to reset metrics for (optional). 

10323 db: Database session 

10324 user: Authenticated user 

10325 

10326 Returns: 

10327 A success message in a dictionary. 

10328 

10329 Raises: 

10330 HTTPException: If an invalid entity type is specified. 

10331 """ 

10332 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested metrics reset for entity: {entity}, id: {entity_id}") 

10333 if entity is None: 

10334 # Global reset 

10335 await tool_service.reset_metrics(db) 

10336 await resource_service.reset_metrics(db) 

10337 await server_service.reset_metrics(db) 

10338 await prompt_service.reset_metrics(db) 

10339 if a2a_service and settings.mcpgateway_a2a_metrics_enabled: 

10340 await a2a_service.reset_metrics(db) 

10341 elif entity.lower() == "tool": 

10342 await tool_service.reset_metrics(db, entity_id) 

10343 elif entity.lower() == "resource": 

10344 await resource_service.reset_metrics(db) 

10345 elif entity.lower() == "server": 

10346 await server_service.reset_metrics(db) 

10347 elif entity.lower() == "prompt": 

10348 await prompt_service.reset_metrics(db) 

10349 elif entity.lower() in ("a2a_agent", "a2a"): 

10350 if a2a_service and settings.mcpgateway_a2a_metrics_enabled: 

10351 await a2a_service.reset_metrics(db, str(entity_id) if entity_id is not None else None) 

10352 else: 

10353 raise HTTPException(status_code=400, detail="A2A features are disabled") 

10354 else: 

10355 raise HTTPException(status_code=400, detail="Invalid entity type for metrics reset") 

10356 return {"status": "success", "message": f"Metrics reset for {entity if entity else 'all entities'}"} 

10357 

10358 

10359#################### 

10360# Healthcheck # 

10361#################### 

10362@app.get("/health") 

10363def healthcheck(response: Response = None): 

10364 """ 

10365 Perform a basic health check to verify database connectivity. 

10366 

10367 Sync function so FastAPI runs it in a threadpool, avoiding event loop blocking. 

10368 Uses a dedicated session to avoid cross-thread issues and double-commit 

10369 from get_db dependency. All DB operations happen in the same thread. 

10370 

10371 Args: 

10372 response: Optional response object used to attach runtime-mode headers. 

10373 

10374 Returns: 

10375 A dictionary with the health status and optional error message. 

10376 """ 

10377 db = SessionLocal() 

10378 try: 

10379 db.execute(text("SELECT 1")) 

10380 # Explicitly commit to release PgBouncer backend connection in transaction mode. 

10381 db.commit() 

10382 if response is not None: 

10383 _apply_runtime_mode_headers(response) 

10384 return {"status": "healthy", "mcp_runtime": _mcp_runtime_status_payload()} 

10385 except Exception as e: 

10386 # Rollback, then invalidate if rollback fails (mirrors get_db cleanup). 

10387 try: 

10388 db.rollback() 

10389 except Exception: 

10390 try: 

10391 db.invalidate() 

10392 except Exception: 

10393 pass # nosec B110 - Best effort cleanup on connection failure 

10394 error_message = f"Database connection error: {str(e)}" 

10395 logger.error(error_message) 

10396 if response is not None: 

10397 _apply_runtime_mode_headers(response) 

10398 return {"status": "unhealthy", "error": error_message, "mcp_runtime": _mcp_runtime_status_payload()} 

10399 finally: 

10400 db.close() 

10401 

10402 

10403@app.get("/ready") 

10404async def readiness_check(): 

10405 """ 

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

10407 

10408 Creates and manages its own session inside the worker thread to ensure all DB 

10409 operations (create, execute, commit, rollback, close) happen in the same thread. 

10410 This avoids cross-thread session issues and double-commit from get_db. 

10411 

10412 Returns: 

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

10414 """ 

10415 

10416 def _check_db() -> str | None: 

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

10418 

10419 Returns: 

10420 None if successful, error message string if failed. 

10421 """ 

10422 # Create session in this thread - all DB operations stay in the same thread. 

10423 db = SessionLocal() 

10424 try: 

10425 db.execute(text("SELECT 1")) 

10426 # Explicitly commit to release PgBouncer backend connection. 

10427 db.commit() 

10428 return None # Success 

10429 except Exception as e: 

10430 # Rollback, then invalidate if rollback fails (mirrors get_db cleanup). 

10431 try: 

10432 db.rollback() 

10433 except Exception: 

10434 try: 

10435 db.invalidate() 

10436 except Exception: 

10437 pass # nosec B110 - Best effort cleanup on connection failure 

10438 return str(e) 

10439 finally: 

10440 db.close() 

10441 

10442 # Run the blocking DB check in a thread to avoid blocking the event loop. 

10443 error = await asyncio.to_thread(_check_db) 

10444 if error: 

10445 error_message = f"Readiness check failed: {error}" 

10446 logger.error(error_message) 

10447 response = ORJSONResponse(content={"status": "not ready", "error": error_message, "mcp_runtime": _mcp_runtime_status_payload()}, status_code=503) 

10448 _apply_runtime_mode_headers(response) 

10449 return response 

10450 response = ORJSONResponse(content={"status": "ready", "mcp_runtime": _mcp_runtime_status_payload()}, status_code=200) 

10451 _apply_runtime_mode_headers(response) 

10452 return response 

10453 

10454 

10455@app.get("/health/security", tags=["health"]) 

10456async def security_health(request: Request, _user=Depends(require_admin_auth)): # pylint: disable=unused-argument 

10457 """ 

10458 Get the security configuration health status (admin only). 

10459 

10460 Args: 

10461 request (Request): The incoming HTTP request. 

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

10463 

10464 Returns: 

10465 dict: A dictionary containing the overall security health status, score, 

10466 individual checks, warning count, and timestamp. 

10467 """ 

10468 security_status = settings.get_security_status() 

10469 

10470 # Determine overall health 

10471 score = security_status["security_score"] 

10472 is_healthy = score >= 60 # Minimum acceptable score 

10473 

10474 # Build response 

10475 response = { 

10476 "status": "healthy" if is_healthy else "unhealthy", 

10477 "score": score, 

10478 "checks": { 

10479 "authentication": security_status["auth_enabled"], 

10480 "secure_secrets": security_status["secure_secrets"], 

10481 "ssl_verification": security_status["ssl_verification"], 

10482 "debug_disabled": security_status["debug_disabled"], 

10483 "cors_restricted": security_status["cors_restricted"], 

10484 "ui_protected": security_status["ui_protected"], 

10485 }, 

10486 "warning_count": len(security_status["warnings"]), 

10487 "timestamp": datetime.now(timezone.utc).isoformat(), 

10488 } 

10489 

10490 # Include warnings for admin users 

10491 if security_status["warnings"]: 

10492 response["warnings"] = security_status["warnings"] 

10493 

10494 return response 

10495 

10496 

10497#################### 

10498# Tag Endpoints # 

10499#################### 

10500 

10501 

10502@tag_router.get("", response_model=List[TagInfo]) 

10503@tag_router.get("/", response_model=List[TagInfo]) 

10504@require_permission("tags.read") 

10505async def list_tags( 

10506 request: Request, 

10507 entity_types: Optional[str] = None, 

10508 include_entities: bool = False, 

10509 db: Session = Depends(get_db), 

10510 user=Depends(get_current_user_with_permissions), 

10511) -> List[TagInfo]: 

10512 """ 

10513 Retrieve all unique tags across specified entity types. 

10514 

10515 Args: 

10516 request: FastAPI request object used to derive token/team visibility scope 

10517 entity_types: Comma-separated list of entity types to filter by 

10518 (e.g., "tools,resources,prompts,servers,gateways"). 

10519 If not provided, returns tags from all entity types. 

10520 include_entities: Whether to include the list of entities that have each tag 

10521 db: Database session 

10522 user: Authenticated user 

10523 

10524 Returns: 

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

10526 

10527 Raises: 

10528 HTTPException: If tag retrieval fails 

10529 """ 

10530 # Parse entity types parameter if provided 

10531 entity_types_list = None 

10532 if entity_types: 

10533 entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()] 

10534 

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

10536 

10537 try: 

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

10539 if is_admin and token_teams is None: 

10540 user_email = None 

10541 elif token_teams is None: 

10542 token_teams = [] 

10543 

10544 tags = await tag_service.get_all_tags( 

10545 db, 

10546 entity_types=entity_types_list, 

10547 include_entities=include_entities, 

10548 user_email=user_email, 

10549 token_teams=token_teams, 

10550 ) 

10551 return tags 

10552 except Exception as e: 

10553 logger.error(f"Failed to retrieve tags: {str(e)}") 

10554 raise HTTPException(status_code=500, detail=f"Failed to retrieve tags: {str(e)}") 

10555 

10556 

10557@tag_router.get("/{tag_name}/entities", response_model=List[TaggedEntity]) 

10558@require_permission("tags.read") 

10559async def get_entities_by_tag( 

10560 request: Request, 

10561 tag_name: str, 

10562 entity_types: Optional[str] = None, 

10563 db: Session = Depends(get_db), 

10564 user=Depends(get_current_user_with_permissions), 

10565) -> List[TaggedEntity]: 

10566 """ 

10567 Get all entities that have a specific tag. 

10568 

10569 Args: 

10570 request: FastAPI request object used to derive token/team visibility scope 

10571 tag_name: The tag to search for 

10572 entity_types: Comma-separated list of entity types to filter by 

10573 (e.g., "tools,resources,prompts,servers,gateways"). 

10574 If not provided, returns entities from all types. 

10575 db: Database session 

10576 user: Authenticated user 

10577 

10578 Returns: 

10579 List of TaggedEntity objects 

10580 

10581 Raises: 

10582 HTTPException: If entity retrieval fails 

10583 """ 

10584 # Parse entity types parameter if provided 

10585 entity_types_list = None 

10586 if entity_types: 

10587 entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()] 

10588 

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

10590 

10591 try: 

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

10593 if is_admin and token_teams is None: 

10594 user_email = None 

10595 elif token_teams is None: 

10596 token_teams = [] 

10597 

10598 entities = await tag_service.get_entities_by_tag( 

10599 db, 

10600 tag_name=tag_name, 

10601 entity_types=entity_types_list, 

10602 user_email=user_email, 

10603 token_teams=token_teams, 

10604 ) 

10605 return entities 

10606 except Exception as e: 

10607 logger.error(f"Failed to retrieve entities for tag '{tag_name}': {str(e)}") 

10608 raise HTTPException(status_code=500, detail=f"Failed to retrieve entities: {str(e)}") 

10609 

10610 

10611#################### 

10612# Export/Import # 

10613#################### 

10614 

10615 

10616@export_import_router.get("/export", response_model=Dict[str, Any]) 

10617@require_permission("admin.export") 

10618async def export_configuration( 

10619 request: Request, # pylint: disable=unused-argument 

10620 export_format: str = "json", # pylint: disable=unused-argument 

10621 types: Optional[str] = None, 

10622 exclude_types: Optional[str] = None, 

10623 tags: Optional[str] = None, 

10624 include_inactive: bool = False, 

10625 include_dependencies: bool = True, 

10626 db: Session = Depends(get_db), 

10627 user=Depends(get_current_user_with_permissions), 

10628) -> Dict[str, Any]: 

10629 """ 

10630 Export gateway configuration to JSON format. 

10631 

10632 Args: 

10633 request: FastAPI request object for extracting root path 

10634 export_format: Export format (currently only 'json' supported) 

10635 types: Comma-separated list of entity types to include (tools,gateways,servers,prompts,resources,roots) 

10636 exclude_types: Comma-separated list of entity types to exclude 

10637 tags: Comma-separated list of tags to filter by 

10638 include_inactive: Whether to include inactive entities 

10639 include_dependencies: Whether to include dependent entities 

10640 db: Database session 

10641 user: Authenticated user 

10642 

10643 Returns: 

10644 Export data in the specified format 

10645 

10646 Raises: 

10647 HTTPException: If export fails 

10648 """ 

10649 try: 

10650 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested configuration export") 

10651 username: Optional[str] = None 

10652 # Parse parameters 

10653 include_types = None 

10654 if types: 

10655 include_types = [t.strip() for t in types.split(",") if t.strip()] 

10656 

10657 exclude_types_list = None 

10658 if exclude_types: 

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

10660 

10661 tags_list = None 

10662 if tags: 

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

10664 

10665 # Extract username from user (which is now an EmailUser object) 

10666 if hasattr(user, "email"): 

10667 username = getattr(user, "email", None) 

10668 elif isinstance(user, dict): 

10669 username = user.get("email", None) 

10670 else: 

10671 username = None 

10672 

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

10674 root_path = settings.app_root_path 

10675 

10676 # Derive team-scoped visibility from the requesting user's token 

10677 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user) 

10678 

10679 # Perform export 

10680 export_data = await export_service.export_configuration( 

10681 db=db, 

10682 include_types=include_types, 

10683 exclude_types=exclude_types_list, 

10684 tags=tags_list, 

10685 include_inactive=include_inactive, 

10686 include_dependencies=include_dependencies, 

10687 exported_by=username or "unknown", 

10688 root_path=root_path, 

10689 user_email=scoped_user_email, 

10690 token_teams=scoped_token_teams, 

10691 ) 

10692 

10693 return export_data 

10694 

10695 except ExportError as e: 

10696 logger.error(f"Export failed for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

10697 raise HTTPException(status_code=400, detail=str(e)) 

10698 except Exception as e: 

10699 logger.error(f"Unexpected export error for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

10700 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}") 

10701 

10702 

10703@export_import_router.post("/export/selective", response_model=Dict[str, Any]) 

10704@require_permission("admin.export") 

10705async def export_selective_configuration( 

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

10707) -> Dict[str, Any]: 

10708 """ 

10709 Export specific entities by their IDs/names. 

10710 

10711 Args: 

10712 request: FastAPI request object for token scope context 

10713 entity_selections: Dict mapping entity types to lists of IDs/names to export 

10714 include_dependencies: Whether to include dependent entities 

10715 db: Database session 

10716 user: Authenticated user 

10717 

10718 Returns: 

10719 Selective export data 

10720 

10721 Raises: 

10722 HTTPException: If export fails 

10723 

10724 Example request body: 

10725 { 

10726 "tools": ["tool1", "tool2"], 

10727 "servers": ["server1"], 

10728 "prompts": ["prompt1"] 

10729 } 

10730 """ 

10731 try: 

10732 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested selective configuration export") 

10733 

10734 username: Optional[str] = None 

10735 # Extract username from user (which is now an EmailUser object) 

10736 if hasattr(user, "email"): 

10737 username = getattr(user, "email", None) 

10738 elif isinstance(user, dict): 

10739 username = user.get("email") 

10740 

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

10742 root_path = settings.app_root_path 

10743 

10744 # Derive team-scoped visibility from the requesting user's token 

10745 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user) 

10746 

10747 export_data = await export_service.export_selective( 

10748 db=db, 

10749 entity_selections=entity_selections, 

10750 include_dependencies=include_dependencies, 

10751 exported_by=username or "unknown", 

10752 root_path=root_path, 

10753 user_email=scoped_user_email, 

10754 token_teams=scoped_token_teams, 

10755 ) 

10756 

10757 return export_data 

10758 

10759 except ExportError as e: 

10760 logger.error(f"Selective export failed for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

10761 raise HTTPException(status_code=400, detail=str(e)) 

10762 except Exception as e: 

10763 logger.error(f"Unexpected selective export error for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

10764 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}") 

10765 

10766 

10767@export_import_router.post("/import", response_model=Dict[str, Any]) 

10768@require_permission("admin.import") 

10769async def import_configuration( 

10770 import_data: Dict[str, Any] = Body(...), 

10771 conflict_strategy: str = Body("update"), 

10772 dry_run: bool = Body(False), 

10773 rekey_secret: Optional[str] = Body(None), 

10774 selected_entities: Optional[Dict[str, List[str]]] = Body(None), 

10775 db: Session = Depends(get_db), 

10776 user=Depends(get_current_user_with_permissions), 

10777) -> Dict[str, Any]: 

10778 """ 

10779 Import configuration data with conflict resolution. 

10780 

10781 Args: 

10782 import_data: The configuration data to import 

10783 conflict_strategy: How to handle conflicts: skip, update, rename, fail 

10784 dry_run: If true, validate but don't make changes 

10785 rekey_secret: New encryption secret for cross-environment imports 

10786 selected_entities: Dict of entity types to specific entity names/ids to import 

10787 db: Database session 

10788 user: Authenticated user 

10789 

10790 Returns: 

10791 Import status and results 

10792 

10793 Raises: 

10794 HTTPException: If import fails or validation errors occur 

10795 """ 

10796 try: 

10797 if not import_data: 

10798 raise HTTPException(status_code=400, detail="Missing 'import_data' in request body") 

10799 

10800 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested configuration import (dry_run={dry_run})") 

10801 

10802 # Validate conflict strategy 

10803 try: 

10804 strategy = ConflictStrategy(conflict_strategy.lower()) 

10805 except ValueError: 

10806 raise HTTPException(status_code=400, detail=f"Invalid conflict strategy. Must be one of: {[s.value for s in list(ConflictStrategy)]}") 

10807 

10808 # Extract username from user (which is now an EmailUser object) 

10809 if hasattr(user, "email"): 

10810 username = getattr(user, "email", None) 

10811 elif isinstance(user, dict): 

10812 username = user.get("email", None) 

10813 else: 

10814 username = None 

10815 

10816 # Perform import 

10817 import_status = await import_service.import_configuration( 

10818 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 

10819 ) 

10820 

10821 return import_status.to_dict() 

10822 

10823 except HTTPException: 

10824 raise 

10825 except ImportValidationError as e: 

10826 logger.error(f"Import validation failed for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

10827 raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}") 

10828 except ImportConflictError as e: 

10829 logger.error(f"Import conflict for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

10830 raise HTTPException(status_code=409, detail=f"Conflict error: {str(e)}") 

10831 except ImportServiceError as e: 

10832 logger.error(f"Import failed for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

10833 raise HTTPException(status_code=400, detail=str(e)) 

10834 except Exception as e: 

10835 logger.error(f"Unexpected import error for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}") 

10836 raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}") 

10837 

10838 

10839@export_import_router.get("/import/status/{import_id}", response_model=Dict[str, Any]) 

10840@require_permission("admin.import") 

10841async def get_import_status(import_id: str, user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: 

10842 """ 

10843 Get the status of an import operation. 

10844 

10845 Args: 

10846 import_id: The import operation ID 

10847 user: Authenticated user 

10848 

10849 Returns: 

10850 Import status information 

10851 

10852 Raises: 

10853 HTTPException: If import not found 

10854 """ 

10855 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested import status for {import_id}") 

10856 

10857 import_status = import_service.get_import_status(import_id) 

10858 if not import_status: 

10859 raise HTTPException(status_code=404, detail=f"Import {import_id} not found") 

10860 

10861 return import_status.to_dict() 

10862 

10863 

10864@export_import_router.get("/import/status", response_model=List[Dict[str, Any]]) 

10865@require_permission("admin.import") 

10866async def list_import_statuses(user=Depends(get_current_user_with_permissions)) -> List[Dict[str, Any]]: 

10867 """ 

10868 List all import operation statuses. 

10869 

10870 Args: 

10871 user: Authenticated user 

10872 

10873 Returns: 

10874 List of import status information 

10875 """ 

10876 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested all import statuses") 

10877 

10878 statuses = import_service.list_import_statuses() 

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

10880 

10881 

10882@export_import_router.post("/import/cleanup", response_model=Dict[str, Any]) 

10883@require_permission("admin.import") 

10884async def cleanup_import_statuses(max_age_hours: int = 24, user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: 

10885 """ 

10886 Clean up completed import statuses older than specified age. 

10887 

10888 Args: 

10889 max_age_hours: Maximum age in hours for keeping completed imports 

10890 user: Authenticated user 

10891 

10892 Returns: 

10893 Cleanup results 

10894 """ 

10895 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested import status cleanup (max_age_hours={max_age_hours})") 

10896 

10897 removed_count = import_service.cleanup_completed_imports(max_age_hours) 

10898 return {"status": "success", "message": f"Cleaned up {removed_count} completed import statuses", "removed_count": removed_count} 

10899 

10900 

10901# Mount static files 

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

10903 

10904# Include routers 

10905app.include_router(version_router) 

10906app.include_router(protocol_router) 

10907app.include_router(tool_router) 

10908app.include_router(resource_router) 

10909app.include_router(prompt_router) 

10910app.include_router(gateway_router) 

10911app.include_router(root_router) 

10912app.include_router(utility_router) 

10913app.include_router(server_router) 

10914app.include_router(server_well_known_router, prefix="/servers") 

10915app.include_router(metrics_router) 

10916app.include_router(tag_router) 

10917app.include_router(export_import_router) 

10918 

10919# Include log search router if structured logging is enabled 

10920if getattr(settings, "structured_logging_enabled", True): 

10921 try: 

10922 # First-Party 

10923 from mcpgateway.routers.log_search import router as log_search_router 

10924 

10925 app.include_router(log_search_router) 

10926 logger.info("Log search router included - structured logging enabled") 

10927 except ImportError as e: 

10928 logger.warning(f"Failed to import log search router: {e}") 

10929else: 

10930 logger.info("Log search router not included - structured logging disabled") 

10931 

10932# Conditionally include observability router if enabled 

10933if settings.observability_enabled: 

10934 # First-Party 

10935 from mcpgateway.routers.observability import router as observability_router 

10936 

10937 app.include_router(observability_router) 

10938 logger.info("Observability router included - observability API endpoints enabled") 

10939else: 

10940 logger.info("Observability router not included - observability disabled") 

10941 

10942# Conditionally include metrics maintenance router if cleanup or rollup is enabled 

10943if settings.metrics_cleanup_enabled or settings.metrics_rollup_enabled: 

10944 # First-Party 

10945 from mcpgateway.routers.metrics_maintenance import router as metrics_maintenance_router 

10946 

10947 app.include_router(metrics_maintenance_router) 

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

10949 

10950# Conditionally include A2A router if A2A features are enabled 

10951if settings.mcpgateway_a2a_enabled: 

10952 app.include_router(a2a_router) 

10953 logger.info("A2A router included - A2A features enabled") 

10954else: 

10955 logger.info("A2A router not included - A2A features disabled") 

10956 

10957app.include_router(well_known_router) 

10958 

10959# Include Email Authentication router if enabled 

10960if settings.email_auth_enabled: 

10961 try: 

10962 # First-Party 

10963 from mcpgateway.routers.auth import auth_router 

10964 from mcpgateway.routers.email_auth import email_auth_router 

10965 

10966 app.include_router(email_auth_router, prefix="/auth/email", tags=["Email Authentication"]) 

10967 app.include_router(auth_router, tags=["Main Authentication"]) 

10968 logger.info("Authentication routers included - Auth enabled") 

10969 

10970 # Include SSO router if enabled 

10971 if settings.sso_enabled: 

10972 try: 

10973 # First-Party 

10974 from mcpgateway.routers.sso import sso_router 

10975 

10976 app.include_router(sso_router, tags=["SSO Authentication"]) 

10977 logger.info("SSO router included - SSO authentication enabled") 

10978 except ImportError as e: 

10979 logger.error(f"SSO router not available: {e}") 

10980 else: 

10981 logger.info("SSO router not included - SSO authentication disabled") 

10982 except ImportError as e: 

10983 logger.error(f"Authentication routers not available: {e}") 

10984else: 

10985 logger.info("Email authentication router not included - Email auth disabled") 

10986 

10987# Include Team Management router if email auth is enabled 

10988if settings.email_auth_enabled: 

10989 try: 

10990 # First-Party 

10991 from mcpgateway.routers.teams import teams_router 

10992 

10993 app.include_router(teams_router, prefix="/teams", tags=["Teams"]) 

10994 logger.info("Team management router included - Teams enabled with email auth") 

10995 except ImportError as e: 

10996 logger.error(f"Team management router not available: {e}") 

10997else: 

10998 logger.info("Team management router not included - Email auth disabled") 

10999 

11000# Include JWT Token Catalog router if email auth is enabled 

11001if settings.email_auth_enabled: 

11002 try: 

11003 # First-Party 

11004 from mcpgateway.routers.tokens import router as tokens_router 

11005 

11006 app.include_router(tokens_router, tags=["JWT Token Catalog"]) 

11007 logger.info("JWT Token Catalog router included - Token management enabled with email auth") 

11008 except ImportError as e: 

11009 logger.error(f"JWT Token Catalog router not available: {e}") 

11010else: 

11011 logger.info("JWT Token Catalog router not included - Email auth disabled") 

11012 

11013# Include RBAC router if email auth is enabled 

11014if settings.email_auth_enabled: 

11015 try: 

11016 # First-Party 

11017 from mcpgateway.routers.rbac import router as rbac_router 

11018 

11019 app.include_router(rbac_router, tags=["RBAC"]) 

11020 logger.info("RBAC router included - Role-based access control enabled") 

11021 except ImportError as e: 

11022 logger.error(f"RBAC router not available: {e}") 

11023else: 

11024 logger.info("RBAC router not included - Email auth disabled") 

11025 

11026# Include OAuth router 

11027try: 

11028 # First-Party 

11029 from mcpgateway.routers.oauth_router import oauth_router 

11030 

11031 app.include_router(oauth_router) 

11032 logger.info("OAuth router included") 

11033except ImportError: 

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

11035 

11036# Include reverse proxy router if enabled 

11037if settings.mcpgateway_reverse_proxy_enabled: 

11038 try: 

11039 # First-Party 

11040 from mcpgateway.routers.reverse_proxy import router as reverse_proxy_router 

11041 

11042 app.include_router(reverse_proxy_router) 

11043 logger.info("Reverse proxy router included") 

11044 except ImportError: 

11045 logger.debug("Reverse proxy router not available") 

11046else: 

11047 logger.info("Reverse proxy router not included - feature disabled") 

11048 

11049# Include LLMChat router 

11050if settings.llmchat_enabled: 

11051 try: 

11052 # First-Party 

11053 from mcpgateway.routers.llmchat_router import llmchat_router 

11054 

11055 app.include_router(llmchat_router) 

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

11057 except ImportError: 

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

11059 

11060 # Include LLM configuration and proxy routers (internal API) 

11061 try: 

11062 # First-Party 

11063 from mcpgateway.routers.llm_admin_router import llm_admin_router 

11064 from mcpgateway.routers.llm_config_router import llm_config_router 

11065 from mcpgateway.routers.llm_proxy_router import llm_proxy_router 

11066 

11067 app.include_router(llm_config_router, prefix="/llm", tags=["LLM Configuration"]) 

11068 app.include_router(llm_proxy_router, prefix=settings.llm_api_prefix, tags=["LLM Proxy"]) 

11069 app.include_router(llm_admin_router, prefix="/admin/llm", tags=["LLM Admin"]) 

11070 logger.info("LLM configuration, proxy, and admin routers included") 

11071 except ImportError as e: 

11072 logger.debug(f"LLM routers not available: {e}") 

11073 

11074# Include Toolops router 

11075if settings.toolops_enabled: 

11076 try: 

11077 # First-Party 

11078 from mcpgateway.routers.toolops_router import toolops_router 

11079 

11080 app.include_router(toolops_router) 

11081 logger.info("Toolops router included") 

11082 except ImportError: 

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

11084 

11085# Cancellation router (tool cancellation endpoints) 

11086if settings.mcpgateway_tool_cancellation_enabled: 

11087 try: 

11088 # First-Party 

11089 from mcpgateway.routers.cancellation_router import router as cancellation_router 

11090 

11091 app.include_router(cancellation_router) 

11092 logger.info("Cancellation router included (tool cancellation enabled)") 

11093 except ImportError: 

11094 logger.debug("Orchestrate router not available") 

11095else: 

11096 logger.info("Tool cancellation feature disabled - cancellation endpoints not available") 

11097 

11098# Feature flags for admin UI and API 

11099UI_ENABLED = settings.mcpgateway_ui_enabled 

11100ADMIN_API_ENABLED = settings.mcpgateway_admin_api_enabled 

11101logger.info(f"Admin UI enabled: {UI_ENABLED}") 

11102logger.info(f"Admin API enabled: {ADMIN_API_ENABLED}") 

11103 

11104# Conditional UI and admin API handling 

11105if ADMIN_API_ENABLED: 

11106 logger.info("Including admin_router - Admin API enabled") 

11107 app.include_router(admin_router) # Admin routes imported from admin.py 

11108 

11109 # Validate section-to-permission mapping consistency at startup 

11110 # First-Party 

11111 from mcpgateway.admin import validate_section_permissions 

11112 

11113 validate_section_permissions(admin_router) 

11114else: 

11115 logger.warning("Admin API routes not mounted - Admin API disabled via MCPGATEWAY_ADMIN_API_ENABLED=False") 

11116 

11117 

11118class MCPRuntimeHeaderTransportWrapper: 

11119 """Annotate Python-owned MCP transport responses with the active runtime marker.""" 

11120 

11121 def __init__(self, transport_app, *, runtime_name: str) -> None: 

11122 """Wrap an MCP transport app and stamp a runtime header on responses. 

11123 

11124 Args: 

11125 transport_app: Underlying MCP transport app. 

11126 runtime_name: Runtime label to expose via response headers. 

11127 """ 

11128 self.transport_app = transport_app 

11129 self.runtime_name = runtime_name.encode("ascii") 

11130 

11131 async def handle_streamable_http(self, scope, receive, send): 

11132 """Forward an MCP request while ensuring the runtime marker header is present. 

11133 

11134 Args: 

11135 scope: Incoming ASGI scope. 

11136 receive: ASGI receive callable. 

11137 send: ASGI send callable. 

11138 """ 

11139 

11140 async def _send_with_runtime_header(message): 

11141 """Attach MCP runtime mode headers before sending the ASGI event downstream. 

11142 

11143 Args: 

11144 message: Outgoing ASGI message emitted by the wrapped application. 

11145 """ 

11146 if message.get("type") == "http.response.start": 

11147 headers = list(message.get("headers") or []) 

11148 if not any(isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-runtime" for item in headers): 

11149 headers.append((b"x-contextforge-mcp-runtime", self.runtime_name)) 

11150 if not any( 

11151 isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-session-core" for item in headers 

11152 ): 

11153 headers.append((b"x-contextforge-mcp-session-core", _current_mcp_session_core_mode().encode("ascii"))) 

11154 if not any(isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-resume-core" for item in headers): 

11155 headers.append((b"x-contextforge-mcp-resume-core", _current_mcp_resume_core_mode().encode("ascii"))) 

11156 if not any( 

11157 isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-live-stream-core" for item in headers 

11158 ): 

11159 headers.append((b"x-contextforge-mcp-live-stream-core", _current_mcp_live_stream_core_mode().encode("ascii"))) 

11160 if not any( 

11161 isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-affinity-core" for item in headers 

11162 ): 

11163 headers.append((b"x-contextforge-mcp-affinity-core", _current_mcp_affinity_core_mode().encode("ascii"))) 

11164 if not any( 

11165 isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-session-auth-reuse" for item in headers 

11166 ): 

11167 headers.append((b"x-contextforge-mcp-session-auth-reuse", _current_mcp_session_auth_reuse_mode().encode("ascii"))) 

11168 message = dict(message) 

11169 message["headers"] = headers 

11170 await send(message) 

11171 

11172 await self.transport_app.handle_streamable_http(scope, receive, _send_with_runtime_header) 

11173 

11174 

11175def _build_mcp_transport_app(): 

11176 """Choose the MCP transport app for the mounted /mcp path. 

11177 

11178 Returns: 

11179 Transport app object that should be mounted at the public ``/mcp`` path. 

11180 """ 

11181 if _should_mount_public_rust_transport(): 

11182 logger.warning( 

11183 "MCP runtime mode: %s. GET/POST/DELETE /mcp requests will be proxied to %s. MCP session core mode: %s. MCP replay/resume core mode: %s. MCP live stream core mode: %s. MCP affinity core mode: %s. MCP session auth reuse mode: %s.", 

11184 _current_mcp_runtime_mode(), 

11185 settings.experimental_rust_mcp_runtime_uds or settings.experimental_rust_mcp_runtime_url, 

11186 _current_mcp_session_core_mode(), 

11187 _current_mcp_resume_core_mode(), 

11188 _current_mcp_live_stream_core_mode(), 

11189 _current_mcp_affinity_core_mode(), 

11190 _current_mcp_session_auth_reuse_mode(), 

11191 ) 

11192 return RustMCPRuntimeProxy(streamable_http_session.handle_streamable_http) 

11193 

11194 if settings.experimental_rust_mcp_runtime_enabled: 

11195 logger.warning( 

11196 "MCP runtime mode: %s. Rust sidecar remains enabled, but public /mcp stays on the Python transport because MCP session auth reuse is disabled. MCP session core mode: %s. MCP replay/resume core mode: %s. MCP live stream core mode: %s. MCP affinity core mode: %s. MCP session auth reuse mode: %s.", 

11197 _current_mcp_runtime_mode(), 

11198 _current_mcp_session_core_mode(), 

11199 _current_mcp_resume_core_mode(), 

11200 _current_mcp_live_stream_core_mode(), 

11201 _current_mcp_affinity_core_mode(), 

11202 _current_mcp_session_auth_reuse_mode(), 

11203 ) 

11204 return MCPRuntimeHeaderTransportWrapper(streamable_http_session, runtime_name="python") 

11205 

11206 if _rust_build_included(): 

11207 logger.warning( 

11208 "MCP runtime mode: %s. Rust MCP artifacts are present in this image, but EXPERIMENTAL_RUST_MCP_RUNTIME_ENABLED=false so /mcp remains on the Python transport. Set RUST_MCP_MODE=edge or RUST_MCP_MODE=full to activate the Rust runtime with the simple env flow.", 

11209 _current_mcp_runtime_mode(), 

11210 ) 

11211 else: 

11212 logger.info("MCP runtime mode: %s. /mcp is mounted on the Python transport.", _current_mcp_runtime_mode()) 

11213 

11214 return MCPRuntimeHeaderTransportWrapper(streamable_http_session, runtime_name="python") 

11215 

11216 

11217class InternalTrustedMCPTransportBridge: 

11218 """Trusted internal bridge from Rust MCP transport requests to the Python session manager.""" 

11219 

11220 def __init__(self, transport_app) -> None: 

11221 """Store the underlying Python transport app used for trusted forwarding. 

11222 

11223 Args: 

11224 transport_app: Python transport app that ultimately owns session handling. 

11225 """ 

11226 self.transport_app = transport_app 

11227 

11228 async def handle_streamable_http(self, scope, receive, send): 

11229 """Translate trusted Rust transport requests into Python session-manager calls. 

11230 

11231 Args: 

11232 scope: Incoming ASGI scope. 

11233 receive: ASGI receive callable. 

11234 send: ASGI send callable. 

11235 """ 

11236 if scope.get("type") != "http": 

11237 response = ORJSONResponse(status_code=404, content={"detail": "Not found"}) 

11238 await response(scope, receive, send) 

11239 return 

11240 

11241 method = str(scope.get("method", "GET")).upper() 

11242 if method not in {"GET", "POST", "DELETE"}: 

11243 response = ORJSONResponse(status_code=405, content={"detail": "Method not allowed"}) 

11244 await response(scope, receive, send) 

11245 return 

11246 

11247 request = Request(scope, receive=receive) 

11248 try: 

11249 _build_internal_mcp_forwarded_user(request) 

11250 except HTTPException as exc: 

11251 response = ORJSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) 

11252 await response(scope, receive, send) 

11253 return 

11254 

11255 auth_context = _get_internal_mcp_auth_context(request) or {} 

11256 server_id = request.headers.get("x-contextforge-server-id") 

11257 forwarded_scope = dict(scope) 

11258 forwarded_scope["path"] = "/mcp/" 

11259 forwarded_scope["modified_path"] = f"/servers/{server_id}/mcp" if server_id else "/mcp/" 

11260 forwarded_auth_method = auth_context.get("auth_method") or "mcp_internal_forward" 

11261 

11262 token = user_context_var.set(auth_context) 

11263 try: 

11264 set_trace_context_from_teams( 

11265 auth_context.get("teams"), 

11266 user_email=auth_context.get("email"), 

11267 is_admin=bool(auth_context.get("permission_is_admin", auth_context.get("is_admin", False))), 

11268 auth_method=forwarded_auth_method, 

11269 team_name=auth_context.get("team_name"), 

11270 ) 

11271 await self.transport_app.handle_streamable_http(forwarded_scope, receive, send) 

11272 finally: 

11273 user_context_var.reset(token) 

11274 clear_trace_context() 

11275 

11276 

11277mcp_transport_app = _build_mcp_transport_app() 

11278internal_trusted_mcp_transport = InternalTrustedMCPTransportBridge(streamable_http_session) 

11279 

11280# Streamable http Mount 

11281app.mount("/mcp", app=mcp_transport_app.handle_streamable_http) 

11282app.mount("/_internal/mcp/transport", app=internal_trusted_mcp_transport.handle_streamable_http) 

11283 

11284# Conditional static files mounting and root redirect 

11285if UI_ENABLED: 

11286 # Mount static files for UI 

11287 logger.info("Mounting static files - UI enabled") 

11288 try: 

11289 # Create a sub-application for static files that will respect root_path 

11290 static_app = StaticFiles(directory=str(settings.static_dir)) 

11291 STATIC_PATH = "/static" 

11292 

11293 app.mount( 

11294 STATIC_PATH, 

11295 static_app, 

11296 name="static", 

11297 ) 

11298 logger.info("Static assets served from %s at %s", settings.static_dir, STATIC_PATH) 

11299 except RuntimeError as exc: 

11300 logger.warning( 

11301 "Static dir %s not found - Admin UI disabled (%s)", 

11302 settings.static_dir, 

11303 exc, 

11304 ) 

11305 

11306 # Redirect root path to admin UI 

11307 @app.get("/") 

11308 async def root_redirect(): 

11309 """ 

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

11311 

11312 Logs a debug message before redirecting. 

11313 

11314 Returns: 

11315 RedirectResponse: Redirects to /admin/. 

11316 

11317 Raises: 

11318 HTTPException: If there is an error during redirection. 

11319 """ 

11320 logger.debug("Redirecting root path to /admin/") 

11321 root_path = settings.app_root_path 

11322 return RedirectResponse(f"{root_path}/admin/", status_code=303) 

11323 # return RedirectResponse(request.url_for("admin_home")) 

11324 

11325 # Redirect /favicon.ico to /static/favicon.ico for browser compatibility 

11326 @app.get("/favicon.ico", include_in_schema=False) 

11327 async def favicon_redirect() -> RedirectResponse: 

11328 """Redirect /favicon.ico to /static/favicon.ico for browser compatibility. 

11329 

11330 Returns: 

11331 RedirectResponse: 301 redirect to /static/favicon.ico. 

11332 """ 

11333 root_path = settings.app_root_path 

11334 return RedirectResponse(f"{root_path}/static/favicon.ico", status_code=301) 

11335 

11336else: 

11337 # If UI is disabled, provide API info at root 

11338 logger.warning("Static files not mounted - UI disabled via MCPGATEWAY_UI_ENABLED=False") 

11339 

11340 @app.get("/") 

11341 async def root_info(): 

11342 """ 

11343 Returns basic API information at the root path. 

11344 

11345 Logs an info message indicating UI is disabled and provides details 

11346 about the app, including its name, version, and whether the UI and 

11347 admin API are enabled. 

11348 

11349 Returns: 

11350 dict: API info with app name, version, and UI/admin API status. 

11351 """ 

11352 logger.info("UI disabled, serving API info at root path") 

11353 return {"name": settings.app_name, "description": f"{settings.app_name} API"} 

11354 

11355 

11356# Expose some endpoints at the root level as well 

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

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