Coverage for mcpgateway/main.py: 70%

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

2"""

3 Copyright 2025

4 SPDX-License-Identifier: Apache-2.0

5 Authors: Mihai Criveti

6

7 MCP Gateway - Main FastAPI Application.

8

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

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

11

12 Features 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

20 Structure:

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

28# Standard

29 import asyncio

30 from contextlib import asynccontextmanager

31 import json

32 import logging

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

34

35# Third-Party

36 from fastapi import (

37 APIRouter,

38 Body,

39 Depends,

40 FastAPI,

41 HTTPException,

42 Request,

43 status,

44 WebSocket,

45 WebSocketDisconnect,

46)

47 from fastapi.background import BackgroundTasks

48 from fastapi.middleware.cors import CORSMiddleware

49 from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse

50 from fastapi.staticfiles import StaticFiles

51 from fastapi.templating import Jinja2Templates

52 import httpx

53 from sqlalchemy import text

54 from sqlalchemy.orm import Session

55 from starlette.middleware.base import BaseHTTPMiddleware

56

57# First-Party

58 from mcpgateway import __version__

59 from mcpgateway.admin import admin_router

60 from mcpgateway.bootstrap_db import main as bootstrap_db

61 from mcpgateway.cache import ResourceCache, SessionRegistry

62 from mcpgateway.config import jsonpath_modifier, settings

63 from mcpgateway.db import SessionLocal

64 from mcpgateway.handlers.sampling import SamplingHandler

65 from mcpgateway.models import (

66 InitializeRequest,

67 InitializeResult,

68 ListResourceTemplatesResult,

69 LogLevel,

70 ResourceContent,

71 Root,

72)

73 from mcpgateway.schemas import (

74 GatewayCreate,

75 GatewayRead,

76 GatewayUpdate,

77 JsonPathModifier,

78 PromptCreate,

79 PromptRead,

80 PromptUpdate,

81 ResourceCreate,

82 ResourceRead,

83 ResourceUpdate,

84 ServerCreate,

85 ServerRead,

86 ServerUpdate,

87 ToolCreate,

88 ToolRead,

89 ToolUpdate,

90)

91 from mcpgateway.services.completion_service import CompletionService

92 from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayService

93 from mcpgateway.services.logging_service import LoggingService

94 from mcpgateway.services.prompt_service import (

95 PromptError,

96 PromptNameConflictError,

97 PromptNotFoundError,

98 PromptService,

99)

100 from mcpgateway.services.resource_service import (

101 ResourceError,

102 ResourceNotFoundError,

103 ResourceService,

104 ResourceURIConflictError,

105)

106 from mcpgateway.services.root_service import RootService

107 from mcpgateway.services.server_service import (

108 ServerError,

109 ServerNameConflictError,

110 ServerNotFoundError,

111 ServerService,

112)

113 from mcpgateway.services.tool_service import (

114 ToolError,

115 ToolNameConflictError,

116 ToolService,

117)

118 from mcpgateway.transports.sse_transport import SSETransport

119 from mcpgateway.transports.streamablehttp_transport import (

120 SessionManagerWrapper,

121 streamable_http_auth,

122)

123 from mcpgateway.utils.db_isready import wait_for_db_ready

124 from mcpgateway.utils.redis_isready import wait_for_redis_ready

125 from mcpgateway.utils.verify_credentials import require_auth, require_auth_override

126 from mcpgateway.validation.jsonrpc import (

127 JSONRPCError,

128 validate_request,

129)

130

131# Import the admin routes from the new module

132 from mcpgateway.version import router as version_router

133

134# Initialize logging service first

135 logging_service = LoggingService()

136 logger = logging_service.get_logger("mcpgateway")

137

138# Configure root logger level

139 logging.basicConfig(

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

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

142)

143

144# Wait for database to be ready before creating tables

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

146

147# Create database tables

148 try:

149 loop = asyncio.get_running_loop()

150 except RuntimeError:

151 asyncio.run(bootstrap_db())

152 else:

153 loop.create_task(bootstrap_db())

154

155

156# Initialize services

157 tool_service = ToolService()

158 resource_service = ResourceService()

159 prompt_service = PromptService()

160 gateway_service = GatewayService()

161 root_service = RootService()

162 completion_service = CompletionService()

163 sampling_handler = SamplingHandler()

164 server_service = ServerService()

165

166# Initialize session manager for Streamable HTTP transport

167 streamable_http_session = SessionManagerWrapper()

168

169# Wait for redis to be ready

170 if settings.cache_type == "redis": 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

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

172

173# Initialize session registry

174 session_registry = SessionRegistry(

175 backend=settings.cache_type,

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

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

178 session_ttl=settings.session_ttl,

179 message_ttl=settings.message_ttl,

180)

181

182# Initialize cache

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

184

185

186####################

187# Startup/Shutdown #

188####################

189@asynccontextmanager

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

191 """

192 Manage the application's startup and shutdown lifecycle.

193

194 The function initialises every core service on entry and then

195 shuts them down in reverse order on exit.

196

197 Args:

198 _app (FastAPI): FastAPI app

199

200 Yields:

201 None

202

203 Raises:

204 Exception: Any unhandled error that occurs during service

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

