Coverage for mcpgateway/main.py: 70%
732 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-09 11:03 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-09 11:03 +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"""
28# Standard
29import asyncio
30from contextlib import asynccontextmanager
31import json
32import logging
33from typing import Any, AsyncIterator, Dict, List, Optional, Union
35# Third-Party
36from fastapi import (
37 APIRouter,
38 Body,
39 Depends,
40 FastAPI,
41 HTTPException,
42 Request,
43 status,
44 WebSocket,
45 WebSocketDisconnect,
46)
47from fastapi.background import BackgroundTasks
48from fastapi.middleware.cors import CORSMiddleware
49from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
50from fastapi.staticfiles import StaticFiles
51from fastapi.templating import Jinja2Templates
52import httpx
53from sqlalchemy import text
54from sqlalchemy.orm import Session
55from starlette.middleware.base import BaseHTTPMiddleware
57# First-Party
58from mcpgateway import __version__
59from mcpgateway.admin import admin_router
60from mcpgateway.bootstrap_db import main as bootstrap_db
61from mcpgateway.cache import ResourceCache, SessionRegistry
62from mcpgateway.config import jsonpath_modifier, settings
63from mcpgateway.db import SessionLocal
64from mcpgateway.handlers.sampling import SamplingHandler
65from mcpgateway.models import (
66 InitializeRequest,
67 InitializeResult,
68 ListResourceTemplatesResult,
69 LogLevel,
70 ResourceContent,
71 Root,
72)
73from 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)
91from mcpgateway.services.completion_service import CompletionService
92from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayService
93from mcpgateway.services.logging_service import LoggingService
94from mcpgateway.services.prompt_service import (
95 PromptError,
96 PromptNameConflictError,
97 PromptNotFoundError,
98 PromptService,
99)
100from mcpgateway.services.resource_service import (
101 ResourceError,
102 ResourceNotFoundError,
103 ResourceService,
104 ResourceURIConflictError,
105)
106from mcpgateway.services.root_service import RootService
107from mcpgateway.services.server_service import (
108 ServerError,
109 ServerNameConflictError,
110 ServerNotFoundError,
111 ServerService,
112)
113from mcpgateway.services.tool_service import (
114 ToolError,
115 ToolNameConflictError,
116 ToolService,
117)
118from mcpgateway.transports.sse_transport import SSETransport
119from mcpgateway.transports.streamablehttp_transport import (
120 SessionManagerWrapper,
121 streamable_http_auth,
122)
123from mcpgateway.utils.db_isready import wait_for_db_ready
124from mcpgateway.utils.redis_isready import wait_for_redis_ready
125from mcpgateway.utils.verify_credentials import require_auth, require_auth_override
126from mcpgateway.validation.jsonrpc import (
127 JSONRPCError,
128 validate_request,
129)
131# Import the admin routes from the new module
132from mcpgateway.version import router as version_router
134# Initialize logging service first
135logging_service = LoggingService()
136logger = logging_service.get_logger("mcpgateway")
138# Configure root logger level
139logging.basicConfig(
140 level=getattr(logging, settings.log_level.upper()),
141 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
142)
144# Wait for database to be ready before creating tables
145wait_for_db_ready(max_tries=int(settings.db_max_retries), interval=int(settings.db_retry_interval_ms) / 1000, sync=True) # Converting ms to s
147# Create database tables
148try:
149 loop = asyncio.get_running_loop()
150except RuntimeError:
151 asyncio.run(bootstrap_db())
152else:
153 loop.create_task(bootstrap_db())
156# Initialize services
157tool_service = ToolService()
158resource_service = ResourceService()
159prompt_service = PromptService()
160gateway_service = GatewayService()
161root_service = RootService()
162completion_service = CompletionService()
163sampling_handler = SamplingHandler()
164server_service = ServerService()
166# Initialize session manager for Streamable HTTP transport
167streamable_http_session = SessionManagerWrapper()
169# Wait for redis to be ready
170if 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)
173# Initialize session registry
174session_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)
182# Initialize cache
183resource_cache = ResourceCache(max_size=settings.resource_cache_size, ttl=settings.resource_cache_ttl)
186####################
187# Startup/Shutdown #
188####################
189@asynccontextmanager
190async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
191 """
192 Manage the application's startup and shutdown lifecycle.
194 The function initialises every core service on entry and then
195 shuts them down in reverse order on exit.
197 Args:
198 _app (FastAPI): FastAPI app
200 Yields:
201 None
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()
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")
236# Initialize FastAPI app
237app = 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)
246class DocsAuthMiddleware(BaseHTTPMiddleware):
247 """
248 Middleware to protect FastAPI's auto-generated documentation routes
249 (/docs, /redoc, and /openapi.json) using Bearer token authentication.
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 """
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.
260 Args:
261 request (Request): The incoming HTTP request.
262 call_next (Callable): The function to call the next middleware or endpoint.
264 Returns:
265 Response: Either the standard route response or a 401/403 error response.
266 """
267 protected_paths = ["/docs", "/redoc", "/openapi.json"]
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")
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)
279 # Proceed to next middleware or route
280 return await call_next(request)
283class MCPPathRewriteMiddleware:
284 """
285 Supports requests like '/servers/<server_id>/mcp' by rewriting the path to '/mcp'.
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 """
293 def __init__(self, app):
294 """
295 Initialize the middleware with the ASGI application.
297 Args:
298 app (Callable): The next ASGI application in the middleware stack.
299 """
300 self.app = app
302 async def __call__(self, scope, receive, send):
303 """
304 Intercept and potentially rewrite the incoming HTTP request path.
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
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
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)
331# Configure CORS
332app.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)
342# Add custom DocsAuthMiddleware
343app.add_middleware(DocsAuthMiddleware)
345# Add streamable HTTP middleware for /mcp routes
346app.add_middleware(MCPPathRewriteMiddleware)
349# Set up Jinja2 templates and store in app state for later use
350templates = Jinja2Templates(directory=str(settings.templates_dir))
351app.state.templates = templates
353# Create API routers
354protocol_router = APIRouter(prefix="/protocol", tags=["Protocol"])
355tool_router = APIRouter(prefix="/tools", tags=["Tools"])
356resource_router = APIRouter(prefix="/resources", tags=["Resources"])
357prompt_router = APIRouter(prefix="/prompts", tags=["Prompts"])
358gateway_router = APIRouter(prefix="/gateways", tags=["Gateways"])
359root_router = APIRouter(prefix="/roots", tags=["Roots"])
360utility_router = APIRouter(tags=["Utilities"])
361server_router = APIRouter(prefix="/servers", tags=["Servers"])
362metrics_router = APIRouter(prefix="/metrics", tags=["Metrics"])
364# Basic Auth setup
367# Database dependency
368def get_db():
369 """
370 Dependency function to provide a database session.
372 Yields:
373 Session: A SQLAlchemy session object for interacting with the database.
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()
385def require_api_key(api_key: str) -> None:
386 """
387 Validates the provided API key.
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.
393 Args:
394 api_key (str): The API key provided by the user or client.
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")
405async def invalidate_resource_cache(uri: Optional[str] = None) -> None:
406 """
407 Invalidates the resource cache.
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.
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()
421#################
422# Protocol APIs #
423#################
424@protocol_router.post("/initialize")
425async def initialize(request: Request, user: str = Depends(require_auth)) -> InitializeResult:
426 """
427 Initialize a protocol.
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.
433 Args:
434 request (Request): The incoming request object containing the JSON body.
435 user (str): The authenticated user (from `require_auth` dependency).
437 Returns:
438 InitializeResult: The result of the initialization process.
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()
446 logger.debug(f"Authenticated user {user} is initializing the protocol.")
447 return await session_registry.handle_initialize_logic(body)
449 except json.JSONDecodeError:
450 raise HTTPException(
451 status_code=status.HTTP_400_BAD_REQUEST,
452 detail="Invalid JSON in request body",
453 )
456@protocol_router.post("/ping")
457async def ping(request: Request, user: str = Depends(require_auth)) -> JSONResponse:
458 """
459 Handle a ping request according to the MCP specification.
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.
464 Args:
465 request (Request): The incoming FastAPI request.
466 user (str): The authenticated user (dependency injection).
468 Returns:
469 JSONResponse: A JSON-RPC response with an empty result or an error response.
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)
492@protocol_router.post("/notifications")
493async 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).
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 )
520@protocol_router.post("/completion/complete")
521async 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.
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.
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)
538@protocol_router.post("/sampling/createMessage")
539async 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.
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.
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)
556###############
557# Server APIs #
558###############
559@server_router.get("", response_model=List[ServerRead])
560@server_router.get("/", response_model=List[ServerRead])
561async 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.
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.
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)
581@server_router.get("/{server_id}", response_model=ServerRead)
582async 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.
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.
591 Returns:
592 ServerRead: The server object with the specified ID.
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))
604@server_router.post("", response_model=ServerRead, status_code=201)
605@server_router.post("/", response_model=ServerRead, status_code=201)
606async 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.
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.
619 Returns:
620 ServerRead: The created server object.
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))
634@server_router.put("/{server_id}", response_model=ServerRead)
635async 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.
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.
650 Returns:
651 ServerRead: The updated server object.
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))
667@server_router.post("/{server_id}/toggle", response_model=ServerRead)
668async 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).
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.
683 Returns:
684 ServerRead: The server object after the status change.
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))
698@server_router.delete("/{server_id}", response_model=Dict[str, str])
699async 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.
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.
708 Returns:
709 Dict[str, str]: A success message indicating the server was deleted.
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))
727@server_router.get("/{server_id}/sse")
728async 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.
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.
737 Returns:
738 The SSE response object for the established connection.
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)
752 asyncio.create_task(session_registry.respond(server_id, user, session_id=transport.session_id, base_url=base_url))
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")
764@server_router.post("/{server_id}/message")
765async def message_endpoint(request: Request, server_id: str, user: str = Depends(require_auth)):
766 """
767 Handles incoming messages for a specific server.
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.
774 Returns:
775 JSONResponse: A success status after processing the message.
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")
787 message = await request.json()
789 await session_registry.broadcast(
790 session_id=session_id,
791 message=message,
792 )
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")
805@server_router.get("/{server_id}/tools", response_model=List[ToolRead])
806async 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.
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.
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.
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]
833@server_router.get("/{server_id}/resources", response_model=List[ResourceRead])
834async 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.
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.
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.
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]
861@server_router.get("/{server_id}/prompts", response_model=List[PromptRead])
862async 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.
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.
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.
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]
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])
894async 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.
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
910 Returns:
911 List of tools or modified result based on jsonpath
912 """
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)
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
920 tools_dict_list = [tool.to_dict(use_alias=True) for tool in data]
922 return jsonpath_modifier(tools_dict_list, apijsonpath.jsonpath, apijsonpath.mapping)
925@tool_router.post("", response_model=ToolRead)
926@tool_router.post("/", response_model=ToolRead)
927async 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.
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.
936 Returns:
937 ToolRead: The created tool data.
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))
956@tool_router.get("/{tool_id}", response_model=Union[ToolRead, Dict])
957async 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.
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.
972 Returns:
973 The raw ``ToolRead`` model **or** a JSON-transformed ``dict`` if
974 a JSONPath filter/mapping was supplied.
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
985 data_dict = data.to_dict(use_alias=True)
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))
992@tool_router.put("/{tool_id}", response_model=ToolRead)
993async 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.
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.
1008 Returns:
1009 ToolRead: The updated tool data.
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))
1021@tool_router.delete("/{tool_id}")
1022async 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.
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.
1031 Returns:
1032 Dict[str, str]: A confirmation message upon successful deletion.
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))
1045@tool_router.post("/{tool_id}/toggle")
1046async 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.
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.
1061 Returns:
1062 Dict[str, Any]: The status, message, and updated tool data.
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))
1079#################
1080# Resource APIs #
1081#################
1082# --- Resource templates endpoint - MUST come before variable paths ---
1083@resource_router.get("/templates/list", response_model=ListResourceTemplatesResult)
1084async 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.
1091 Args:
1092 db (Session): Database session.
1093 user (str): Authenticated user.
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
1104@resource_router.post("/{resource_id}/toggle")
1105async 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.
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.
1120 Returns:
1121 Dict[str, Any]: Status message and updated resource data.
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))
1138@resource_router.get("", response_model=List[ResourceRead])
1139@resource_router.get("/", response_model=List[ResourceRead])
1140async 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.
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.
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
1167@resource_router.post("", response_model=ResourceRead)
1168@resource_router.post("/", response_model=ResourceRead)
1169async 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.
1177 Args:
1178 resource (ResourceCreate): Data for the new resource.
1179 db (Session): Database session.
1180 user (str): Authenticated user.
1182 Returns:
1183 ResourceRead: The created resource.
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))
1198@resource_router.get("/{uri:path}")
1199async def read_resource(uri: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ResourceContent:
1200 """
1201 Read a resource by its URI.
1203 Args:
1204 uri (str): URI of the resource.
1205 db (Session): Database session.
1206 user (str): Authenticated user.
1208 Returns:
1209 ResourceContent: The content of the resource.
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
1226@resource_router.put("/{uri:path}", response_model=ResourceRead)
1227async 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.
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.
1242 Returns:
1243 ResourceRead: The updated resource.
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
1257@resource_router.delete("/{uri:path}")
1258async 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.
1262 Args:
1263 uri (str): URI of the resource to delete.
1264 db (Session): Database session.
1265 user (str): Authenticated user.
1267 Returns:
1268 Dict[str, str]: Status message indicating deletion success.
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))
1284@resource_router.post("/subscribe/{uri:path}")
1285async def subscribe_resource(uri: str, user: str = Depends(require_auth)) -> StreamingResponse:
1286 """
1287 Subscribe to server-sent events (SSE) for a specific resource.
1289 Args:
1290 uri (str): URI of the resource to subscribe to.
1291 user (str): Authenticated user.
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")
1300###############
1301# Prompt APIs #
1302###############
1303@prompt_router.post("/{prompt_id}/toggle")
1304async 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.
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.
1319 Returns:
1320 Status message and updated prompt details.
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))
1337@prompt_router.get("", response_model=List[PromptRead])
1338@prompt_router.get("/", response_model=List[PromptRead])
1339async 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.
1348 Args:
1349 cursor: Cursor for pagination.
1350 include_inactive: Include inactive prompts.
1351 db: Database session.
1352 user: Authenticated user.
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)
1361@prompt_router.post("", response_model=PromptRead)
1362@prompt_router.post("/", response_model=PromptRead)
1363async 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.
1371 Args:
1372 prompt (PromptCreate): Payload describing the prompt to create.
1373 db (Session): Active SQLAlchemy session.
1374 user (str): Authenticated username.
1376 Returns:
1377 PromptRead: The newly-created prompt.
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))
1393@prompt_router.post("/{name}")
1394async 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.
1402 This implements the prompts/get functionality from the MCP spec,
1403 which requires a POST request with arguments in the body.
1406 Args:
1407 name: Name of the prompt.
1408 args: Template arguments.
1409 db: Database session.
1410 user: Authenticated user.
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)
1419@prompt_router.get("/{name}")
1420async 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.
1427 This endpoint is for convenience when no arguments are needed.
1429 Args:
1430 name: The name of the prompt to retrieve
1431 db: Database session
1432 user: Authenticated user
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, {})
1441@prompt_router.put("/{name}", response_model=PromptRead)
1442async 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.
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.
1457 Returns:
1458 PromptRead: The updated prompt object.
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))
1473@prompt_router.delete("/{name}")
1474async 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.
1478 Args:
1479 name: Name of the prompt.
1480 db: Database session.
1481 user: Authenticated user.
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)}
1496################
1497# Gateway APIs #
1498################
1499@gateway_router.post("/{gateway_id}/toggle")
1500async 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.
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.
1515 Returns:
1516 Dict[str, Any]: A dict containing the operation status, a message, and the updated gateway object.
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))
1537@gateway_router.get("", response_model=List[GatewayRead])
1538@gateway_router.get("/", response_model=List[GatewayRead])
1539async 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.
1547 Args:
1548 include_inactive: Include inactive gateways.
1549 db: Database session.
1550 user: Authenticated user.
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)
1559@gateway_router.post("", response_model=GatewayRead)
1560@gateway_router.post("/", response_model=GatewayRead)
1561async 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.
1569 Args:
1570 gateway: Gateway creation data.
1571 db: Database session.
1572 user: Authenticated user.
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)
1590@gateway_router.get("/{gateway_id}", response_model=GatewayRead)
1591async def get_gateway(gateway_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> GatewayRead:
1592 """
1593 Retrieve a gateway by ID.
1595 Args:
1596 gateway_id: ID of the gateway.
1597 db: Database session.
1598 user: Authenticated user.
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)
1607@gateway_router.put("/{gateway_id}", response_model=GatewayRead)
1608async 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.
1617 Args:
1618 gateway_id: Gateway ID.
1619 gateway: Gateway update data.
1620 db: Database session.
1621 user: Authenticated user.
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)
1630@gateway_router.delete("/{gateway_id}")
1631async 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.
1635 Args:
1636 gateway_id: ID of the gateway.
1637 db: Database session.
1638 user: Authenticated user.
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"}
1648##############
1649# Root APIs #
1650##############
1651@root_router.get("", response_model=List[Root])
1652@root_router.get("/", response_model=List[Root])
1653async def list_roots(
1654 user: str = Depends(require_auth),
1655) -> List[Root]:
1656 """
1657 Retrieve a list of all registered roots.
1659 Args:
1660 user: Authenticated user.
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()
1669@root_router.post("", response_model=Root)
1670@root_router.post("/", response_model=Root)
1671async 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.
1678 Args:
1679 root: Root object containing URI and name.
1680 user: Authenticated user.
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)
1689@root_router.delete("/{uri:path}")
1690async def remove_root(
1691 uri: str,
1692 user: str = Depends(require_auth),
1693) -> Dict[str, str]:
1694 """
1695 Remove a registered root by URI.
1697 Args:
1698 uri: URI of the root to remove.
1699 user: Authenticated user.
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"}
1709@root_router.get("/changes")
1710async 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).
1716 Args:
1717 user: Authenticated user.
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")
1726##################
1727# Utility Routes #
1728##################
1729@utility_router.post("/rpc/")
1730@utility_router.post("/rpc")
1731async def handle_rpc(request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)): # revert this back
1732 """Handle RPC requests.
1734 Args:
1735 request (Request): The incoming FastAPI request.
1736 db (Session): Database session.
1737 user (str): The authenticated user.
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
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)
1799 response = result
1800 return response
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 }
1813@utility_router.websocket("/ws")
1814async def websocket_endpoint(websocket: WebSocket):
1815 """
1816 Handle WebSocket connection to relay JSON-RPC requests to the internal RPC endpoint.
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.
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}")
1862@utility_router.get("/sse")
1863async def utility_sse_endpoint(request: Request, user: str = Depends(require_auth)):
1864 """
1865 Establish a Server-Sent Events (SSE) connection for real-time updates.
1867 Args:
1868 request (Request): The incoming HTTP request.
1869 user (str): Authenticated username.
1871 Returns:
1872 StreamingResponse: A streaming response that keeps the connection
1873 open and pushes events to the client.
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)
1885 asyncio.create_task(session_registry.respond(None, user, session_id=transport.session_id, base_url=base_url))
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")
1898@utility_router.post("/message")
1899async def utility_message_endpoint(request: Request, user: str = Depends(require_auth)):
1900 """
1901 Handle a JSON-RPC message directed to a specific SSE session.
1903 Args:
1904 request (Request): Incoming request containing the JSON-RPC payload.
1905 user (str): Authenticated user.
1907 Returns:
1908 JSONResponse: ``{"status": "success"}`` with HTTP 202 on success.
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)
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")
1922 message = await request.json()
1924 await session_registry.broadcast(
1925 session_id=session_id,
1926 message=message,
1927 )
1929 return JSONResponse(content={"status": "success"}, status_code=202)
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")
1941@utility_router.post("/logging/setLevel")
1942async def set_log_level(request: Request, user: str = Depends(require_auth)) -> None:
1943 """
1944 Update the server's log level at runtime.
1946 Args:
1947 request: HTTP request with log level JSON body.
1948 user: Authenticated user.
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
1960####################
1961# Metrics #
1962####################
1963@metrics_router.get("", response_model=dict)
1964async 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).
1968 Args:
1969 db: Database session
1970 user: Authenticated user
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 }
1988@metrics_router.post("/reset", response_model=dict)
1989async 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.
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
2000 Returns:
2001 A success message in a dictionary.
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'}"}
2026####################
2027# Healthcheck #
2028####################
2029@app.get("/health")
2030async def healthcheck(db: Session = Depends(get_db)):
2031 """
2032 Perform a basic health check to verify database connectivity.
2034 Args:
2035 db: SQLAlchemy session dependency.
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"}
2050@app.get("/ready")
2051async def readiness_check(db: Session = Depends(get_db)):
2052 """
2053 Perform a readiness check to verify if the application is ready to receive traffic.
2055 Args:
2056 db: SQLAlchemy session dependency.
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)
2071# Mount static files
2072# app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static")
2074# Include routers
2075app.include_router(version_router)
2076app.include_router(protocol_router)
2077app.include_router(tool_router)
2078app.include_router(resource_router)
2079app.include_router(prompt_router)
2080app.include_router(gateway_router)
2081app.include_router(root_router)
2082app.include_router(utility_router)
2083app.include_router(server_router)
2084app.include_router(metrics_router)
2087# Feature flags for admin UI and API
2088UI_ENABLED = settings.mcpgateway_ui_enabled
2089ADMIN_API_ENABLED = settings.mcpgateway_admin_api_enabled
2090logger.info(f"Admin UI enabled: {UI_ENABLED}")
2091logger.info(f"Admin API enabled: {ADMIN_API_ENABLED}")
2093# Conditional UI and admin API handling
2094if 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
2097else:
2098 logger.warning("Admin API routes not mounted - Admin API disabled via MCPGATEWAY_ADMIN_API_ENABLED=False")
2100# Streamable http Mount
2101app.mount("/mcp", app=streamable_http_session.handle_streamable_http)
2103# Conditional static files mounting and root redirect
2104if 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 )
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".
2127 Logs a debug message before redirecting.
2129 Args:
2130 request (Request): The incoming HTTP request (used only to build the
2131 target URL via :pymeth:`starlette.requests.Request.url_for`).
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"))
2141else:
2142 # If UI is disabled, provide API info at root
2143 logger.warning("Static files not mounted - UI disabled via MCPGATEWAY_UI_ENABLED=False")
2145 @app.get("/")
2146 async def root_info():
2147 """
2148 Returns basic API information at the root path.
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.
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}
2161# Expose some endpoints at the root level as well
2162app.post("/initialize")(initialize)
2163app.post("/notifications")(handle_notification)