Coverage for mcpgateway/main.py: 41%
722 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-22 12:53 +0100
« 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
7MCP Gateway - Main FastAPI Application.
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.
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.
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"""
28import asyncio
29import json
30import logging
31from contextlib import asynccontextmanager
32from typing import Any, AsyncIterator, Dict, List, Optional, Union
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
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)
125# Import the admin routes from the new module
126from mcpgateway.version import router as version_router
128# Initialize logging service first
129logging_service = LoggingService()
130logger = logging_service.get_logger("mcpgateway")
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)
138# Create database tables
139Base.metadata.create_all(bind=engine)
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()
151# Initialize session manager for Streamable HTTP transport
152streamable_http_session = SessionManagerWrapper()
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)
164# Initialize cache
165resource_cache = ResourceCache(max_size=settings.resource_cache_size, ttl=settings.resource_cache_ttl)
168####################
169# Startup/Shutdown #
170####################
171@asynccontextmanager
172async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
173 """
174 Manage the application's startup and shutdown lifecycle.
176 The function initialises every core service on entry and then
177 shuts them down in reverse order on exit.
179 Args:
180 _app (FastAPI): FastAPI app
182 Yields:
183 None
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()
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")
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)
228class DocsAuthMiddleware(BaseHTTPMiddleware):
229 """
230 Middleware to protect FastAPI's auto-generated documentation routes
231 (/docs, /redoc, and /openapi.json) using Bearer token authentication.
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 """
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.
242 Args:
243 request (Request): The incoming HTTP request.
244 call_next (Callable): The function to call the next middleware or endpoint.
246 Returns:
247 Response: Either the standard route response or a 401/403 error response.
248 """
249 protected_paths = ["/docs", "/redoc", "/openapi.json"]
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")
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)
261 # Proceed to next middleware or route
262 return await call_next(request)
265class MCPPathRewriteMiddleware:
266 """
267 Supports requests like '/servers/<server_id>/mcp' by rewriting the path to '/mcp'.
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 """
275 def __init__(self, app):
276 """
277 Initialize the middleware with the ASGI application.
279 Args:
280 app (Callable): The next ASGI application in the middleware stack.
281 """
282 self.app = app
284 async def __call__(self, scope, receive, send):
285 """
286 Intercept and potentially rewrite the incoming HTTP request path.
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
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
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)
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)
324# Add custom DocsAuthMiddleware
325app.add_middleware(DocsAuthMiddleware)
327# Add streamable HTTP middleware for /mcp routes
328app.add_middleware(MCPPathRewriteMiddleware)
331# Set up Jinja2 templates and store in app state for later use
332templates = Jinja2Templates(directory=str(settings.templates_dir))
333app.state.templates = templates
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"])
346# Basic Auth setup
349# Database dependency
350def get_db():
351 """
352 Dependency function to provide a database session.
354 Yields:
355 Session: A SQLAlchemy session object for interacting with the database.
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()
367def require_api_key(api_key: str) -> None:
368 """
369 Validates the provided API key.
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.
375 Args:
376 api_key (str): The API key provided by the user or client.
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")
387async def invalidate_resource_cache(uri: Optional[str] = None) -> None:
388 """
389 Invalidates the resource cache.
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.
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()
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.
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.
415 Args:
416 request (Request): The incoming request object containing the JSON body.
417 user (str): The authenticated user (from `require_auth` dependency).
419 Returns:
420 InitializeResult: The result of the initialization process.
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()
428 logger.debug(f"Authenticated user {user} is initializing the protocol.")
429 return await session_registry.handle_initialize_logic(body)
431 except json.JSONDecodeError:
432 raise HTTPException(
433 status_code=status.HTTP_400_BAD_REQUEST,
434 detail="Invalid JSON in request body",
435 )
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.
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.
446 Args:
447 request (Request): The incoming FastAPI request.
448 user (str): The authenticated user (dependency injection).
450 Returns:
451 JSONResponse: A JSON-RPC response with an empty result or an error response.
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)
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).
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 )
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.
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.
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)
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.
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.
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)
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.
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.
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)
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.
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.
573 Returns:
574 ServerRead: The server object with the specified ID.
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))
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.
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.
601 Returns:
602 ServerRead: The created server object.
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))
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.
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.
632 Returns:
633 ServerRead: The updated server object.
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))
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).
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.
665 Returns:
666 ServerRead: The server object after the status change.
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))
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.
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.
690 Returns:
691 Dict[str, str]: A success message indicating the server was deleted.
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))
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.
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.
719 Returns:
720 The SSE response object for the established connection.
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)
734 asyncio.create_task(session_registry.respond(server_id, user, session_id=transport.session_id, base_url=base_url))
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")
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.
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.
756 Returns:
757 JSONResponse: A success status after processing the message.
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")
769 message = await request.json()
771 await session_registry.broadcast(
772 session_id=session_id,
773 message=message,
774 )
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")
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.
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.
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.
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]
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.
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.
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.
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]
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.
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.
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.
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]
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.
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
892 Returns:
893 List of tools or modified result based on jsonpath
894 """
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)
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
902 tools_dict_list = [tool.to_dict(use_alias=True) for tool in data]
904 return jsonpath_modifier(tools_dict_list, apijsonpath.jsonpath, apijsonpath.mapping)
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.
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.
918 Returns:
919 ToolRead: The created tool data.
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))
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.
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.
954 Returns:
955 The raw ``ToolRead`` model **or** a JSON-transformed ``dict`` if
956 a JSONPath filter/mapping was supplied.
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
967 data_dict = data.to_dict(use_alias=True)
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))
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.
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.
990 Returns:
991 ToolRead: The updated tool data.
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))
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.
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.
1013 Returns:
1014 Dict[str, str]: A confirmation message upon successful deletion.
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))
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.
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.
1043 Returns:
1044 Dict[str, Any]: The status, message, and updated tool data.
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))
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.
1073 Args:
1074 db (Session): Database session.
1075 user (str): Authenticated user.
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
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.
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.
1102 Returns:
1103 Dict[str, Any]: Status message and updated resource data.
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))
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.
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.
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
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.
1159 Args:
1160 resource (ResourceCreate): Data for the new resource.
1161 db (Session): Database session.
1162 user (str): Authenticated user.
1164 Returns:
1165 ResourceRead: The created resource.
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))
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.
1185 Args:
1186 uri (str): URI of the resource.
1187 db (Session): Database session.
1188 user (str): Authenticated user.
1190 Returns:
1191 ResourceContent: The content of the resource.
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
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.
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.
1224 Returns:
1225 ResourceRead: The updated resource.
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
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.
1244 Args:
1245 uri (str): URI of the resource to delete.
1246 db (Session): Database session.
1247 user (str): Authenticated user.
1249 Returns:
1250 Dict[str, str]: Status message indicating deletion success.
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))
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.
1271 Args:
1272 uri (str): URI of the resource to subscribe to.
1273 user (str): Authenticated user.
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")
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.
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.
1301 Returns:
1302 Status message and updated prompt details.
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))
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.
1330 Args:
1331 cursor: Cursor for pagination.
1332 include_inactive: Include inactive prompts.
1333 db: Database session.
1334 user: Authenticated user.
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)
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.
1353 Args:
1354 prompt (PromptCreate): Payload describing the prompt to create.
1355 db (Session): Active SQLAlchemy session.
1356 user (str): Authenticated username.
1358 Returns:
1359 PromptRead: The newly–created prompt.
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))
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.
1384 This implements the prompts/get functionality from the MCP spec,
1385 which requires a POST request with arguments in the body.
1388 Args:
1389 name: Name of the prompt.
1390 args: Template arguments.
1391 db: Database session.
1392 user: Authenticated user.
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)
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.
1409 This endpoint is for convenience when no arguments are needed.
1411 Args:
1412 name: The name of the prompt to retrieve
1413 db: Database session
1414 user: Authenticated user
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, {})
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.
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.
1439 Returns:
1440 PromptRead: The updated prompt object.
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))
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.
1460 Args:
1461 name: Name of the prompt.
1462 db: Database session.
1463 user: Authenticated user.
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)}
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.
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.
1497 Returns:
1498 Dict[str, Any]: A dict containing the operation status, a message, and the updated gateway object.
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))
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.
1529 Args:
1530 include_inactive: Include inactive gateways.
1531 db: Database session.
1532 user: Authenticated user.
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)
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.
1551 Args:
1552 gateway: Gateway creation data.
1553 db: Database session.
1554 user: Authenticated user.
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)
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.
1578 Args:
1579 gateway_id: ID of the gateway.
1580 db: Database session.
1581 user: Authenticated user.
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)
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.
1600 Args:
1601 gateway_id: Gateway ID.
1602 gateway: Gateway update data.
1603 db: Database session.
1604 user: Authenticated user.
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)
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.
1618 Args:
1619 gateway_id: ID of the gateway.
1620 db: Database session.
1621 user: Authenticated user.
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"}
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.
1642 Args:
1643 user: Authenticated user.
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()
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.
1661 Args:
1662 root: Root object containing URI and name.
1663 user: Authenticated user.
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)
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.
1680 Args:
1681 uri: URI of the root to remove.
1682 user: Authenticated user.
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"}
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).
1699 Args:
1700 user: Authenticated user.
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")
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.
1717 Args:
1718 request (Request): The incoming FastAPI request.
1719 db (Session): Database session.
1720 user (str): The authenticated user.
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
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)
1782 response = result
1783 return response
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 }
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.
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.
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}")
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.
1850 Args:
1851 request (Request): The incoming HTTP request.
1852 user (str): Authenticated username.
1854 Returns:
1855 StreamingResponse: A streaming response that keeps the connection
1856 open and pushes events to the client.
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)
1868 asyncio.create_task(session_registry.respond(None, user, session_id=transport.session_id, base_url=base_url))
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")
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.
1886 Args:
1887 request (Request): Incoming request containing the JSON-RPC payload.
1888 user (str): Authenticated user.
1890 Returns:
1891 JSONResponse: ``{"status": "success"}`` with HTTP 202 on success.
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)
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")
1905 message = await request.json()
1907 await session_registry.broadcast(
1908 session_id=session_id,
1909 message=message,
1910 )
1912 return JSONResponse(content={"status": "success"}, status_code=202)
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")
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.
1929 Args:
1930 request: HTTP request with log level JSON body.
1931 user: Authenticated user.
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
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).
1951 Args:
1952 db: Database session
1953 user: Authenticated user
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 }
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.
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
1983 Returns:
1984 A success message in a dictionary.
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'}"}
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.
2017 Args:
2018 db: SQLAlchemy session dependency.
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"}
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.
2038 Args:
2039 db: SQLAlchemy session dependency.
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)
2054# Mount static files
2055# app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static")
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)
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}")
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")
2083# Streamable http Mount
2084app.mount("/mcp", app=streamable_http_session.handle_streamable_http)
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 )
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".
2110 Logs a debug message before redirecting.
2112 Args:
2113 request (Request): The incoming HTTP request (used only to build the
2114 target URL via :pymeth:`starlette.requests.Request.url_for`).
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"))
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")
2128 @app.get("/")
2129 async def root_info():
2130 """
2131 Returns basic API information at the root path.
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.
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}
2144# Expose some endpoints at the root level as well
2145app.post("/initialize")(initialize)
2146app.post("/notifications")(handle_notification)