206 """

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

208 try:

209 await tool_service.initialize()

210 await resource_service.initialize()

211 await prompt_service.initialize()

212 await gateway_service.initialize()

213 await root_service.initialize()

214 await completion_service.initialize()

215 await logging_service.initialize()

216 await sampling_handler.initialize()

217 await resource_cache.initialize()

218 await streamable_http_session.initialize()

219

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

221 yield

222 except Exception as e:

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

224 raise

225 finally:

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

227 # await stop_streamablehttp()

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

229 try:

230 await service.shutdown()

231 except Exception as e:

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

233 logger.info("Shutdown complete")

234

235

236# Initialize FastAPI app

237 app = FastAPI(

238 title=settings.app_name,

239 version=__version__,

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

241 root_path=settings.app_root_path,

242 lifespan=lifespan,

243)

244

245

246 class DocsAuthMiddleware(BaseHTTPMiddleware):

247 """

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

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

250

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

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

253 """

254

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

256 """

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

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

259

260 Args:

261 request (Request): The incoming HTTP request.

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

263

264 Returns:

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

266 """

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

268

269 if any(request.url.path.startswith(p) for p in protected_paths):

270 try:

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

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

273

274 # Simulate what Depends(require_auth) would do

275 await require_auth_override(token, cookie_token)

276 except HTTPException as e:

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

278

279 # Proceed to next middleware or route

280 return await call_next(request)

281

282

283 class MCPPathRewriteMiddleware:

284 """

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

286

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

288 - Performs authentication before rewriting.

289 - Passes rewritten requests to `streamable_http_session`.

290 - All other requests are passed through without change.

291 """

292

293 def __init__(self, app):

294 """

295 Initialize the middleware with the ASGI application.

296

297 Args:

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

299 """

300 self.app = app

301

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

303 """

304 Intercept and potentially rewrite the incoming HTTP request path.

305

306 Args:

307 scope (dict): The ASGI connection scope.

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

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

310 """

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

312 if scope["type"] != "http":

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

314 return

315

316 # Call auth check first

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

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

319 return

320

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

322 scope["modified_path"] = original_path

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

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

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

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

327 return

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

329

330

331# Configure CORS

332 app.add_middleware(

333 CORSMiddleware,

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

335 allow_credentials=True,

336 allow_methods=["*"],

337 allow_headers=["*"],

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

339)

340

341

342# Add custom DocsAuthMiddleware

343 app.add_middleware(DocsAuthMiddleware)

344

345# Add streamable HTTP middleware for /mcp routes

346 app.add_middleware(MCPPathRewriteMiddleware)

347

348

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

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

351 app.state.templates = templates

352

353# Create API routers

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

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

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

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

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

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

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

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

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

363

364# Basic Auth setup

365

366

367# Database dependency

368 def get_db():

369 """

370 Dependency function to provide a database session.

371

372 Yields:

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

374

375 Ensures:

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

377 """

378 db = SessionLocal()

379 try:

380 yield db

381 finally:

382 db.close()

383

384

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

386 """

387 Validates the provided API key.

388

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

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

391 with a 401 Unauthorized status.

392

393 Args:

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

395

396 Raises:

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

398 """

399 if settings.auth_required:

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

401 if api_key != expected:

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

403

404

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

406 """

407 Invalidates the resource cache.

408

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

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

411

412 Args:

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

414 """

415 if uri: 415 ↛ 418line 415 didn't jump to line 418 because the condition on line 415 was always true

416 resource_cache.delete(uri)

417 else:

418 resource_cache.clear()

419

420

421#################

422# Protocol APIs #

423#################

424@protocol_router.post("/initialize")

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

426 """

427 Initialize a protocol.

428

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

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

431 the user is authenticated before proceeding.

432

433 Args:

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

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

436

437 Returns:

438 InitializeResult: The result of the initialization process.

439

440 Raises:

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

442 """

443 try:

444 body = await request.json()

445

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

447 return await session_registry.handle_initialize_logic(body)

448

449 except json.JSONDecodeError:

450 raise HTTPException(

451 status_code=status.HTTP_400_BAD_REQUEST,

452 detail="Invalid JSON in request body",

453 )

454

455

456@protocol_router.post("/ping")

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

458 """

459 Handle a ping request according to the MCP specification.

460

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

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

463

464 Args:

465 request (Request): The incoming FastAPI request.

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

467

468 Returns:

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

470

471 Raises:

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

473 """

474 try:

475 body: dict = await request.json()

476 if body.get("method") != "ping":

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

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

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

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

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

482 return JSONResponse(content=response)

483 except Exception as e:

484 error_response: dict = {

485 "jsonrpc": "2.0",

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

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

488 }

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

490

491

492@protocol_router.post("/notifications")

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

494 """

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

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

497

498 Args:

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

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

501 """

502 body = await request.json()

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

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

505 logger.info("Client initialized")

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

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

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

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

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

511 elif body.get("method") == "notifications/message": 511 ↛ exitline 511 didn't return from function 'handle_notification' because the condition on line 511 was always true

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

513 await logging_service.notify(

514 params.get("data"),

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

516 params.get("logger"),

517 )

518

519

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

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

522 """

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

524

525 Args:

526 request (Request): The incoming request with completion 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 completion process.

