Coverage for mcpgateway/main.py: 41%

722 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-22 12:53 +0100

1# -*- coding: utf-8 -*- 

2""" 

3Copyright 2025 

4SPDX-License-Identifier: Apache-2.0 

5Authors: Mihai Criveti 

6 

7MCP Gateway - Main FastAPI Application. 

8 

9This module defines the core FastAPI application for the Model Context Protocol (MCP) Gateway. 

10It serves as the entry point for handling all HTTP and WebSocket traffic. 

11 

12Features and Responsibilities: 

13- Initializes and orchestrates services for tools, resources, prompts, servers, gateways, and roots. 

14- Supports full MCP protocol operations: initialize, ping, notify, complete, and sample. 

15- Integrates authentication (JWT and basic), CORS, caching, and middleware. 

16- Serves a rich Admin UI for managing gateway entities via HTMX-based frontend. 

17- Exposes routes for JSON-RPC, SSE, and WebSocket transports. 

18- Manages application lifecycle including startup and graceful shutdown of all services. 

19 

20Structure: 

21- Declares routers for MCP protocol operations and administration. 

22- Registers dependencies (e.g., DB sessions, auth handlers). 

23- Applies middleware including custom documentation protection. 

24- Configures resource caching and session registry using pluggable backends. 

25- Provides OpenAPI metadata and redirect handling depending on UI feature flags. 

26""" 

27 

28import asyncio 

29import json 

30import logging 

31from contextlib import asynccontextmanager 

32from typing import Any, AsyncIterator, Dict, List, Optional, Union 

33 

34import httpx 

35from fastapi import ( 

36 APIRouter, 

37 Body, 

38 Depends, 

39 FastAPI, 

40 HTTPException, 

41 Request, 

42 WebSocket, 

43 WebSocketDisconnect, 

44 status, 

45) 

46from fastapi.background import BackgroundTasks 

47from fastapi.middleware.cors import CORSMiddleware 

48from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse 

49from fastapi.staticfiles import StaticFiles 

50from fastapi.templating import Jinja2Templates 

51from sqlalchemy import text 

52from sqlalchemy.orm import Session 

53from starlette.middleware.base import BaseHTTPMiddleware 

54 

55from mcpgateway import __version__ 

56from mcpgateway.admin import admin_router 

57from mcpgateway.cache import ResourceCache, SessionRegistry 

58from mcpgateway.config import jsonpath_modifier, settings 

59from mcpgateway.db import Base, SessionLocal, engine 

60from mcpgateway.handlers.sampling import SamplingHandler 

61from mcpgateway.schemas import ( 

62 GatewayCreate, 

63 GatewayRead, 

64 GatewayUpdate, 

65 JsonPathModifier, 

66 PromptCreate, 

67 PromptRead, 

68 PromptUpdate, 

69 ResourceCreate, 

70 ResourceRead, 

71 ResourceUpdate, 

72 ServerCreate, 

73 ServerRead, 

74 ServerUpdate, 

75 ToolCreate, 

76 ToolRead, 

77 ToolUpdate, 

78) 

79from mcpgateway.services.completion_service import CompletionService 

80from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayService 

81from mcpgateway.services.logging_service import LoggingService 

82from mcpgateway.services.prompt_service import ( 

83 PromptError, 

84 PromptNameConflictError, 

85 PromptNotFoundError, 

86 PromptService, 

87) 

88from mcpgateway.services.resource_service import ( 

89 ResourceError, 

90 ResourceNotFoundError, 

91 ResourceService, 

92 ResourceURIConflictError, 

93) 

94from mcpgateway.services.root_service import RootService 

95from mcpgateway.services.server_service import ( 

96 ServerError, 

97 ServerNameConflictError, 

98 ServerNotFoundError, 

99 ServerService, 

100) 

101from mcpgateway.services.tool_service import ( 

102 ToolError, 

103 ToolNameConflictError, 

104 ToolService, 

105) 

106from mcpgateway.transports.sse_transport import SSETransport 

107from mcpgateway.transports.streamablehttp_transport import ( 

108 SessionManagerWrapper, 

109 streamable_http_auth, 

110) 

111from mcpgateway.types import ( 

112 InitializeRequest, 

113 InitializeResult, 

114 ListResourceTemplatesResult, 

115 LogLevel, 

116 ResourceContent, 

117 Root, 

118) 

119from mcpgateway.utils.verify_credentials import require_auth, require_auth_override 

120from mcpgateway.validation.jsonrpc import ( 

121 JSONRPCError, 

122 validate_request, 

123) 

124 

125# Import the admin routes from the new module 

126from mcpgateway.version import router as version_router 

127 

128# Initialize logging service first 

129logging_service = LoggingService() 

130logger = logging_service.get_logger("mcpgateway") 

131 

132# Configure root logger level 

133logging.basicConfig( 

134 level=getattr(logging, settings.log_level.upper()), 

135 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 

136) 

137 

138# Create database tables 

139Base.metadata.create_all(bind=engine) 

140 

141# Initialize services 

142tool_service = ToolService() 

143resource_service = ResourceService() 

144prompt_service = PromptService() 

145gateway_service = GatewayService() 

146root_service = RootService() 

147completion_service = CompletionService() 

148sampling_handler = SamplingHandler() 

149server_service = ServerService() 

150 

151# Initialize session manager for Streamable HTTP transport 

152streamable_http_session = SessionManagerWrapper() 

153 

154 

155# Initialize session registry 

156session_registry = SessionRegistry( 

157 backend=settings.cache_type, 

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

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

160 session_ttl=settings.session_ttl, 

161 message_ttl=settings.message_ttl, 

162) 

163 

164# Initialize cache 

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

166 

167 

168#################### 

169# Startup/Shutdown # 

170#################### 

171@asynccontextmanager 

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

173 """ 

174 Manage the application's startup and shutdown lifecycle. 

175 

176 The function initialises every core service on entry and then 

177 shuts them down in reverse order on exit. 

178 

179 Args: 

180 _app (FastAPI): FastAPI app 

181 

182 Yields: 

183 None 

184 

185 Raises: 

186 Exception: Any unhandled error that occurs during service 

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

188 """ 

189 logger.info("Starting MCP Gateway services") 

190 try: 

191 await tool_service.initialize() 

192 await resource_service.initialize() 

193 await prompt_service.initialize() 

194 await gateway_service.initialize() 

195 await root_service.initialize() 

196 await completion_service.initialize() 

197 await logging_service.initialize() 

198 await sampling_handler.initialize() 

199 await resource_cache.initialize() 

200 await streamable_http_session.initialize() 

201 

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

203 yield 

204 except Exception as e: 

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

206 raise 

207 finally: 

208 logger.info("Shutting down MCP Gateway services") 

209 # await stop_streamablehttp() 

210 for service in [resource_cache, sampling_handler, logging_service, completion_service, root_service, gateway_service, prompt_service, resource_service, tool_service, streamable_http_session]: 

211 try: 

212 await service.shutdown() 

213 except Exception as e: 

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

215 logger.info("Shutdown complete") 

216 

217 

218# Initialize FastAPI app 

219app = FastAPI( 

220 title=settings.app_name, 

221 version=__version__, 

222 description="A FastAPI-based MCP Gateway with federation support", 

223 root_path=settings.app_root_path, 

224 lifespan=lifespan, 

225) 

226 

227 

228class DocsAuthMiddleware(BaseHTTPMiddleware): 

229 """ 

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

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

232 

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

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

235 """ 

236 

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

238 """ 

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

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

241 

242 Args: 

243 request (Request): The incoming HTTP request. 

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

245 

246 Returns: 

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

248 """ 

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

250 

251 if any(request.url.path.startswith(p) for p in protected_paths): 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 try: 

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

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

255 

256 # Simulate what Depends(require_auth) would do 

257 await require_auth_override(token, cookie_token) 

258 except HTTPException as e: 

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

260 

261 # Proceed to next middleware or route 

262 return await call_next(request) 

263 

264 

265class MCPPathRewriteMiddleware: 

266 """ 

267 Supports requests like '/servers/<server_id>/mcp' by rewriting the path to '/mcp'. 

268 

269 - Only rewrites paths ending with '/mcp' but not exactly '/mcp'. 

270 - Performs authentication before rewriting. 

271 - Passes rewritten requests to `streamable_http_session`. 

272 - All other requests are passed through without change. 

273 """ 

274 

275 def __init__(self, app): 

276 """ 

277 Initialize the middleware with the ASGI application. 

278 

279 Args: 

280 app (Callable): The next ASGI application in the middleware stack. 

281 """ 

282 self.app = app 

283 

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

285 """ 

286 Intercept and potentially rewrite the incoming HTTP request path. 

287 

288 Args: 

289 scope (dict): The ASGI connection scope. 

290 receive (Callable): Awaitable that yields events from the client. 

291 send (Callable): Awaitable used to send events to the client. 

292 """ 

293 # Only handle HTTP requests, HTTPS uses scope["type"] == "http" in ASGI 

294 if scope["type"] != "http": 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true

295 await self.app(scope, receive, send) 

296 return 

297 

298 # Call auth check first 

299 auth_ok = await streamable_http_auth(scope, receive, send) 

300 if not auth_ok: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

301 return 

302 

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

304 scope["modified_path"] = original_path 

305 if (original_path.endswith("/mcp") and original_path != "/mcp") or (original_path.endswith("/mcp/") and original_path != "/mcp/"): 305 ↛ 307line 305 didn't jump to line 307 because the condition on line 305 was never true

306 # Rewrite path so mounted app at /mcp handles it 

307 scope["path"] = "/mcp" 

308 await streamable_http_session.handle_streamable_http(scope, receive, send) 

309 return 

310 await self.app(scope, receive, send) 