532 """

533 body = await request.json()

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

535 return await completion_service.handle_completion(db, body)

536

537

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

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

540 """

541 Handles the creation of a new message for sampling.

542

543 Args:

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

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

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

547

548 Returns:

549 The result of the message creation process.

550 """

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

552 body = await request.json()

553 return await sampling_handler.create_message(db, body)

554

555

556###############

557# Server APIs #

558###############

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

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

561 async def list_servers(

562 include_inactive: bool = False,

563 db: Session = Depends(get_db),

564 user: str = Depends(require_auth),

565) -> List[ServerRead]:

566 """

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

568

569 Args:

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

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

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

573

574 Returns:

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

576 """

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

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

579

580

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

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

583 """

584 Retrieves a server by its ID.

585

586 Args:

587 server_id (str): The ID of the server to retrieve.

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

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

590

591 Returns:

592 ServerRead: The server object with the specified ID.

593

594 Raises:

595 HTTPException: If the server is not found.

596 """

597 try:

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

599 return await server_service.get_server(db, server_id)

600 except ServerNotFoundError as e:

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

602

603

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

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

606 async def create_server(

607 server: ServerCreate,

608 db: Session = Depends(get_db),

609 user: str = Depends(require_auth),

610) -> ServerRead:

611 """

612 Creates a new server.

613

614 Args:

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

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

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

618

619 Returns:

620 ServerRead: The created server object.

621

622 Raises:

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

624 """

625 try:

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

627 return await server_service.register_server(db, server)

628 except ServerNameConflictError as e:

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

630 except ServerError as e:

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

632

633

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

635 async def update_server(

636 server_id: str,

637 server: ServerUpdate,

638 db: Session = Depends(get_db),

639 user: str = Depends(require_auth),

640) -> ServerRead:

641 """

642 Updates the information of an existing server.

643

644 Args:

645 server_id (str): The ID of the server to update.

646 server (ServerUpdate): The updated server data.

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

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

649

650 Returns:

651 ServerRead: The updated server object.

652

653 Raises:

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

655 """

656 try:

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

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

659 except ServerNotFoundError as e:

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

661 except ServerNameConflictError as e:

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

663 except ServerError as e:

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

665

666

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

668 async def toggle_server_status(

669 server_id: str,

670 activate: bool = True,

671 db: Session = Depends(get_db),

672 user: str = Depends(require_auth),

673) -> ServerRead:

674 """

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

676

677 Args:

678 server_id (str): The ID of the server to toggle.

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

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

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

682

683 Returns:

684 ServerRead: The server object after the status change.

685

686 Raises:

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

688 """

689 try:

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

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

692 except ServerNotFoundError as e:

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

694 except ServerError as e:

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

696

697

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

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

700 """

701 Deletes a server by its ID.

702

703 Args:

704 server_id (str): The ID of the server to delete.

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

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

707

708 Returns:

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

710

711 Raises:

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

713 """

714 try:

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

716 await server_service.delete_server(db, server_id)

717 return {

718 "status": "success",

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

720 }

721 except ServerNotFoundError as e:

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

723 except ServerError as e:

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

725

726

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

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

729 """

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

731

732 Args:

733 request (Request): The incoming request.

734 server_id (str): The ID of the server for which updates are received.

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

736

737 Returns:

738 The SSE response object for the established connection.

739

740 Raises:

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

742 """

743 try:

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

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

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

747 transport = SSETransport(base_url=server_sse_url)

748 await transport.connect()

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

750 response = await transport.create_sse_response(request)

751

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

753

754 tasks = BackgroundTasks()

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

756 response.background = tasks

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

758 return response

759 except Exception as e:

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

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

762

763

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

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

766 """

767 Handles incoming messages for a specific server.

768

769 Args:

770 request (Request): The incoming message request.

771 server_id (str): The ID of the server receiving the message.

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

773

774 Returns:

775 JSONResponse: A success status after processing the message.

776

777 Raises:

778 HTTPException: If there are errors processing the message.

779 """

780 try:

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

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

783 if not session_id:

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

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

786

787 message = await request.json()

788

789 await session_registry.broadcast(

790 session_id=session_id,

791 message=message,

792 )

793

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

795 except ValueError as e:

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

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

798 except HTTPException:

799 raise

800 except Exception as e:

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

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

803

804

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

806 async def server_get_tools(

807 server_id: str,

808 include_inactive: bool = False,

809 db: Session = Depends(get_db),

810 user: str = Depends(require_auth),

811) -> List[ToolRead]:

812 """

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

814

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

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

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

818

819 Args:

820 server_id (str): ID of the server

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

822 db (Session): Database session dependency.

823 user (str): Authenticated user dependency.

824

825 Returns:

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

827 """

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

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

830 return [tool.model_dump(by_alias=True) for tool in tools]

831

832

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

834 async def server_get_resources(

835 server_id: str,

836 include_inactive: bool = False,

837 db: Session = Depends(get_db),

838 user: str = Depends(require_auth),

839) -> List[ResourceRead]:

840 """

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

842

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

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

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

846

847 Args:

848 server_id (str): ID of the server

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

850 db (Session): Database session dependency.

851 user (str): Authenticated user dependency.

852

853 Returns:

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

855 """

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

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

858 return [resource.model_dump(by_alias=True) for resource in resources]

859

860

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

862 async def server_get_prompts(

863 server_id: str,

864 include_inactive: bool = False,

865 db: Session = Depends(get_db),

866 user: str = Depends(require_auth),

867) -> List[PromptRead]:

868 """

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

870

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

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

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

874

875 Args:

876 server_id (str): ID of the server

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

878 db (Session): Database session dependency.

879 user (str): Authenticated user dependency.

880

881 Returns:

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

883 """

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

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

886 return [prompt.model_dump(by_alias=True) for prompt in prompts]

887

888

889#############

890# Tool APIs #

891#############

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

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

894 async def list_tools(

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

896 include_inactive: bool = False,

897 db: Session = Depends(get_db),

898 apijsonpath: JsonPathModifier = Body(None),

899 _: str = Depends(require_auth),

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

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

902

903 Args:

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

905 include_inactive: Whether to include inactive tools in the results

906 db: Database session

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

908 _: Authenticated user

909

910 Returns:

911 List of tools or modified result based on jsonpath

912 """

913

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

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

916

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

918 return data

919

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

921

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

923

924

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

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

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

928 """

929 Creates a new tool in the system.

930

931 Args:

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

933 db (Session): The database session dependency.

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

935

936 Returns:

937 ToolRead: The created tool data.

938

939 Raises:

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

941 """

942 try:

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

944 return await tool_service.register_tool(db, tool)

945 except ToolNameConflictError as e:

946 if not e.enabled and e.tool_id: 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true

947 raise HTTPException(

948 status_code=status.HTTP_409_CONFLICT,

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

950 )

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

952 except ToolError as e:

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

954

955

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

957 async def get_tool(

958 tool_id: str,

959 db: Session = Depends(get_db),

960 user: str = Depends(require_auth),

961 apijsonpath: JsonPathModifier = Body(None),

962) -> Union[ToolRead, Dict]:

963 """

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

965

966 Args:

967 tool_id: The numeric ID of the tool.

968 db: Active SQLAlchemy session (dependency).

969 user: Authenticated username (dependency).

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

971

972 Returns:

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

974 a JSONPath filter/mapping was supplied.

975

976 Raises:

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

978 """

979 try:

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

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

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

983 return data

984

985 data_dict = data.to_dict(use_alias=True)

986

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

988 except Exception as e:

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

990

991

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

993 async def update_tool(

994 tool_id: str,

995 tool: ToolUpdate,

996 db: Session = Depends(get_db),

997 user: str = Depends(require_auth),

998) -> ToolRead:

999 """

1000 Updates an existing tool with new data.

1001

1002 Args:

1003 tool_id (str): The ID of the tool to update.

1004 tool (ToolUpdate): The updated tool information.

1005 db (Session): The database session dependency.

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

1007

1008 Returns:

1009 ToolRead: The updated tool data.

1010

1011 Raises:

1012 HTTPException: If an error occurs during the update.

1013 """

1014 try:

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

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

1017 except Exception as e:

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

1019

1020

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

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

1023 """

1024 Permanently deletes a tool by ID.

1025

1026 Args:

1027 tool_id (str): The ID of the tool to delete.

1028 db (Session): The database session dependency.

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

1030

1031 Returns:

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

1033

1034 Raises:

1035 HTTPException: If an error occurs during deletion.

1036 """

1037 try:

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

1039 await tool_service.delete_tool(db, tool_id)

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

1041 except Exception as e:

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

1043

1044

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

1046 async def toggle_tool_status(

1047 tool_id: str,

1048 activate: bool = True,

1049 db: Session = Depends(get_db),

1050 user: str = Depends(require_auth),

1051) -> Dict[str, Any]:

1052 """

1053 Activates or deactivates a tool.

1054

1055 Args:

1056 tool_id (str): The ID of the tool to toggle.

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

1058 db (Session): The database session dependency.

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

1060

1061 Returns:

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

1063

1064 Raises:

1065 HTTPException: If an error occurs during status toggling.

1066 """

1067 try:

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

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

1070 return {

1071 "status": "success",

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

1073 "tool": tool.model_dump(),

1074 }

1075 except Exception as e:

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

1077

1078

1079#################

1080# Resource APIs #

1081#################

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

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

1084 async def list_resource_templates(

1085 db: Session = Depends(get_db),

1086 user: str = Depends(require_auth),

1087) -> ListResourceTemplatesResult:

1088 """

1089 List all available resource templates.

1090

1091 Args:

1092 db (Session): Database session.

1093 user (str): Authenticated user.

1094

1095 Returns:

1096 ListResourceTemplatesResult: A paginated list of resource templates.

1097 """

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

1099 resource_templates = await resource_service.list_resource_templates(db)

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

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

1102

1103

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

1105 async def toggle_resource_status(

1106 resource_id: int,

1107 activate: bool = True,

1108 db: Session = Depends(get_db),

1109 user: str = Depends(require_auth),

1110) -> Dict[str, Any]:

1111 """

1112 Activate or deactivate a resource by its ID.

1113

1114 Args:

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

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

1117 db (Session): Database session.

1118 user (str): Authenticated user.

1119

1120 Returns:

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

1122

1123 Raises:

1124 HTTPException: If toggling fails.

1125 """

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