311 

312 

313# Configure CORS 

314app.add_middleware( 

315 CORSMiddleware, 

316 allow_origins=["*"] if not settings.allowed_origins else list(settings.allowed_origins), 

317 allow_credentials=True, 

318 allow_methods=["*"], 

319 allow_headers=["*"], 

320 expose_headers=["Content-Type", "Content-Length"], 

321) 

322 

323 

324# Add custom DocsAuthMiddleware 

325app.add_middleware(DocsAuthMiddleware) 

326 

327# Add streamable HTTP middleware for /mcp routes 

328app.add_middleware(MCPPathRewriteMiddleware) 

329 

330 

331# Set up Jinja2 templates and store in app state for later use 

332templates = Jinja2Templates(directory=str(settings.templates_dir)) 

333app.state.templates = templates 

334 

335# Create API routers 

336protocol_router = APIRouter(prefix="/protocol", tags=["Protocol"]) 

337tool_router = APIRouter(prefix="/tools", tags=["Tools"]) 

338resource_router = APIRouter(prefix="/resources", tags=["Resources"]) 

339prompt_router = APIRouter(prefix="/prompts", tags=["Prompts"]) 

340gateway_router = APIRouter(prefix="/gateways", tags=["Gateways"]) 

341root_router = APIRouter(prefix="/roots", tags=["Roots"]) 

342utility_router = APIRouter(tags=["Utilities"]) 

343server_router = APIRouter(prefix="/servers", tags=["Servers"]) 

344metrics_router = APIRouter(prefix="/metrics", tags=["Metrics"]) 

345 

346# Basic Auth setup 

347 

348 

349# Database dependency 

350def get_db(): 

351 """ 

352 Dependency function to provide a database session. 

353 

354 Yields: 

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

356 

357 Ensures: 

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

359 """ 

360 db = SessionLocal() 

361 try: 

362 yield db 

363 finally: 

364 db.close() 

365 

366 

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

368 """ 

369 Validates the provided API key. 

370 

371 This function checks if the provided API key matches the expected one 

372 based on the settings. If the validation fails, it raises an HTTPException 

373 with a 401 Unauthorized status. 

374 

375 Args: 

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

377 

378 Raises: 

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

380 """ 

381 if settings.auth_required: 

382 expected = f"{settings.basic_auth_user}:{settings.basic_auth_password}" 

383 if api_key != expected: 

384 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") 

385 

386 

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

388 """ 

389 Invalidates the resource cache. 

390 

391 If a specific URI is provided, only that resource will be removed from the cache. 

392 If no URI is provided, the entire resource cache will be cleared. 

393 

394 Args: 

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

396 """ 

397 if uri: 

398 resource_cache.delete(uri) 

399 else: 

400 resource_cache.clear() 

401 

402 

403################# 

404# Protocol APIs # 

405################# 

406@protocol_router.post("/initialize") 

407async def initialize(request: Request, user: str = Depends(require_auth)) -> InitializeResult: 

408 """ 

409 Initialize a protocol. 

410 

411 This endpoint handles the initialization process of a protocol by accepting 

412 a JSON request body and processing it. The `require_auth` dependency ensures that 

413 the user is authenticated before proceeding. 

414 

415 Args: 

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

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

418 

419 Returns: 

420 InitializeResult: The result of the initialization process. 

421 

422 Raises: 

423 HTTPException: If the request body contains invalid JSON, a 400 Bad Request error is raised. 

424 """ 

425 try: 

426 body = await request.json() 

427 

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

429 return await session_registry.handle_initialize_logic(body) 

430 

431 except json.JSONDecodeError: 

432 raise HTTPException( 

433 status_code=status.HTTP_400_BAD_REQUEST, 

434 detail="Invalid JSON in request body", 

435 ) 

436 

437 

438@protocol_router.post("/ping") 

439async def ping(request: Request, user: str = Depends(require_auth)) -> JSONResponse: 

440 """ 

441 Handle a ping request according to the MCP specification. 

442 

443 This endpoint expects a JSON-RPC request with the method "ping" and responds 

444 with a JSON-RPC response containing an empty result, as required by the protocol. 

445 

446 Args: 

447 request (Request): The incoming FastAPI request. 

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

449 

450 Returns: 

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

452 

453 Raises: 

454 HTTPException: If the request method is not "ping". 

455 """ 

456 try: 

457 body: dict = await request.json() 

458 if body.get("method") != "ping": 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true

459 raise HTTPException(status_code=400, detail="Invalid method") 

460 req_id: str = body.get("id") 

461 logger.debug(f"Authenticated user {user} sent ping request.") 

462 # Return an empty result per the MCP ping specification. 

463 response: dict = {"jsonrpc": "2.0", "id": req_id, "result": {}} 

464 return JSONResponse(content=response) 

465 except Exception as e: 

466 error_response: dict = { 

467 "jsonrpc": "2.0", 

468 "id": body.get("id") if "body" in locals() else None, 

469 "error": {"code": -32603, "message": "Internal error", "data": str(e)}, 

470 } 

471 return JSONResponse(status_code=500, content=error_response) 

472 

473 

474@protocol_router.post("/notifications") 

475async def handle_notification(request: Request, user: str = Depends(require_auth)) -> None: 

476 """ 

477 Handles incoming notifications from clients. Depending on the notification method, 

478 different actions are taken (e.g., logging initialization, cancellation, or messages). 

479 

480 Args: 

481 request (Request): The incoming request containing the notification data. 

482 user (str): The authenticated user making the request. 

483 """ 

484 body = await request.json() 

485 logger.debug(f"User {user} sent a notification") 

486 if body.get("method") == "notifications/initialized": 

487 logger.info("Client initialized") 

488 await logging_service.notify("Client initialized", LogLevel.INFO) 

489 elif body.get("method") == "notifications/cancelled": 

490 request_id = body.get("params", {}).get("requestId") 

491 logger.info(f"Request cancelled: {request_id}") 

492 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO) 

493 elif body.get("method") == "notifications/message": 

494 params = body.get("params", {}) 

495 await logging_service.notify( 

496 params.get("data"), 

497 LogLevel(params.get("level", "info")), 

498 params.get("logger"), 

499 ) 

500 

501 

502@protocol_router.post("/completion/complete") 

503async def handle_completion(request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)): 

504 """ 

505 Handles the completion of tasks by processing a completion request. 

506 

507 Args: 

508 request (Request): The incoming request with completion data. 

509 db (Session): The database session used to interact with the data store. 

510 user (str): The authenticated user making the request. 

511 

512 Returns: 

513 The result of the completion process. 

514 """ 

515 body = await request.json() 

516 logger.debug(f"User {user} sent a completion request") 

517 return await completion_service.handle_completion(db, body) 

518 

519 

520@protocol_router.post("/sampling/createMessage") 

521async def handle_sampling(request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)): 

522 """ 

523 Handles the creation of a new message for sampling. 

524 

525 Args: 

526 request (Request): The incoming request with sampling data. 

527 db (Session): The database session used to interact with the data store. 

528 user (str): The authenticated user making the request. 

529 

530 Returns: 

531 The result of the message creation process. 

532 """ 

533 logger.debug(f"User {user} sent a sampling request") 

534 body = await request.json() 

535 return await sampling_handler.create_message(db, body) 

536 

537 

538############### 

539# Server APIs # 

540############### 

541@server_router.get("", response_model=List[ServerRead]) 

542@server_router.get("/", response_model=List[ServerRead]) 

543async def list_servers( 

544 include_inactive: bool = False, 

545 db: Session = Depends(get_db), 

546 user: str = Depends(require_auth), 

547) -> List[ServerRead]: 

548 """ 

549 Lists all servers in the system, optionally including inactive ones. 

550 

551 Args: 

552 include_inactive (bool): Whether to include inactive servers in the response. 

553 db (Session): The database session used to interact with the data store. 

554 user (str): The authenticated user making the request. 

555 

556 Returns: 

557 List[ServerRead]: A list of server objects. 

558 """ 

559 logger.debug(f"User {user} requested server list") 

560 return await server_service.list_servers(db, include_inactive=include_inactive) 

561 

562 

563@server_router.get("/{server_id}", response_model=ServerRead) 