1127 try:

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

1129 return {

1130 "status": "success",

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

1132 "resource": resource.model_dump(),

1133 }

1134 except Exception as e:

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

1136

1137

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

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

1140 async def list_resources(

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

1142 include_inactive: bool = False,

1143 db: Session = Depends(get_db),

1144 user: str = Depends(require_auth),

1145) -> List[ResourceRead]:

1146 """

1147 Retrieve a list of resources.

1148

1149 Args:

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

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

1152 db (Session): Database session.

1153 user (str): Authenticated user.

1154

1155 Returns:

1156 List[ResourceRead]: List of resources.

1157 """

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

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

1160 return cached

1161 # Pass the cursor parameter

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

1163 resource_cache.set("resource_list", resources)

1164 return resources

1165

1166

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

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

1169 async def create_resource(

1170 resource: ResourceCreate,

1171 db: Session = Depends(get_db),

1172 user: str = Depends(require_auth),

1173) -> ResourceRead:

1174 """

1175 Create a new resource.

1176

1177 Args:

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

1179 db (Session): Database session.

1180 user (str): Authenticated user.

1181

1182 Returns:

1183 ResourceRead: The created resource.

1184

1185 Raises:

1186 HTTPException: On conflict or validation errors.

1187 """

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

1189 try:

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

1191 return result

1192 except ResourceURIConflictError as e:

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

1194 except ResourceError as e:

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

1196

1197

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

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

1200 """

1201 Read a resource by its URI.

1202

1203 Args:

1204 uri (str): URI of the resource.

1205 db (Session): Database session.

1206 user (str): Authenticated user.

1207

1208 Returns:

1209 ResourceContent: The content of the resource.

1210

1211 Raises:

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

1213 """

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

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

1216 return cached

1217 try:

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

1219 except (ResourceNotFoundError, ResourceError) as exc:

1220 # Translate to FastAPI HTTP error

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

1222 resource_cache.set(uri, content)

1223 return content

1224

1225

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

1227 async def update_resource(

1228 uri: str,

1229 resource: ResourceUpdate,

1230 db: Session = Depends(get_db),

1231 user: str = Depends(require_auth),

1232) -> ResourceRead:

1233 """

1234 Update a resource identified by its URI.

1235

1236 Args:

1237 uri (str): URI of the resource.

1238 resource (ResourceUpdate): New resource data.

1239 db (Session): Database session.

1240 user (str): Authenticated user.

1241

1242 Returns:

1243 ResourceRead: The updated resource.

1244

1245 Raises:

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

1247 """

1248 try:

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

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

1251 except ResourceNotFoundError as e:

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

1253 await invalidate_resource_cache(uri)

1254 return result

1255

1256

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

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

1259 """

1260 Delete a resource by its URI.

1261

1262 Args:

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

1264 db (Session): Database session.

1265 user (str): Authenticated user.

1266

1267 Returns:

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

1269

1270 Raises:

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

1272 """

1273 try:

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

1275 await resource_service.delete_resource(db, uri)

1276 await invalidate_resource_cache(uri)

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

1278 except ResourceNotFoundError as e:

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

1280 except ResourceError as e:

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

1282

1283

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

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

1286 """

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

1288

1289 Args:

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

1291 user (str): Authenticated user.

1292

1293 Returns:

1294 StreamingResponse: A streaming response with event updates.

1295 """

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

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

1298

1299

1300###############

1301# Prompt APIs #

1302###############

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

1304 async def toggle_prompt_status(

1305 prompt_id: int,

1306 activate: bool = True,

1307 db: Session = Depends(get_db),

1308 user: str = Depends(require_auth),

1309) -> Dict[str, Any]:

1310 """

1311 Toggle the activation status of a prompt.

1312

1313 Args:

1314 prompt_id: ID of the prompt to toggle.

1315 activate: True to activate, False to deactivate.

1316 db: Database session.

1317 user: Authenticated user.

1318

1319 Returns:

1320 Status message and updated prompt details.

1321

1322 Raises:

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

1324 """

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

1326 try:

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

1328 return {

1329 "status": "success",

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

1331 "prompt": prompt.model_dump(),

1332 }

1333 except Exception as e:

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

1335

1336

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

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

1339 async def list_prompts(

1340 cursor: Optional[str] = None,

1341 include_inactive: bool = False,

1342 db: Session = Depends(get_db),

1343 user: str = Depends(require_auth),

1344) -> List[PromptRead]:

1345 """

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

1347

1348 Args:

1349 cursor: Cursor for pagination.

1350 include_inactive: Include inactive prompts.

1351 db: Database session.

1352 user: Authenticated user.

1353

1354 Returns:

1355 List of prompt records.

1356 """

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

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

1359

1360

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

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

1363 async def create_prompt(

1364 prompt: PromptCreate,

1365 db: Session = Depends(get_db),

1366 user: str = Depends(require_auth),

1367) -> PromptRead:

1368 """

1369 Create a new prompt.

1370

1371 Args:

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

1373 db (Session): Active SQLAlchemy session.

1374 user (str): Authenticated username.

1375

1376 Returns:

1377 PromptRead: The newly-created prompt.

1378

1379 Raises:

1380 HTTPException: * **409 Conflict** - another prompt with the same name already exists.

1381 * **400 Bad Request** - validation or persistence error raised

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

1383 """

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

1385 try:

1386 return await prompt_service.register_prompt(db, prompt)

1387 except PromptNameConflictError as e:

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

1389 except PromptError as e:

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

1391

1392

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

1394 async def get_prompt(

1395 name: str,

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

1397 db: Session = Depends(get_db),

1398 user: str = Depends(require_auth),

1399) -> Any:

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

1401

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

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

1404

1405

1406 Args:

1407 name: Name of the prompt.

1408 args: Template arguments.

1409 db: Database session.

1410 user: Authenticated user.

1411

1412 Returns:

1413 Rendered prompt or metadata.

1414 """

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

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

1417

1418

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

1420 async def get_prompt_no_args(

1421 name: str,

1422 db: Session = Depends(get_db),

1423 user: str = Depends(require_auth),

1424) -> Any:

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

1426

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

1428

1429 Args:

1430 name: The name of the prompt to retrieve

1431 db: Database session

1432 user: Authenticated user

1433

1434 Returns:

1435 The prompt template information

1436 """

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

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

1439

1440

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

1442 async def update_prompt(

1443 name: str,

1444 prompt: PromptUpdate,

1445 db: Session = Depends(get_db),

1446 user: str = Depends(require_auth),

1447) -> PromptRead:

1448 """

1449 Update (overwrite) an existing prompt definition.

1450

1451 Args:

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

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

1454 db (Session): Active SQLAlchemy session.

1455 user (str): Authenticated username.

1456

1457 Returns:

1458 PromptRead: The updated prompt object.

1459

1460 Raises:

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

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

1463 """

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

1465 try:

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

1467 except PromptNameConflictError as e:

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

1469 except PromptError as e:

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

1471

1472

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

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

1475 """

1476 Delete a prompt by name.

1477

1478 Args:

1479 name: Name of the prompt.

1480 db: Database session.

1481 user: Authenticated user.

1482

1483 Returns:

1484 Status message.

1485 """

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

1487 try:

1488 await prompt_service.delete_prompt(db, name)

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

1490 except PromptNotFoundError as e:

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

1492 except PromptError as e:

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

1494

1495

1496################

1497# Gateway APIs #

1498################

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

1500 async def toggle_gateway_status(

1501 gateway_id: str,

1502 activate: bool = True,

1503 db: Session = Depends(get_db),

1504 user: str = Depends(require_auth),

1505) -> Dict[str, Any]:

1506 """

1507 Toggle the activation status of a gateway.

1508

1509 Args:

1510 gateway_id (str): String ID of the gateway to toggle.

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

1512 db (Session): Active SQLAlchemy session.

1513 user (str): Authenticated username.

1514

1515 Returns:

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

1517

1518 Raises:

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

1520 """

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

1522 try:

1523 gateway = await gateway_service.toggle_gateway_status(

1524 db,

1525 gateway_id,

1526 activate,

1527 )

1528 return {

1529 "status": "success",

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

1531 "gateway": gateway.model_dump(),

1532 }

1533 except Exception as e:

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

1535

1536

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

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

1539 async def list_gateways(

1540 include_inactive: bool = False,

1541 db: Session = Depends(get_db),

1542 user: str = Depends(require_auth),

1543) -> List[GatewayRead]:

1544 """

1545 List all gateways.

1546

1547 Args:

1548 include_inactive: Include inactive gateways.

1549 db: Database session.

1550 user: Authenticated user.

1551

1552 Returns:

1553 List of gateway records.

1554 """

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

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

1557

1558

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

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

1561 async def register_gateway(

1562 gateway: GatewayCreate,

1563 db: Session = Depends(get_db),

1564 user: str = Depends(require_auth),

1565) -> GatewayRead:

1566 """

1567 Register a new gateway.

1568

1569 Args:

1570 gateway: Gateway creation data.

1571 db: Database session.

1572 user: Authenticated user.

1573

1574 Returns:

1575 Created gateway.

1576 """

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

1578 try:

1579 return await gateway_service.register_gateway(db, gateway)

1580 except Exception as ex:

1581 if isinstance(ex, GatewayConnectionError):

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

1583 if isinstance(ex, ValueError):

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

1585 if isinstance(ex, RuntimeError):

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

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

1588

1589

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

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

1592 """

1593 Retrieve a gateway by ID.

1594

1595 Args:

1596 gateway_id: ID of the gateway.

1597 db: Database session.

1598 user: Authenticated user.

1599

1600 Returns:

1601 Gateway data.

1602 """

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

1604 return await gateway_service.get_gateway(db, gateway_id)

1605

1606

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

1608 async def update_gateway(

1609 gateway_id: str,

1610 gateway: GatewayUpdate,

1611 db: Session = Depends(get_db),

1612 user: str = Depends(require_auth),

1613) -> GatewayRead:

1614 """

1615 Update a gateway.

1616

1617 Args:

1618 gateway_id: Gateway ID.

1619 gateway: Gateway update data.

1620 db: Database session.

1621 user: Authenticated user.

1622

1623 Returns:

1624 Updated gateway.

1625 """

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

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

1628

1629

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

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