564async def get_server(server_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ServerRead: 

565 """ 

566 Retrieves a server by its ID. 

567 

568 Args: 

569 server_id (int): The ID of the server to retrieve. 

570 db (Session): The database session used to interact with the data store. 

571 user (str): The authenticated user making the request. 

572 

573 Returns: 

574 ServerRead: The server object with the specified ID. 

575 

576 Raises: 

577 HTTPException: If the server is not found. 

578 """ 

579 try: 

580 logger.debug(f"User {user} requested server with ID {server_id}") 

581 return await server_service.get_server(db, server_id) 

582 except ServerNotFoundError as e: 

583 raise HTTPException(status_code=404, detail=str(e)) 

584 

585 

586@server_router.post("", response_model=ServerRead, status_code=201) 

587@server_router.post("/", response_model=ServerRead, status_code=201) 

588async def create_server( 

589 server: ServerCreate, 

590 db: Session = Depends(get_db), 

591 user: str = Depends(require_auth), 

592) -> ServerRead: 

593 """ 

594 Creates a new server. 

595 

596 Args: 

597 server (ServerCreate): The data for the new server. 

598 db (Session): The database session used to interact with the data store. 

599 user (str): The authenticated user making the request. 

600 

601 Returns: 

602 ServerRead: The created server object. 

603 

604 Raises: 

605 HTTPException: If there is a conflict with the server name or other errors. 

606 """ 

607 try: 

608 logger.debug(f"User {user} is creating a new server") 

609 return await server_service.register_server(db, server) 

610 except ServerNameConflictError as e: 

611 raise HTTPException(status_code=409, detail=str(e)) 

612 except ServerError as e: 

613 raise HTTPException(status_code=400, detail=str(e)) 

614 

615 

616@server_router.put("/{server_id}", response_model=ServerRead) 

617async def update_server( 

618 server_id: int, 

619 server: ServerUpdate, 

620 db: Session = Depends(get_db), 

621 user: str = Depends(require_auth), 

622) -> ServerRead: 

623 """ 

624 Updates the information of an existing server. 

625 

626 Args: 

627 server_id (int): The ID of the server to update. 

628 server (ServerUpdate): The updated server data. 

629 db (Session): The database session used to interact with the data store. 

630 user (str): The authenticated user making the request. 

631 

632 Returns: 

633 ServerRead: The updated server object. 

634 

635 Raises: 

636 HTTPException: If the server is not found, there is a name conflict, or other errors. 

637 """ 

638 try: 

639 logger.debug(f"User {user} is updating server with ID {server_id}") 

640 return await server_service.update_server(db, server_id, server) 

641 except ServerNotFoundError as e: 

642 raise HTTPException(status_code=404, detail=str(e)) 

643 except ServerNameConflictError as e: 

644 raise HTTPException(status_code=409, detail=str(e)) 

645 except ServerError as e: 

646 raise HTTPException(status_code=400, detail=str(e)) 

647 

648 

649@server_router.post("/{server_id}/toggle", response_model=ServerRead) 

650async def toggle_server_status( 

651 server_id: int, 

652 activate: bool = True, 

653 db: Session = Depends(get_db), 

654 user: str = Depends(require_auth), 

655) -> ServerRead: 

656 """ 

657 Toggles the status of a server (activate or deactivate). 

658 

659 Args: 

660 server_id (int): The ID of the server to toggle. 

661 activate (bool): Whether to activate or deactivate the server. 

662 db (Session): The database session used to interact with the data store. 

663 user (str): The authenticated user making the request. 

664 

665 Returns: 

666 ServerRead: The server object after the status change. 

667 

668 Raises: 

669 HTTPException: If the server is not found or there is an error. 

670 """ 

671 try: 

672 logger.debug(f"User {user} is toggling server with ID {server_id} to {'active' if activate else 'inactive'}") 

673 return await server_service.toggle_server_status(db, server_id, activate) 

674 except ServerNotFoundError as e: 

675 raise HTTPException(status_code=404, detail=str(e)) 

676 except ServerError as e: 

677 raise HTTPException(status_code=400, detail=str(e)) 

678 

679 

680@server_router.delete("/{server_id}", response_model=Dict[str, str]) 

681async def delete_server(server_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: 

682 """ 

683 Deletes a server by its ID. 

684 

685 Args: 

686 server_id (int): The ID of the server to delete. 

687 db (Session): The database session used to interact with the data store. 

688 user (str): The authenticated user making the request. 

689 

690 Returns: 

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

692 

693 Raises: 

694 HTTPException: If the server is not found or there is an error. 

695 """ 

696 try: 

697 logger.debug(f"User {user} is deleting server with ID {server_id}") 

698 await server_service.delete_server(db, server_id) 

699 return { 

700 "status": "success", 

701 "message": f"Server {server_id} deleted successfully", 

702 } 

703 except ServerNotFoundError as e: 

704 raise HTTPException(status_code=404, detail=str(e)) 

705 except ServerError as e: 

706 raise HTTPException(status_code=400, detail=str(e)) 

707 

708 

709@server_router.get("/{server_id}/sse") 

710async def sse_endpoint(request: Request, server_id: int, user: str = Depends(require_auth)): 

711 """ 

712 Establishes a Server-Sent Events (SSE) connection for real-time updates about a server. 

713 

714 Args: 

715 request (Request): The incoming request. 

716 server_id (int): The ID of the server for which updates are received. 

717 user (str): The authenticated user making the request. 

718 

719 Returns: 

720 The SSE response object for the established connection. 

721 

722 Raises: 

723 HTTPException: If there is an error in establishing the SSE connection. 

724 """ 

725 try: 

726 logger.debug(f"User {user} is establishing SSE connection for server {server_id}") 

727 base_url = str(request.base_url).rstrip("/") 

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

729 transport = SSETransport(base_url=server_sse_url) 

730 await transport.connect() 

731 await session_registry.add_session(transport.session_id, transport) 

732 response = await transport.create_sse_response(request) 

733 

734 asyncio.create_task(session_registry.respond(server_id, user, session_id=transport.session_id, base_url=base_url)) 

735 

736 tasks = BackgroundTasks() 

737 tasks.add_task(session_registry.remove_session, transport.session_id) 

738 response.background = tasks 

739 logger.info(f"SSE connection established: {transport.session_id}") 

740 return response 

741 except Exception as e: 

742 logger.error(f"SSE connection error: {e}") 

743 raise HTTPException(status_code=500, detail="SSE connection failed") 

744 

745 

746@server_router.post("/{server_id}/message") 

747async def message_endpoint(request: Request, server_id: int, user: str = Depends(require_auth)): 

748 """ 

749 Handles incoming messages for a specific server. 

750 

751 Args: 

752 request (Request): The incoming message request. 

753 server_id (int): The ID of the server receiving the message. 

754 user (str): The authenticated user making the request. 

755 

756 Returns: 

757 JSONResponse: A success status after processing the message. 

758 

759 Raises: 

760 HTTPException: If there are errors processing the message. 

761 """ 

762 try: 

763 logger.debug(f"User {user} sent a message to server {server_id}") 

764 session_id = request.query_params.get("session_id") 

765 if not session_id: 

766 logger.error("Missing session_id in message request") 

767 raise HTTPException(status_code=400, detail="Missing session_id") 

768 

769 message = await request.json() 

770 

771 await session_registry.broadcast( 

772 session_id=session_id, 

773 message=message, 

774 ) 

775 

776 return JSONResponse(content={"status": "success"}, status_code=202) 

777 except ValueError as e: 

778 logger.error(f"Invalid message format: {e}") 

779 raise HTTPException(status_code=400, detail=str(e)) 

780 except HTTPException: 

781 raise 

782 except Exception as e: 

783 logger.error(f"Message handling error: {e}") 

784 raise HTTPException(status_code=500, detail="Failed to process message") 

785 

786 

787@server_router.get("/{server_id}/tools", response_model=List[ToolRead]) 

788async def server_get_tools( 

789 server_id: int, 

790 include_inactive: bool = False, 

791 db: Session = Depends(get_db), 

792 user: str = Depends(require_auth), 

793) -> List[ToolRead]: 

794 """ 

795 List tools for the server with an option to include inactive tools. 

796 

797 This endpoint retrieves a list of tools from the database, optionally including 

798 those that are inactive. The inactive filter helps administrators manage tools 

799 that have been deactivated but not deleted from the system. 

800 

801 Args: 

802 server_id (int): ID of the server 

803 include_inactive (bool): Whether to include inactive tools in the results. 

804 db (Session): Database session dependency. 

805 user (str): Authenticated user dependency. 

806 

807 Returns: 

808 List[ToolRead]: A list of tool records formatted with by_alias=True. 

809 """ 

810 logger.debug(f"User: {user} has listed tools for the server_id: {server_id}") 

811 tools = await tool_service.list_server_tools(db, server_id=server_id, include_inactive=include_inactive) 

812 return [tool.dict(by_alias=True) for tool in tools] 

813 

814 

815@server_router.get("/{server_id}/resources", response_model=List[ResourceRead]) 

816async def server_get_resources( 

817 server_id: int, 

818 include_inactive: bool = False, 

819 db: Session = Depends(get_db), 

820 user: str = Depends(require_auth), 

821) -> List[ResourceRead]: 

822 """ 

823 List resources for the server with an option to include inactive resources. 

824 

825 This endpoint retrieves a list of resources from the database, optionally including 

826 those that are inactive. The inactive filter is useful for administrators who need 

827 to view or manage resources that have been deactivated but not deleted. 

828 

829 Args: 

830 server_id (int): ID of the server 

831 include_inactive (bool): Whether to include inactive resources in the results. 

832 db (Session): Database session dependency. 

833 user (str): Authenticated user dependency. 

834 

835 Returns: 

836 List[ResourceRead]: A list of resource records formatted with by_alias=True. 

837 """ 

838 logger.debug(f"User: {user} has listed resources for the server_id: {server_id}") 

839 resources = await resource_service.list_server_resources(db, server_id=server_id, include_inactive=include_inactive) 

840 return [resource.dict(by_alias=True) for resource in resources] 

841 

842 

843@server_router.get("/{server_id}/prompts", response_model=List[PromptRead]) 

844async def server_get_prompts( 

845 server_id: int, 

846 include_inactive: bool = False, 

847 db: Session = Depends(get_db), 

848 user: str = Depends(require_auth), 

849) -> List[PromptRead]: 

850 """ 

851 List prompts for the server with an option to include inactive prompts. 

852 

853 This endpoint retrieves a list of prompts from the database, optionally including 

854 those that are inactive. The inactive filter helps administrators see and manage 

855 prompts that have been deactivated but not deleted from the system. 

856 

857 Args: 

858 server_id (int): ID of the server 

859 include_inactive (bool): Whether to include inactive prompts in the results. 

860 db (Session): Database session dependency. 

861 user (str): Authenticated user dependency. 

862 

863 Returns: 

864 List[PromptRead]: A list of prompt records formatted with by_alias=True. 

865 """ 

866 logger.debug(f"User: {user} has listed prompts for the server_id: {server_id}") 

867 prompts = await prompt_service.list_server_prompts(db, server_id=server_id, include_inactive=include_inactive) 

868 return [prompt.dict(by_alias=True) for prompt in prompts] 

869 

870 

871############# 

872# Tool APIs # 

873############# 

874@tool_router.get("", response_model=Union[List[ToolRead], List[Dict], Dict, List]) 

875@tool_router.get("/", response_model=Union[List[ToolRead], List[Dict], Dict, List]) 

876async def list_tools( 

877 cursor: Optional[str] = None, # Add this parameter 

878 include_inactive: bool = False, 

879 db: Session = Depends(get_db), 

880 apijsonpath: JsonPathModifier = Body(None), 

881 _: str = Depends(require_auth), 

882) -> Union[List[ToolRead], List[Dict], Dict]: 

883 """List all registered tools with pagination support. 

884 

885 Args: 

886 cursor: Pagination cursor for fetching the next set of results 

887 include_inactive: Whether to include inactive tools in the results 

888 db: Database session 

889 apijsonpath: JSON path modifier to filter or transform the response 

890 _: Authenticated user 

891 

892 Returns: 

893 List of tools or modified result based on jsonpath 

894 """ 

895 

896 # For now just pass the cursor parameter even if not used 

897 data = await tool_service.list_tools(db, cursor=cursor, include_inactive=include_inactive) 

898 

899 if apijsonpath is None: 899 ↛ 902line 899 didn't jump to line 902 because the condition on line 899 was always true

900 return data 

901 

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

903 

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

905 

906 

907@tool_router.post("", response_model=ToolRead) 

908@tool_router.post("/", response_model=ToolRead) 

909async def create_tool(tool: ToolCreate, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ToolRead: 

910 """ 

911 Creates a new tool in the system. 

912 

913 Args: 

914 tool (ToolCreate): The data needed to create the tool. 

915 db (Session): The database session dependency. 

916 user (str): The authenticated user making the request. 

917 

918 Returns: 

919 ToolRead: The created tool data. 

920 

921 Raises: 

922 HTTPException: If the tool name already exists or other validation errors occur. 

923 """ 

924 try: 

925 logger.debug(f"User {user} is creating a new tool") 

926 return await tool_service.register_tool(db, tool) 

927 except ToolNameConflictError as e: 

928 if not e.is_active and e.tool_id: 

929 raise HTTPException( 

930 status_code=status.HTTP_409_CONFLICT, 

931 detail=f"Tool name already exists but is inactive. Consider activating it with ID: {e.tool_id}", 

932 ) 

933 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) 

934 except ToolError as e: 

935 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

936 

937 

938@tool_router.get("/{tool_id}", response_model=Union[ToolRead, Dict]) 

939async def get_tool( 

940 tool_id: int, 

941 db: Session = Depends(get_db), 

942 user: str = Depends(require_auth), 

943 apijsonpath: JsonPathModifier = Body(None), 

944) -> Union[ToolRead, Dict]: 

945 """ 

946 Retrieve a tool by ID, optionally applying a JSONPath post-filter. 

947 

948 Args: 

949 tool_id: The numeric ID of the tool. 

950 db: Active SQLAlchemy session (dependency). 

951 user: Authenticated username (dependency). 

952 apijsonpath: Optional JSON-Path modifier supplied in the body. 

953 

954 Returns: 

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

956 a JSONPath filter/mapping was supplied. 

957 

958 Raises: 

959 HTTPException: If the tool does not exist or the transformation fails. 

960 """ 

961 try: 

962 logger.debug(f"User {user} is retrieving tool with ID {tool_id}") 

963 data = await tool_service.get_tool(db, tool_id) 

964 if apijsonpath is None: 

965 return data 

966 

967 data_dict = data.to_dict(use_alias=True) 

968 

969 return jsonpath_modifier(data_dict, apijsonpath.jsonpath, apijsonpath.mapping) 

970 except Exception as e: 

971 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) 

972 

973 

974@tool_router.put("/{tool_id}", response_model=ToolRead) 

975async def update_tool( 

976 tool_id: int, 

977 tool: ToolUpdate, 

978 db: Session = Depends(get_db), 

979 user: str = Depends(require_auth), 

980) -> ToolRead: 

981 """ 

982 Updates an existing tool with new data. 

983 

984 Args: 

985 tool_id (int): The ID of the tool to update. 

986 tool (ToolUpdate): The updated tool information. 

987 db (Session): The database session dependency. 

988 user (str): The authenticated user making the request. 

989 

990 Returns: 

991 ToolRead: The updated tool data. 

992 

993 Raises: 

994 HTTPException: If an error occurs during the update. 

995 """ 

996 try: 

997 logger.debug(f"User {user} is updating tool with ID {tool_id}") 

998 return await tool_service.update_tool(db, tool_id, tool) 

999 except Exception as e: 

1000 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

1001 

1002 

1003@tool_router.delete("/{tool_id}") 

1004async def delete_tool(tool_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: 

1005 """ 

1006 Permanently deletes a tool by ID. 

1007 

1008 Args: 

1009 tool_id (int): The ID of the tool to delete. 

1010 db (Session): The database session dependency. 

1011 user (str): The authenticated user making the request. 

1012 

1013 Returns: 

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

1015 

1016 Raises: 

1017 HTTPException: If an error occurs during deletion. 

1018 """ 

1019 try: 

1020 logger.debug(f"User {user} is deleting tool with ID {tool_id}") 

1021 await tool_service.delete_tool(db, tool_id) 

1022 return {"status": "success", "message": f"Tool {tool_id} permanently deleted"} 

1023 except Exception as e: 

1024 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

1025 

1026 

1027@tool_router.post("/{tool_id}/toggle") 

1028async def toggle_tool_status( 

1029 tool_id: int, 

1030 activate: bool = True, 

1031 db: Session = Depends(get_db), 

1032 user: str = Depends(require_auth), 

1033) -> Dict[str, Any]: 

1034 """ 

1035 Activates or deactivates a tool. 

1036 

1037 Args: 

1038 tool_id (int): The ID of the tool to toggle. 

1039 activate (bool): Whether to activate (`True`) or deactivate (`False`) the tool. 

1040 db (Session): The database session dependency. 

1041 user (str): The authenticated user making the request. 

1042 

1043 Returns: 

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

1045 

1046 Raises: 

1047 HTTPException: If an error occurs during status toggling. 

1048 """ 

1049 try: 

1050 logger.debug(f"User {user} is toggling tool with ID {tool_id} to {'active' if activate else 'inactive'}") 

1051 tool = await tool_service.toggle_tool_status(db, tool_id, activate) 

1052 return { 

1053 "status": "success", 

1054 "message": f"Tool {tool_id} {'activated' if activate else 'deactivated'}", 

1055 "tool": tool.model_dump(), 

1056 } 

1057 except Exception as e: 

1058 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

1059 

1060 

1061################# 

1062# Resource APIs # 

1063################# 

1064# --- Resource templates endpoint - MUST come before variable paths --- 

1065@resource_router.get("/templates/list", response_model=ListResourceTemplatesResult) 

1066async def list_resource_templates( 

1067 db: Session = Depends(get_db), 

1068 user: str = Depends(require_auth), 

1069) -> ListResourceTemplatesResult: 

1070 """ 

1071 List all available resource templates. 

1072 

1073 Args: 

1074 db (Session): Database session. 

1075 user (str): Authenticated user. 

1076 

1077 Returns: 

1078 ListResourceTemplatesResult: A paginated list of resource templates. 

1079 """ 

1080 logger.debug(f"User {user} requested resource templates") 

1081 resource_templates = await resource_service.list_resource_templates(db) 

1082 # For simplicity, we're not implementing real pagination here 

1083 return ListResourceTemplatesResult(_meta={}, resource_templates=resource_templates, next_cursor=None) # No pagination for now 

1084 

1085 

1086@resource_router.post("/{resource_id}/toggle") 

1087async def toggle_resource_status( 

1088 resource_id: int, 

1089 activate: bool = True, 

1090 db: Session = Depends(get_db), 

1091 user: str = Depends(require_auth), 

1092) -> Dict[str, Any]: 

1093 """ 

1094 Activate or deactivate a resource by its ID. 

1095 

1096 Args: 

1097 resource_id (int): The ID of the resource. 

1098 activate (bool): True to activate, False to deactivate. 

1099 db (Session): Database session. 

1100 user (str): Authenticated user. 

1101 

1102 Returns: 

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

1104 

1105 Raises: 

1106 HTTPException: If toggling fails. 

1107 """ 

1108 logger.debug(f"User {user} is toggling resource with ID {resource_id} to {'active' if activate else 'inactive'}") 

1109 try: 

1110 resource = await resource_service.toggle_resource_status(db, resource_id, activate) 

1111 return { 

1112 "status": "success", 

1113 "message": f"Resource {resource_id} {'activated' if activate else 'deactivated'}", 

1114 "resource": resource.model_dump(), 

1115 } 

1116 except Exception as e: 

1117 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

1118 

1119 

1120@resource_router.get("", response_model=List[ResourceRead]) 

1121@resource_router.get("/", response_model=List[ResourceRead]) 

1122async def list_resources( 

1123 cursor: Optional[str] = None, # Add this parameter 

1124 include_inactive: bool = False, 

1125 db: Session = Depends(get_db), 

1126 user: str = Depends(require_auth), 

1127) -> List[ResourceRead]: 

1128 """ 

1129 Retrieve a list of resources. 

1130 

1131 Args: 

1132 cursor (Optional[str]): Optional cursor for pagination. 

1133 include_inactive (bool): Whether to include inactive resources. 

1134 db (Session): Database session. 

1135 user (str): Authenticated user. 

1136 

1137 Returns: 

1138 List[ResourceRead]: List of resources. 

1139 """ 

1140 logger.debug(f"User {user} requested resource list with cursor {cursor} and include_inactive={include_inactive}") 

1141 if cached := resource_cache.get("resource_list"): 1141 ↛ 1142line 1141 didn't jump to line 1142 because the condition on line 1141 was never true

1142 return cached 

1143 # Pass the cursor parameter 

1144 resources = await resource_service.list_resources(db, include_inactive=include_inactive) 

1145 resource_cache.set("resource_list", resources) 

1146 return resources 

1147 

1148 

1149@resource_router.post("", response_model=ResourceRead) 

1150@resource_router.post("/", response_model=ResourceRead) 

1151async def create_resource( 

1152 resource: ResourceCreate, 

1153 db: Session = Depends(get_db), 

1154 user: str = Depends(require_auth), 

1155) -> ResourceRead: 

1156 """ 

1157 Create a new resource. 

1158 

1159 Args: 

1160 resource (ResourceCreate): Data for the new resource. 

1161 db (Session): Database session. 

1162 user (str): Authenticated user. 

1163 

1164 Returns: 

1165 ResourceRead: The created resource. 

1166 

1167 Raises: 

1168 HTTPException: On conflict or validation errors. 

1169 """ 

1170 logger.debug(f"User {user} is creating a new resource") 

1171 try: 

1172 result = await resource_service.register_resource(db, resource) 

1173 return result 

1174 except ResourceURIConflictError as e: 

1175 raise HTTPException(status_code=409, detail=str(e)) 

1176 except ResourceError as e: 

1177 raise HTTPException(status_code=400, detail=str(e)) 

1178 

1179 

1180@resource_router.get("/{uri:path}") 

1181async def read_resource(uri: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ResourceContent: 

1182 """ 

1183 Read a resource by its URI. 

1184 

1185 Args: 

1186 uri (str): URI of the resource. 

1187 db (Session): Database session. 

1188 user (str): Authenticated user. 

1189 

1190 Returns: 

1191 ResourceContent: The content of the resource. 

1192 

1193 Raises: 

1194 HTTPException: If the resource cannot be found or read. 

1195 """ 

1196 logger.debug(f"User {user} requested resource with URI {uri}") 

1197 if cached := resource_cache.get(uri): 1197 ↛ 1198line 1197 didn't jump to line 1198 because the condition on line 1197 was never true

1198 return cached 

1199 try: 

1200 content: ResourceContent = await resource_service.read_resource(db, uri) 

1201 except (ResourceNotFoundError, ResourceError) as exc: 

1202 # Translate to FastAPI HTTP error 

1203 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc 

1204 resource_cache.set(uri, content) 

1205 return content 

1206 

1207 

1208@resource_router.put("/{uri:path}", response_model=ResourceRead) 

1209async def update_resource( 

1210 uri: str, 

1211 resource: ResourceUpdate, 

1212 db: Session = Depends(get_db), 

1213 user: str = Depends(require_auth), 

1214) -> ResourceRead: 

1215 """ 

1216 Update a resource identified by its URI. 

1217 

1218 Args: 

1219 uri (str): URI of the resource. 

1220 resource (ResourceUpdate): New resource data. 

1221 db (Session): Database session. 

1222 user (str): Authenticated user. 

1223 

1224 Returns: 

1225 ResourceRead: The updated resource. 

1226 

1227 Raises: 

1228 HTTPException: If the resource is not found or update fails. 

1229 """ 

1230 try: 

1231 logger.debug(f"User {user} is updating resource with URI {uri}") 

1232 result = await resource_service.update_resource(db, uri, resource) 

1233 except ResourceNotFoundError as e: 

1234 raise HTTPException(status_code=404, detail=str(e)) 

1235 await invalidate_resource_cache(uri) 

1236 return result 

1237 

1238 

1239@resource_router.delete("/{uri:path}") 

1240async def delete_resource(uri: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: 

1241 """ 

1242 Delete a resource by its URI. 

1243 

1244 Args: 

1245 uri (str): URI of the resource to delete. 

1246 db (Session): Database session. 

1247 user (str): Authenticated user. 

1248 

1249 Returns: 

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

1251 

1252 Raises: 

1253 HTTPException: If the resource is not found or deletion fails. 

1254 """ 

1255 try: 

1256 logger.debug(f"User {user} is deleting resource with URI {uri}") 

1257 await resource_service.delete_resource(db, uri) 

1258 await invalidate_resource_cache(uri) 

1259 return {"status": "success", "message": f"Resource {uri} deleted"} 

1260 except ResourceNotFoundError as e: 

1261 raise HTTPException(status_code=404, detail=str(e)) 

1262 except ResourceError as e: 

1263 raise HTTPException(status_code=400, detail=str(e)) 

1264 

1265 

1266@resource_router.post("/subscribe/{uri:path}") 

1267async def subscribe_resource(uri: str, user: str = Depends(require_auth)) -> StreamingResponse: 

1268 """ 

1269 Subscribe to server-sent events (SSE) for a specific resource. 

1270 

1271 Args: 

1272 uri (str): URI of the resource to subscribe to. 

1273 user (str): Authenticated user. 

1274 

1275 Returns: 

1276 StreamingResponse: A streaming response with event updates. 

1277 """ 

1278 logger.debug(f"User {user} is subscribing to resource with URI {uri}") 

1279 return StreamingResponse(resource_service.subscribe_events(uri), media_type="text/event-stream") 

1280 

1281 

1282############### 

1283# Prompt APIs # 

1284############### 

1285@prompt_router.post("/{prompt_id}/toggle") 

1286async def toggle_prompt_status( 

1287 prompt_id: int, 

1288 activate: bool = True, 

1289 db: Session = Depends(get_db), 

1290 user: str = Depends(require_auth), 

1291) -> Dict[str, Any]: 

1292 """ 

1293 Toggle the activation status of a prompt. 

1294 

1295 Args: 

1296 prompt_id: ID of the prompt to toggle. 

1297 activate: True to activate, False to deactivate. 

1298 db: Database session. 

1299 user: Authenticated user. 

1300 

1301 Returns: 

1302 Status message and updated prompt details. 

1303 

1304 Raises: 

1305 HTTPException: If the toggle fails (e.g., prompt not found or database error); emitted with *400 Bad Request* status and an error message. 

1306 """ 

1307 logger.debug(f"User: {user} requested toggle for prompt {prompt_id}, activate={activate}") 

1308 try: 

1309 prompt = await prompt_service.toggle_prompt_status(db, prompt_id, activate) 

1310 return { 

1311 "status": "success", 

1312 "message": f"Prompt {prompt_id} {'activated' if activate else 'deactivated'}", 

1313 "prompt": prompt.model_dump(), 

1314 } 

1315 except Exception as e: 

1316 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

1317 

1318 

1319@prompt_router.get("", response_model=List[PromptRead]) 

1320@prompt_router.get("/", response_model=List[PromptRead]) 

1321async def list_prompts( 

1322 cursor: Optional[str] = None, 

1323 include_inactive: bool = False, 

1324 db: Session = Depends(get_db), 

1325 user: str = Depends(require_auth), 

1326) -> List[PromptRead]: 

1327 """ 

1328 List prompts with optional pagination and inclusion of inactive items. 

1329 

1330 Args: 

1331 cursor: Cursor for pagination. 

1332 include_inactive: Include inactive prompts. 

1333 db: Database session. 

1334 user: Authenticated user. 

1335 

1336 Returns: 

1337 List of prompt records. 

1338 """ 

1339 logger.debug(f"User: {user} requested prompt list with include_inactive={include_inactive}, cursor={cursor}") 

1340 return await prompt_service.list_prompts(db, cursor=cursor, include_inactive=include_inactive) 

1341 

1342 

1343@prompt_router.post("", response_model=PromptRead) 

1344@prompt_router.post("/", response_model=PromptRead) 

1345async def create_prompt( 

1346 prompt: PromptCreate, 

1347 db: Session = Depends(get_db), 

1348 user: str = Depends(require_auth), 

1349) -> PromptRead: 

1350 """ 

1351 Create a new prompt. 

1352 

1353 Args: 

1354 prompt (PromptCreate): Payload describing the prompt to create. 

1355 db (Session): Active SQLAlchemy session. 

1356 user (str): Authenticated username. 

1357 

1358 Returns: 

1359 PromptRead: The newly–created prompt. 

1360 

1361 Raises: 

1362 HTTPException: * **409 Conflict** – another prompt with the same name already exists. 

1363 * **400 Bad Request** – validation or persistence error raised 

1364 by :pyclass:`~mcpgateway.services.prompt_service.PromptService`. 

1365 """ 

1366 logger.debug(f"User: {user} requested to create prompt: {prompt}") 

1367 try: 

1368 return await prompt_service.register_prompt(db, prompt) 

1369 except PromptNameConflictError as e: 

1370 raise HTTPException(status_code=409, detail=str(e)) 

1371 except PromptError as e: 

1372 raise HTTPException(status_code=400, detail=str(e)) 

1373 

1374 

1375@prompt_router.post("/{name}") 

1376async def get_prompt( 

1377 name: str, 

1378 args: Dict[str, str] = Body({}), 

1379 db: Session = Depends(get_db), 

1380 user: str = Depends(require_auth), 

1381) -> Any: 

1382 """Get a prompt by name with arguments. 

1383 

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

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

1386 

1387 

1388 Args: 

1389 name: Name of the prompt. 

1390 args: Template arguments. 

1391 db: Database session. 

1392 user: Authenticated user. 

1393 

1394 Returns: 

1395 Rendered prompt or metadata. 

1396 """ 

1397 logger.debug(f"User: {user} requested prompt: {name} with args={args}") 

1398 return await prompt_service.get_prompt(db, name, args) 

1399 

1400 

1401@prompt_router.get("/{name}") 

1402async def get_prompt_no_args( 

1403 name: str, 

1404 db: Session = Depends(get_db), 

1405 user: str = Depends(require_auth), 

1406) -> Any: 

1407 """Get a prompt by name without arguments. 

1408 

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

1410 

1411 Args: 

1412 name: The name of the prompt to retrieve 

1413 db: Database session 

1414 user: Authenticated user 

1415 

1416 Returns: 

1417 The prompt template information 

1418 """ 

1419 logger.debug(f"User: {user} requested prompt: {name} with no arguments") 

1420 return await prompt_service.get_prompt(db, name, {}) 

1421 

1422 

1423@prompt_router.put("/{name}", response_model=PromptRead) 

1424async def update_prompt( 

1425 name: str, 

1426 prompt: PromptUpdate, 

1427 db: Session = Depends(get_db), 

1428 user: str = Depends(require_auth), 

1429) -> PromptRead: 

1430 """ 

1431 Update (overwrite) an existing prompt definition. 

1432 

1433 Args: 

1434 name (str): Identifier of the prompt to update. 

1435 prompt (PromptUpdate): New prompt content and metadata. 

1436 db (Session): Active SQLAlchemy session. 

1437 user (str): Authenticated username. 

1438 

1439 Returns: 

1440 PromptRead: The updated prompt object. 

1441 

1442 Raises: 

1443 HTTPException: * **409 Conflict** – a different prompt with the same *name* already exists and is still active. 

1444 * **400 Bad Request** – validation or persistence error raised by :pyclass:`~mcpgateway.services.prompt_service.PromptService`. 

1445 """ 

1446 logger.debug(f"User: {user} requested to update prompt: {name} with data={prompt}") 

1447 try: 

1448 return await prompt_service.update_prompt(db, name, prompt) 

1449 except PromptNameConflictError as e: 

1450 raise HTTPException(status_code=409, detail=str(e)) 

1451 except PromptError as e: 

1452 raise HTTPException(status_code=400, detail=str(e)) 

1453 

1454 

1455@prompt_router.delete("/{name}") 

1456async def delete_prompt(name: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: 

1457 """ 

1458 Delete a prompt by name. 

1459 

1460 Args: 

1461 name: Name of the prompt. 

1462 db: Database session. 

1463 user: Authenticated user. 

1464 

1465 Returns: 

1466 Status message. 

1467 """ 

1468 logger.debug(f"User: {user} requested deletion of prompt {name}") 

1469 try: 

1470 await prompt_service.delete_prompt(db, name) 

1471 return {"status": "success", "message": f"Prompt {name} deleted"} 

1472 except PromptNotFoundError as e: 

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

1474 except PromptError as e: 

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

1476 

1477 

1478################ 

1479# Gateway APIs # 

1480################ 

1481@gateway_router.post("/{gateway_id}/toggle") 

1482async def toggle_gateway_status( 

1483 gateway_id: int, 

1484 activate: bool = True, 

1485 db: Session = Depends(get_db), 

1486 user: str = Depends(require_auth), 

1487) -> Dict[str, Any]: 

1488 """ 

1489 Toggle the activation status of a gateway. 

1490 

1491 Args: 

1492 gateway_id (int): Numeric ID of the gateway to toggle. 

1493 activate (bool): ``True`` to activate, ``False`` to deactivate. 

1494 db (Session): Active SQLAlchemy session. 

1495 user (str): Authenticated username. 

1496 

1497 Returns: 

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

1499 

1500 Raises: 

1501 HTTPException: Returned with **400 Bad Request** if the toggle operation fails (e.g., the gateway does not exist or the database raises an unexpected error). 

1502 """ 

1503 logger.debug(f"User '{user}' requested toggle for gateway {gateway_id}, activate={activate}") 

1504 try: 

1505 gateway = await gateway_service.toggle_gateway_status( 

1506 db, 

1507 gateway_id, 

1508 activate, 

1509 ) 

1510 return { 

1511 "status": "success", 

1512 "message": f"Gateway {gateway_id} {'activated' if activate else 'deactivated'}", 

1513 "gateway": gateway.model_dump(), 

1514 } 

1515 except Exception as e: 

1516 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 

1517 

1518 

1519@gateway_router.get("", response_model=List[GatewayRead]) 

1520@gateway_router.get("/", response_model=List[GatewayRead]) 

1521async def list_gateways( 

1522 include_inactive: bool = False, 

1523 db: Session = Depends(get_db), 

1524 user: str = Depends(require_auth), 

1525) -> List[GatewayRead]: 

1526 """ 

1527 List all gateways. 

1528 

1529 Args: 

1530 include_inactive: Include inactive gateways. 

1531 db: Database session. 

1532 user: Authenticated user. 

1533 

1534 Returns: 

1535 List of gateway records. 

1536 """ 

1537 logger.debug(f"User '{user}' requested list of gateways with include_inactive={include_inactive}") 

1538 return await gateway_service.list_gateways(db, include_inactive=include_inactive) 

1539 

1540 

1541@gateway_router.post("", response_model=GatewayRead) 

1542@gateway_router.post("/", response_model=GatewayRead) 

1543async def register_gateway( 

1544 gateway: GatewayCreate, 

1545 db: Session = Depends(get_db), 

1546 user: str = Depends(require_auth), 

1547) -> GatewayRead: 

1548 """ 

1549 Register a new gateway. 

1550 

1551 Args: 

1552 gateway: Gateway creation data. 

1553 db: Database session. 

1554 user: Authenticated user. 

1555 

1556 Returns: 

1557 Created gateway. 

1558 """ 

1559 logger.debug(f"User '{user}' requested to register gateway: {gateway}") 

1560 try: 

1561 return await gateway_service.register_gateway(db, gateway) 

1562 except Exception as ex: 

1563 if isinstance(ex, GatewayConnectionError): 

1564 return JSONResponse(content={"message": "Unable to connect to gateway"}, status_code=502) 

1565 elif isinstance(ex, ValueError): 

1566 return JSONResponse(content={"message": "Unable to process input"}, status_code=400) 

1567 elif isinstance(ex, RuntimeError): 

1568 return JSONResponse(content={"message": "Error during execution"}, status_code=500) 

1569 else: 

1570 return JSONResponse(content={"message": "Unexpected error"}, status_code=500) 

1571 

1572 

1573@gateway_router.get("/{gateway_id}", response_model=GatewayRead) 

1574async def get_gateway(gateway_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> GatewayRead: 

1575 """ 

1576 Retrieve a gateway by ID. 

1577 

1578 Args: 

1579 gateway_id: ID of the gateway. 

1580 db: Database session. 

1581 user: Authenticated user. 

1582 

1583 Returns: 

1584 Gateway data. 

1585 """ 

1586 logger.debug(f"User '{user}' requested gateway {gateway_id}") 

1587 return await gateway_service.get_gateway(db, gateway_id) 

1588 

1589 

1590@gateway_router.put("/{gateway_id}", response_model=GatewayRead) 

1591async def update_gateway( 

1592 gateway_id: int, 

1593 gateway: GatewayUpdate, 

1594 db: Session = Depends(get_db), 

1595 user: str = Depends(require_auth), 

1596) -> GatewayRead: 

1597 """ 

1598 Update a gateway. 

1599 

1600 Args: 

1601 gateway_id: Gateway ID. 

1602 gateway: Gateway update data. 

1603 db: Database session. 

1604 user: Authenticated user. 

1605 

1606 Returns: 

1607 Updated gateway. 

1608 """ 

1609 logger.debug(f"User '{user}' requested update on gateway {gateway_id} with data={gateway}") 

1610 return await gateway_service.update_gateway(db, gateway_id, gateway) 

1611 

1612 

1613@gateway_router.delete("/{gateway_id}") 

1614async def delete_gateway(gateway_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: 

1615 """ 

1616 Delete a gateway by ID. 

1617 

1618 Args: 

1619 gateway_id: ID of the gateway. 

1620 db: Database session. 

1621 user: Authenticated user. 

1622 

1623 Returns: 

1624 Status message. 

1625 """ 

1626 logger.debug(f"User '{user}' requested deletion of gateway {gateway_id}") 

1627 await gateway_service.delete_gateway(db, gateway_id) 

1628 return {"status": "success", "message": f"Gateway {gateway_id} deleted"} 

1629 

1630 

1631############## 

1632# Root APIs # 

1633############## 

1634@root_router.get("", response_model=List[Root]) 

1635@root_router.get("/", response_model=List[Root]) 

1636async def list_roots( 

1637 user: str = Depends(require_auth), 

1638) -> List[Root]: 

1639 """ 

1640 Retrieve a list of all registered roots. 

1641 

1642 Args: 

1643 user: Authenticated user. 

1644 

1645 Returns: 

1646 List of Root objects. 

1647 """ 

1648 logger.debug(f"User '{user}' requested list of roots") 

1649 return await root_service.list_roots() 

1650 

1651 

1652@root_router.post("", response_model=Root) 

1653@root_router.post("/", response_model=Root) 

1654async def add_root( 

1655 root: Root, # Accept JSON body using the Root model from types.py 

1656 user: str = Depends(require_auth), 

1657) -> Root: 

1658 """ 

1659 Add a new root. 

1660 

1661 Args: 

1662 root: Root object containing URI and name. 

1663 user: Authenticated user. 

1664 

1665 Returns: 

1666 The added Root object. 

1667 """ 

1668 logger.debug(f"User '{user}' requested to add root: {root}") 

1669 return await root_service.add_root(str(root.uri), root.name) 

1670 

1671 

1672@root_router.delete("/{uri:path}") 

1673async def remove_root( 

1674 uri: str, 

1675 user: str = Depends(require_auth), 

1676) -> Dict[str, str]: 

1677 """ 

1678 Remove a registered root by URI. 

1679 

1680 Args: 

1681 uri: URI of the root to remove. 

1682 user: Authenticated user. 

1683 

1684 Returns: 

1685 Status message indicating result. 

1686 """ 

1687 logger.debug(f"User '{user}' requested to remove root with URI: {uri}") 

1688 await root_service.remove_root(uri) 

1689 return {"status": "success", "message": f"Root {uri} removed"} 

1690 

1691 

1692@root_router.get("/changes") 

1693async def subscribe_roots_changes( 

1694 user: str = Depends(require_auth), 

1695) -> StreamingResponse: 

1696 """ 

1697 Subscribe to real-time changes in root list via Server-Sent Events (SSE). 

1698 

1699 Args: 

1700 user: Authenticated user. 

1701 

1702 Returns: 

1703 StreamingResponse with event-stream media type. 

1704 """ 

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

1706 return StreamingResponse(root_service.subscribe_changes(), media_type="text/event-stream") 

1707 

1708 

1709################## 

1710# Utility Routes # 

1711################## 

1712@utility_router.post("/rpc/") 

1713@utility_router.post("/rpc") 

1714async def handle_rpc(request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)): # revert this back 

1715 """Handle RPC requests. 

1716 

1717 Args: 

1718 request (Request): The incoming FastAPI request. 

1719 db (Session): Database session. 

1720 user (str): The authenticated user. 

1721 

1722 Returns: 

1723 Response with the RPC result or error. 

1724 """ 

1725 try: 

1726 logger.debug(f"User {user} made an RPC request") 

1727 body = await request.json() 

1728 validate_request(body) 

1729 method = body["method"] 

1730 # rpc_id = body.get("id") 

1731 params = body.get("params", {}) 

1732 cursor = params.get("cursor") # Extract cursor parameter 

1733 

1734 if method == "tools/list": 1734 ↛ 1735line 1734 didn't jump to line 1735 because the condition on line 1734 was never true

1735 tools = await tool_service.list_tools(db, cursor=cursor) 

1736 result = [t.model_dump(by_alias=True, exclude_none=True) for t in tools] 

1737 elif method == "list_tools": # Legacy endpoint 1737 ↛ 1738line 1737 didn't jump to line 1738 because the condition on line 1737 was never true

1738 tools = await tool_service.list_tools(db, cursor=cursor) 

1739 result = [t.model_dump(by_alias=True, exclude_none=True) for t in tools] 

1740 elif method == "initialize": 1740 ↛ 1741line 1740 didn't jump to line 1741 because the condition on line 1740 was never true

1741 result = initialize( 

1742 InitializeRequest( 

1743 protocol_version=params.get("protocolVersion") or params.get("protocol_version", ""), 

1744 capabilities=params.get("capabilities", {}), 

1745 client_info=params.get("clientInfo") or params.get("client_info", {}), 

1746 ), 

1747 user, 

1748 ).model_dump(by_alias=True, exclude_none=True) 

1749 elif method == "list_gateways": 1749 ↛ 1750line 1749 didn't jump to line 1750 because the condition on line 1749 was never true

1750 gateways = await gateway_service.list_gateways(db, include_inactive=False) 

1751 result = [g.model_dump(by_alias=True, exclude_none=True) for g in gateways] 

1752 elif method == "list_roots": 1752 ↛ 1753line 1752 didn't jump to line 1753 because the condition on line 1752 was never true

1753 roots = await root_service.list_roots() 

1754 result = [r.model_dump(by_alias=True, exclude_none=True) for r in roots] 

1755 elif method == "resources/list": 1755 ↛ 1756line 1755 didn't jump to line 1756 because the condition on line 1755 was never true

1756 resources = await resource_service.list_resources(db) 

1757 result = [r.model_dump(by_alias=True, exclude_none=True) for r in resources] 

1758 elif method == "prompts/list": 1758 ↛ 1759line 1758 didn't jump to line 1759 because the condition on line 1758 was never true

1759 prompts = await prompt_service.list_prompts(db, cursor=cursor) 

1760 result = [p.model_dump(by_alias=True, exclude_none=True) for p in prompts] 

1761 elif method == "prompts/get": 

1762 name = params.get("name") 

1763 arguments = params.get("arguments", {}) 

1764 if not name: 1764 ↛ 1765line 1764 didn't jump to line 1765 because the condition on line 1764 was never true

1765 raise JSONRPCError(-32602, "Missing prompt name in parameters", params) 

1766 result = await prompt_service.get_prompt(db, name, arguments) 

1767 if hasattr(result, "model_dump"): 1767 ↛ 1768line 1767 didn't jump to line 1768 because the condition on line 1767 was never true

1768 result = result.model_dump(by_alias=True, exclude_none=True) 

1769 elif method == "ping": 1769 ↛ 1771line 1769 didn't jump to line 1771 because the condition on line 1769 was never true

1770 # Per the MCP spec, a ping returns an empty result. 

1771 result = {} 

1772 else: 

1773 try: 

1774 result = await tool_service.invoke_tool(db, method, params) 

1775 if hasattr(result, "model_dump"): 1775 ↛ 1776line 1775 didn't jump to line 1776 because the condition on line 1775 was never true

1776 result = result.model_dump(by_alias=True, exclude_none=True) 

1777 except ValueError: 

1778 result = await gateway_service.forward_request(db, method, params) 

1779 if hasattr(result, "model_dump"): 

1780 result = result.model_dump(by_alias=True, exclude_none=True) 

1781 

1782 response = result 

1783 return response 

1784 

1785 except JSONRPCError as e: 

1786 return e.to_dict() 

1787 except Exception as e: 

1788 logger.error(f"RPC error: {str(e)}") 

1789 return { 

1790 "jsonrpc": "2.0", 

1791 "error": {"code": -32000, "message": "Internal error", "data": str(e)}, 

1792 "id": body.get("id") if "body" in locals() else None, 

1793 } 

1794 

1795 

1796@utility_router.websocket("/ws") 

1797async def websocket_endpoint(websocket: WebSocket): 

1798 """ 

1799 Handle WebSocket connection to relay JSON-RPC requests to the internal RPC endpoint. 

1800 

1801 Accepts incoming text messages, parses them as JSON-RPC requests, sends them to /rpc, 

1802 and returns the result to the client over the same WebSocket. 

1803 

1804 Args: 

1805 websocket: The WebSocket connection instance. 

1806 """ 

1807 try: 

1808 await websocket.accept() 

1809 while True: 

1810 try: 

1811 data = await websocket.receive_text() 

1812 async with httpx.AsyncClient(timeout=settings.federation_timeout, verify=not settings.skip_ssl_verify) as client: 

1813 response = await client.post( 

1814 f"http://localhost:{settings.port}/rpc", 

1815 json=json.loads(data), 

1816 headers={"Content-Type": "application/json"}, 

1817 ) 

1818 await websocket.send_text(response.text) 

1819 except JSONRPCError as e: 

1820 await websocket.send_text(json.dumps(e.to_dict())) 

1821 except json.JSONDecodeError: 

1822 await websocket.send_text( 

1823 json.dumps( 

1824 { 

1825 "jsonrpc": "2.0", 

1826 "error": {"code": -32700, "message": "Parse error"}, 

1827 "id": None, 

1828 } 

1829 ) 

1830 ) 

1831 except Exception as e: 

1832 logger.error(f"WebSocket error: {str(e)}") 

1833 await websocket.close(code=1011) 

1834 break 

1835 except WebSocketDisconnect: 

1836 logger.info("WebSocket disconnected") 

1837 except Exception as e: 

1838 logger.error(f"WebSocket connection error: {str(e)}") 

1839 try: 

1840 await websocket.close(code=1011) 

1841 except Exception as er: 

1842 logger.error(f"Error while closing WebSocket: {er}") 

1843 

1844 

1845@utility_router.get("/sse") 

1846async def utility_sse_endpoint(request: Request, user: str = Depends(require_auth)): 

1847 """ 

1848 Establish a Server-Sent Events (SSE) connection for real-time updates. 

1849 

1850 Args: 

1851 request (Request): The incoming HTTP request. 

1852 user (str): Authenticated username. 

1853 

1854 Returns: 

1855 StreamingResponse: A streaming response that keeps the connection 

1856 open and pushes events to the client. 

1857 

1858 Raises: 

1859 HTTPException: Returned with **500 Internal Server Error** if the SSE connection cannot be established or an unexpected error occurs while creating the transport. 

1860 """ 

1861 try: 

1862 logger.debug("User %s requested SSE connection", user) 

1863 base_url = str(request.base_url).rstrip("/") 

1864 transport = SSETransport(base_url=base_url) 

1865 await transport.connect() 

1866 await session_registry.add_session(transport.session_id, transport) 

1867 

1868 asyncio.create_task(session_registry.respond(None, user, session_id=transport.session_id, base_url=base_url)) 

1869 

1870 response = await transport.create_sse_response(request) 

1871 tasks = BackgroundTasks() 

1872 tasks.add_task(session_registry.remove_session, transport.session_id) 

1873 response.background = tasks 

1874 logger.info("SSE connection established: %s", transport.session_id) 

1875 return response 

1876 except Exception as e: 

1877 logger.error("SSE connection error: %s", e) 

1878 raise HTTPException(status_code=500, detail="SSE connection failed") 

1879 

1880 

1881@utility_router.post("/message") 

1882async def utility_message_endpoint(request: Request, user: str = Depends(require_auth)): 

1883 """ 

1884 Handle a JSON-RPC message directed to a specific SSE session. 

1885 

1886 Args: 

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

1888 user (str): Authenticated user. 

1889 

1890 Returns: 

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

1892 

1893 Raises: 

1894 HTTPException: * **400 Bad Request** – ``session_id`` query parameter is missing or the payload cannot be parsed as JSON. 

1895 * **500 Internal Server Error** – An unexpected error occurs while broadcasting the message. 

1896 """ 

1897 try: 

1898 logger.debug("User %s sent a message to SSE session", user) 

1899 

1900 session_id = request.query_params.get("session_id") 

1901 if not session_id: 

1902 logger.error("Missing session_id in message request") 

1903 raise HTTPException(status_code=400, detail="Missing session_id") 

1904 

1905 message = await request.json() 

1906 

1907 await session_registry.broadcast( 

1908 session_id=session_id, 

1909 message=message, 

1910 ) 

1911 

1912 return JSONResponse(content={"status": "success"}, status_code=202) 

1913 

1914 except ValueError as e: 

1915 logger.error("Invalid message format: %s", e) 

1916 raise HTTPException(status_code=400, detail=str(e)) 

1917 except HTTPException: 

1918 raise 

1919 except Exception as exc: 

1920 logger.error("Message handling error: %s", exc) 

1921 raise HTTPException(status_code=500, detail="Failed to process message") 

1922 

1923 

1924@utility_router.post("/logging/setLevel") 

1925async def set_log_level(request: Request, user: str = Depends(require_auth)) -> None: 

1926 """ 

1927 Update the server's log level at runtime. 

1928 

1929 Args: 

1930 request: HTTP request with log level JSON body. 

1931 user: Authenticated user. 

1932 

1933 Returns: 

1934 None 

1935 """ 

1936 logger.debug(f"User {user} requested to set log level") 

1937 body = await request.json() 

1938 level = LogLevel(body["level"]) 

1939 await logging_service.set_level(level) 

1940 return None 

1941 

1942 

1943#################### 

1944# Metrics # 

1945#################### 

1946@metrics_router.get("", response_model=dict) 

1947async def get_metrics(db: Session = Depends(get_db), user: str = Depends(require_auth)) -> dict: 

1948 """ 

1949 Retrieve aggregated metrics for all entity types (Tools, Resources, Servers, Prompts). 

1950 

1951 Args: 

1952 db: Database session 

1953 user: Authenticated user 

1954 

1955 Returns: 

1956 A dictionary with keys for each entity type and their aggregated metrics. 

1957 """ 

1958 logger.debug(f"User {user} requested aggregated metrics") 

1959 tool_metrics = await tool_service.aggregate_metrics(db) 

1960 resource_metrics = await resource_service.aggregate_metrics(db) 

1961 server_metrics = await server_service.aggregate_metrics(db) 

1962 prompt_metrics = await prompt_service.aggregate_metrics(db) 

1963 return { 

1964 "tools": tool_metrics, 

1965 "resources": resource_metrics, 

1966 "servers": server_metrics, 

1967 "prompts": prompt_metrics, 

1968 } 

1969 

1970 

1971@metrics_router.post("/reset", response_model=dict) 

1972async def reset_metrics(entity: Optional[str] = None, entity_id: Optional[int] = None, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> dict: 

1973 """ 

1974 Reset metrics for a specific entity type and optionally a specific entity ID, 

1975 or perform a global reset if no entity is specified. 

1976 

1977 Args: 

1978 entity: One of "tool", "resource", "server", "prompt", or None for global reset. 

1979 entity_id: Specific entity ID to reset metrics for (optional). 

1980 db: Database session 

1981 user: Authenticated user 

1982 

1983 Returns: 

1984 A success message in a dictionary. 

1985 

1986 Raises: 

1987 HTTPException: If an invalid entity type is specified. 

1988 """ 

1989 logger.debug(f"User {user} requested metrics reset for entity: {entity}, id: {entity_id}") 

1990 if entity is None: 

1991 # Global reset 

1992 await tool_service.reset_metrics(db) 

1993 await resource_service.reset_metrics(db) 

1994 await server_service.reset_metrics(db) 

1995 await prompt_service.reset_metrics(db) 

1996 elif entity.lower() == "tool": 

1997 await tool_service.reset_metrics(db, entity_id) 

1998 elif entity.lower() == "resource": 

1999 await resource_service.reset_metrics(db) 

2000 elif entity.lower() == "server": 

2001 await server_service.reset_metrics(db) 

2002 elif entity.lower() == "prompt": 

2003 await prompt_service.reset_metrics(db) 

2004 else: 

2005 raise HTTPException(status_code=400, detail="Invalid entity type for metrics reset") 

2006 return {"status": "success", "message": f"Metrics reset for {entity if entity else 'all entities'}"} 

2007 

2008 

2009#################### 

2010# Healthcheck # 

2011#################### 

2012@app.get("/health") 

2013async def healthcheck(db: Session = Depends(get_db)): 

2014 """ 

2015 Perform a basic health check to verify database connectivity. 

2016 

2017 Args: 

2018 db: SQLAlchemy session dependency. 

2019 

2020 Returns: 

2021 A dictionary with the health status and optional error message. 

2022 """ 

2023 try: 

2024 # Execute the query using text() for an explicit textual SQL expression. 

2025 db.execute(text("SELECT 1")) 

2026 except Exception as e: 

2027 error_message = f"Database connection error: {str(e)}" 

2028 logger.error(error_message) 

2029 return {"status": "unhealthy", "error": error_message} 

2030 return {"status": "healthy"} 

2031 

2032 

2033@app.get("/ready") 

2034async def readiness_check(db: Session = Depends(get_db)): 

2035 """ 

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

2037 

2038 Args: 

2039 db: SQLAlchemy session dependency. 

2040 

2041 Returns: 

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

2043 """ 

2044 try: 

2045 # Run the blocking DB check in a thread to avoid blocking the event loop 

2046 await asyncio.to_thread(db.execute, text("SELECT 1")) 

2047 return JSONResponse(content={"status": "ready"}, status_code=200) 

2048 except Exception as e: 

2049 error_message = f"Readiness check failed: {str(e)}" 

2050 logger.error(error_message) 

2051 return JSONResponse(content={"status": "not ready", "error": error_message}, status_code=503) 

2052 

2053 

2054# Mount static files 

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

2056 

2057# Include routers 

2058app.include_router(version_router) 

2059app.include_router(protocol_router) 

2060app.include_router(tool_router) 

2061app.include_router(resource_router) 

2062app.include_router(prompt_router) 

2063app.include_router(gateway_router) 

2064app.include_router(root_router) 

2065app.include_router(utility_router) 

2066app.include_router(server_router) 

2067app.include_router(metrics_router) 

2068 

2069 

2070# Feature flags for admin UI and API 

2071UI_ENABLED = settings.mcpgateway_ui_enabled 

2072ADMIN_API_ENABLED = settings.mcpgateway_admin_api_enabled 

2073logger.info(f"Admin UI enabled: {UI_ENABLED}") 

2074logger.info(f"Admin API enabled: {ADMIN_API_ENABLED}") 

2075 

2076# Conditional UI and admin API handling 

2077if ADMIN_API_ENABLED: 2077 ↛ 2081line 2077 didn't jump to line 2081 because the condition on line 2077 was always true

2078 logger.info("Including admin_router - Admin API enabled") 

2079 app.include_router(admin_router) # Admin routes imported from admin.py 

2080else: 

2081 logger.warning("Admin API routes not mounted - Admin API disabled via MCPGATEWAY_ADMIN_API_ENABLED=False") 

2082 

2083# Streamable http Mount 

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

2085 

2086# Conditional static files mounting and root redirect 

2087if UI_ENABLED: 2087 ↛ 2126line 2087 didn't jump to line 2126 because the condition on line 2087 was always true

2088 # Mount static files for UI 

2089 logger.info("Mounting static files - UI enabled") 

2090 try: 

2091 app.mount( 

2092 "/static", 

2093 StaticFiles(directory=str(settings.static_dir)), 

2094 name="static", 

2095 ) 

2096 logger.info("Static assets served from %s", settings.static_dir) 

2097 except RuntimeError as exc: 

2098 logger.warning( 

2099 "Static dir %s not found – Admin UI disabled (%s)", 

2100 settings.static_dir, 

2101 exc, 

2102 ) 

2103 

2104 # Redirect root path to admin UI 

2105 @app.get("/") 

2106 async def root_redirect(request: Request): 

2107 """ 

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

2109 

2110 Logs a debug message before redirecting. 

2111 

2112 Args: 

2113 request (Request): The incoming HTTP request (used only to build the 

2114 target URL via :pymeth:`starlette.requests.Request.url_for`). 

2115 

2116 Returns: 

2117 RedirectResponse: Redirects to /admin. 

2118 """ 

2119 logger.debug("Redirecting root path to /admin") 

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

2121 return RedirectResponse(f"{root_path}/admin", status_code=303) 

2122 # return RedirectResponse(request.url_for("admin_home")) 

2123 

2124else: 

2125 # If UI is disabled, provide API info at root 

2126 logger.warning("Static files not mounted - UI disabled via MCPGATEWAY_UI_ENABLED=False") 

2127 

2128 @app.get("/") 

2129 async def root_info(): 

2130 """ 

2131 Returns basic API information at the root path. 

2132 

2133 Logs an info message indicating UI is disabled and provides details 

2134 about the app, including its name, version, and whether the UI and 

2135 admin API are enabled. 

2136 

2137 Returns: 

2138 dict: API info with app name, version, and UI/admin API status. 

2139 """ 

2140 logger.info("UI disabled, serving API info at root path") 

2141 return {"name": settings.app_name, "version": "1.0.0", "description": f"{settings.app_name} API - UI is disabled", "ui_enabled": False, "admin_api_enabled": ADMIN_API_ENABLED} 

2142 

2143 

2144# Expose some endpoints at the root level as well 

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

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