1632 """

1633 Delete a gateway by ID.

1634

1635 Args:

1636 gateway_id: ID of the gateway.

1637 db: Database session.

1638 user: Authenticated user.

1639

1640 Returns:

1641 Status message.

1642 """

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

1644 await gateway_service.delete_gateway(db, gateway_id)

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

1646

1647

1648##############

1649# Root APIs #

1650##############

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

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

1653 async def list_roots(

1654 user: str = Depends(require_auth),

1655) -> List[Root]:

1656 """

1657 Retrieve a list of all registered roots.

1658

1659 Args:

1660 user: Authenticated user.

1661

1662 Returns:

1663 List of Root objects.

1664 """

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

1666 return await root_service.list_roots()

1667

1668

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

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

1671 async def add_root(

1672 root: Root, # Accept JSON body using the Root model from models.py

1673 user: str = Depends(require_auth),

1674) -> Root:

1675 """

1676 Add a new root.

1677

1678 Args:

1679 root: Root object containing URI and name.

1680 user: Authenticated user.

1681

1682 Returns:

1683 The added Root object.

1684 """

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

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

1687

1688

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

1690 async def remove_root(

1691 uri: str,

1692 user: str = Depends(require_auth),

1693) -> Dict[str, str]:

1694 """

1695 Remove a registered root by URI.

1696

1697 Args:

1698 uri: URI of the root to remove.

1699 user: Authenticated user.

1700

1701 Returns:

1702 Status message indicating result.

1703 """

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

1705 await root_service.remove_root(uri)

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

1707

1708

1709@root_router.get("/changes")

1710 async def subscribe_roots_changes(

1711 user: str = Depends(require_auth),

1712) -> StreamingResponse:

1713 """

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

1715

1716 Args:

1717 user: Authenticated user.

1718

1719 Returns:

1720 StreamingResponse with event-stream media type.

1721 """

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

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

1724

1725

1726##################

1727# Utility Routes #

1728##################

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

1730@utility_router.post("/rpc")

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

1732 """Handle RPC requests.

1733

1734 Args:

1735 request (Request): The incoming FastAPI request.

1736 db (Session): Database session.

1737 user (str): The authenticated user.

1738

1739 Returns:

1740 Response with the RPC result or error.

1741 """

1742 try:

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

1744 body = await request.json()

1745 validate_request(body)

1746 method = body["method"]

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

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

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

1750

1751 if method == "tools/list":

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

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

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

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

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

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

1758 result = initialize(

1759 InitializeRequest(

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

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

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

1763 ),

1764 user,

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

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

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

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

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

1770 roots = await root_service.list_roots()

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

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

1773 resources = await resource_service.list_resources(db)

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

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

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

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

1778 elif method == "prompts/get":

1779 name = params.get("name")

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

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

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

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

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

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

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

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

1788 result = {}

1789 else:

1790 try:

1791 result = await tool_service.invoke_tool(db=db, name=method, arguments=params)

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

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

1794 except ValueError:

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

1796 if hasattr(result, "model_dump"):

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

1798

1799 response = result

1800 return response

1801

1802 except JSONRPCError as e:

1803 return e.to_dict()

1804 except Exception as e:

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

1806 return {

1807 "jsonrpc": "2.0",

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

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

1810 }

1811

1812

1813@utility_router.websocket("/ws")

1814 async def websocket_endpoint(websocket: WebSocket):

1815 """

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

1817

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

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

1820

1821 Args:

1822 websocket: The WebSocket connection instance.

1823 """

1824 try:

1825 await websocket.accept()

1826 while True:

1827 try:

1828 data = await websocket.receive_text()

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

1830 response = await client.post(

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

1832 json=json.loads(data),

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

1834 )

1835 await websocket.send_text(response.text)

1836 except JSONRPCError as e:

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

1838 except json.JSONDecodeError:

1839 await websocket.send_text(

1840 json.dumps(

1841 {

1842 "jsonrpc": "2.0",

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

1844 "id": None,

1845 }

1846 )

1847 )

1848 except Exception as e:

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

1850 await websocket.close(code=1011)

1851 break

1852 except WebSocketDisconnect:

1853 logger.info("WebSocket disconnected")

1854 except Exception as e:

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

1856 try:

1857 await websocket.close(code=1011)

1858 except Exception as er:

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

1860

1861

1862@utility_router.get("/sse")

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

1864 """

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

1866

1867 Args:

1868 request (Request): The incoming HTTP request.

1869 user (str): Authenticated username.

1870

1871 Returns:

1872 StreamingResponse: A streaming response that keeps the connection

1873 open and pushes events to the client.

1874

1875 Raises:

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

1877 """

1878 try:

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

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

1881 transport = SSETransport(base_url=base_url)

1882 await transport.connect()

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

1884

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

1886

1887 response = await transport.create_sse_response(request)

1888 tasks = BackgroundTasks()

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

1890 response.background = tasks

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

1892 return response

1893 except Exception as e:

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

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

1896

1897

1898@utility_router.post("/message")

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

1900 """

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

1902

1903 Args:

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

1905 user (str): Authenticated user.

1906

1907 Returns:

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

1909

1910 Raises:

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

1912 * **500 Internal Server Error** - An unexpected error occurs while broadcasting the message.

1913 """

1914 try:

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

1916

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

1918 if not session_id: 1918 ↛ 1919line 1918 didn't jump to line 1919 because the condition on line 1918 was never true

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

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

1921

1922 message = await request.json()

1923

1924 await session_registry.broadcast(

1925 session_id=session_id,

1926 message=message,

1927 )

1928

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

1930

1931 except ValueError as e:

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

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

1934 except HTTPException:

1935 raise

1936 except Exception as exc:

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

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

1939

1940

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

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

1943 """

1944 Update the server's log level at runtime.

1945

1946 Args:

1947 request: HTTP request with log level JSON body.

1948 user: Authenticated user.

1949

1950 Returns:

1951 None

1952 """

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

1954 body = await request.json()

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

1956 await logging_service.set_level(level)

1957 return None

1958

1959

1960####################

1961# Metrics #

1962####################

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

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

1965 """

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

1967

1968 Args:

1969 db: Database session

1970 user: Authenticated user

1971

1972 Returns:

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

1974 """

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

1976 tool_metrics = await tool_service.aggregate_metrics(db)

1977 resource_metrics = await resource_service.aggregate_metrics(db)

1978 server_metrics = await server_service.aggregate_metrics(db)

1979 prompt_metrics = await prompt_service.aggregate_metrics(db)

1980 return {

1981 "tools": tool_metrics,

1982 "resources": resource_metrics,

1983 "servers": server_metrics,

1984 "prompts": prompt_metrics,

1985 }

1986

1987

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

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

1990 """

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

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

1993

1994 Args:

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

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

1997 db: Database session

1998 user: Authenticated user

1999

2000 Returns:

2001 A success message in a dictionary.

2002

2003 Raises:

2004 HTTPException: If an invalid entity type is specified.

2005 """

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

2007 if entity is None:

2008 # Global reset

2009 await tool_service.reset_metrics(db)

2010 await resource_service.reset_metrics(db)

2011 await server_service.reset_metrics(db)

2012 await prompt_service.reset_metrics(db)

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

2014 await tool_service.reset_metrics(db, entity_id)

2015 elif entity.lower() == "resource": 2015 ↛ 2016line 2015 didn't jump to line 2016 because the condition on line 2015 was never true

2016 await resource_service.reset_metrics(db)

2017 elif entity.lower() == "server": 2017 ↛ 2018line 2017 didn't jump to line 2018 because the condition on line 2017 was never true

2018 await server_service.reset_metrics(db)

2019 elif entity.lower() == "prompt": 2019 ↛ 2020line 2019 didn't jump to line 2020 because the condition on line 2019 was never true

2020 await prompt_service.reset_metrics(db)

2021 else:

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

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

2024

2025

2026####################

2027# Healthcheck #

2028####################

2029@app.get("/health")

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

2031 """

2032 Perform a basic health check to verify database connectivity.

2033

2034 Args:

2035 db: SQLAlchemy session dependency.

2036

2037 Returns:

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

2039 """

2040 try:

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

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

2043 except Exception as e:

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

2045 logger.error(error_message)

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

2047 return {"status": "healthy"}

2048

2049

2050@app.get("/ready")

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

2052 """

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

2054

2055 Args:

2056 db: SQLAlchemy session dependency.

2057

2058 Returns:

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

2060 """

2061 try:

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

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

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

2065 except Exception as e:

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

2067 logger.error(error_message)

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

2069

2070

2071# Mount static files

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

2073

2074# Include routers

2075 app.include_router(version_router)

2076 app.include_router(protocol_router)

2077 app.include_router(tool_router)

2078 app.include_router(resource_router)

2079 app.include_router(prompt_router)

2080 app.include_router(gateway_router)

2081 app.include_router(root_router)

2082 app.include_router(utility_router)

2083 app.include_router(server_router)

2084 app.include_router(metrics_router)

2085

2086

2087# Feature flags for admin UI and API

2088 UI_ENABLED = settings.mcpgateway_ui_enabled

2089 ADMIN_API_ENABLED = settings.mcpgateway_admin_api_enabled

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

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

2092

2093# Conditional UI and admin API handling

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

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

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

2097 else:

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

2099

2100# Streamable http Mount

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

2102

2103# Conditional static files mounting and root redirect

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

2105 # Mount static files for UI

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

2107 try:

2108 app.mount(

2109 "/static",

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

2111 name="static",

2112 )

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

2114 except RuntimeError as exc:

2115 logger.warning(

2116 "Static dir %s not found - Admin UI disabled (%s)",

2117 settings.static_dir,

2118 exc,

2119 )

2120

2121 # Redirect root path to admin UI

2122 @app.get("/")

2123 async def root_redirect(request: Request):

2124 """

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

2126

2127 Logs a debug message before redirecting.

2128

2129 Args:

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

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

2132

2133 Returns:

2134 RedirectResponse: Redirects to /admin.

2135 """

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

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

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

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

2140

2141 else:

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

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

2144

2145 @app.get("/")

2146 async def root_info():

2147 """

2148 Returns basic API information at the root path.

2149

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

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

2152 admin API are enabled.

2153

2154 Returns:

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

2156 """

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

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

2159

2160

2161# Expose some endpoints at the root level as well

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

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