Skip to main content
Glama
main.py13.5 kB
"""FastAPI application with MCP server integration. Provides REST API and MCP tool endpoints for Hostaway property management. """ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import UTC from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware from src.mcp.auth import TokenManager from src.mcp.config import HostawayConfig from src.mcp.logging import ( clear_correlation_id, get_logger, set_correlation_id, setup_logging, ) from src.mcp.server import initialize_mcp from src.services.hostaway_client import HostawayClient from src.services.rate_limiter import RateLimiter # Try to import test mode check, skip if testing module not available (production) try: from src.testing.hostaway_mocks import is_test_mode except (ImportError, ModuleNotFoundError): def is_test_mode(): return False # Always False in production logger = get_logger(__name__) # Global instances (initialized in lifespan) config: HostawayConfig token_manager: TokenManager rate_limiter: RateLimiter hostaway_client: HostawayClient @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Manage application lifecycle: startup and shutdown. Initializes global services on startup and properly cleans up resources on shutdown. """ # Startup: Initialize services global config, token_manager, rate_limiter, hostaway_client config = HostawayConfig() # type: ignore[call-arg] # Setup structured logging setup_logging( level=config.log_level if hasattr(config, "log_level") else "INFO", json_format=True, ) logger.info("Starting Hostaway MCP Server", extra={"version": "0.1.0"}) rate_limiter = RateLimiter( ip_rate_limit=config.rate_limit_ip, account_rate_limit=config.rate_limit_account, max_concurrent=config.max_concurrent_requests, ) token_manager = TokenManager(config=config) # Use mock client in test mode, real client otherwise if is_test_mode(): from src.testing.mock_client import MockHostawayClient logger.info("TEST MODE: Using MockHostawayClient with deterministic data") hostaway_client = MockHostawayClient( config=config, token_manager=token_manager, rate_limiter=rate_limiter, ) else: hostaway_client = HostawayClient( config=config, token_manager=token_manager, rate_limiter=rate_limiter, ) logger.info("Hostaway MCP Server started successfully") yield # Shutdown: Cleanup resources logger.info("Shutting down Hostaway MCP Server") await hostaway_client.aclose() await token_manager.aclose() logger.info("Hostaway MCP Server shut down complete") # Create FastAPI app app = FastAPI( title="Hostaway MCP Server", description="MCP server for Hostaway property management API integration", version="0.1.0", lifespan=lifespan, ) # Custom 404 exception handler (Issue #008-US1: Return 404 for non-existent routes) # This must be registered BEFORE middleware to handle route matching failures # before authentication is checked @app.exception_handler(404) async def custom_404_handler(request: Request, exc: Exception) -> dict: """Return 404 for non-existent routes without authentication check. This handler ensures that non-existent routes return HTTP 404 instead of 401, improving API consumer developer experience. Route existence should be checked BEFORE authentication. Args: request: Incoming HTTP request exc: Exception that triggered the handler (StarletteHTTPException) Returns: JSON response with 404 status and route path in error message """ from fastapi.responses import JSONResponse # Get correlation ID from request state (set by CorrelationIdMiddleware) correlation_id = getattr(request.state, "correlation_id", "unknown") # Build error response with route path response_content = { "detail": f"Route '{request.url.path}' not found", "path": request.url.path, "method": request.method, } # Create JSON response with 404 status response = JSONResponse( status_code=404, content=response_content, ) # Preserve correlation ID header for request tracing response.headers["X-Correlation-ID"] = correlation_id return response # Add CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], # Configure appropriately for production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Correlation ID middleware for request tracing class CorrelationIdMiddleware(BaseHTTPMiddleware): """Middleware to handle correlation IDs for request tracing.""" async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] """Extract or generate correlation ID and add to context. Args: request: Incoming HTTP request call_next: Next middleware/handler in chain Returns: HTTP response with correlation ID header """ # Get correlation ID from header or generate new one correlation_id = request.headers.get("X-Correlation-ID") if not correlation_id: import uuid correlation_id = str(uuid.uuid4()) # Set correlation ID in context for logging set_correlation_id(correlation_id) # Process request response = await call_next(request) # Add correlation ID to response headers response.headers["X-Correlation-ID"] = correlation_id # Clear correlation ID from context clear_correlation_id() return response app.add_middleware(CorrelationIdMiddleware) # Error Recovery Middleware for graceful degradation class ErrorRecoveryMiddleware(BaseHTTPMiddleware): """Middleware for graceful error recovery and partial success handling. Catches unhandled exceptions and returns structured error responses, logging the full context for debugging while preventing sensitive data leakage. """ async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] """Handle request errors gracefully. Args: request: Incoming HTTP request call_next: Next middleware/handler in chain Returns: HTTP response (normal or error response) """ try: response = await call_next(request) return response except Exception as e: # Log the error with full context for debugging logger.error( "Unhandled error in request", extra={ "path": request.url.path, "method": request.method, "error": str(e), "error_type": type(e).__name__, "query_params": dict(request.query_params), }, exc_info=True, # Include stack trace in logs ) # Return 500 with sanitized error details from fastapi.responses import JSONResponse return JSONResponse( content={ "detail": "Internal server error - operation may have partially completed", "error_type": type(e).__name__, "path": request.url.path, }, status_code=500, ) app.add_middleware(ErrorRecoveryMiddleware) # Rate Limiter Middleware (Issue #008-US2: Add rate limit visibility headers) from src.api.middleware.rate_limiter import RateLimiterMiddleware app.add_middleware(RateLimiterMiddleware, ip_limit=15, time_window=10) # MCP API Key Authentication Middleware class MCPAuthMiddleware(BaseHTTPMiddleware): """Middleware to enforce API key authentication for MCP endpoints. ISSUE #008-US1 FIX: This middleware now checks route existence BEFORE enforcing authentication, allowing 404 errors to be returned for non-existent routes instead of 401. """ def _route_exists(self, request: Request) -> bool: """Check if a route exists in FastAPI's router. Args: request: Incoming HTTP request Returns: True if route exists, False otherwise """ # Access FastAPI's router to check if route exists for route in request.app.routes: # Check if this route matches the request path and method match, _ = route.matches(request.scope) if match.name in ("full", "partial"): return True return False async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] """Check API key for MCP endpoint access. Args: request: Incoming HTTP request call_next: Next middleware/handler in chain Returns: HTTP response or 401 if authentication fails """ # Protect both MCP and API endpoints that need authentication if request.url.path.startswith("/mcp") or request.url.path.startswith("/api/"): # ISSUE #008-US1 FIX: Check if route exists BEFORE enforcing authentication # This prevents 401 responses for non-existent routes if not self._route_exists(request): # Route doesn't exist - let it pass through to get proper 404 response response = await call_next(request) return response # Route exists - enforce authentication from fastapi import HTTPException from src.mcp.security import verify_api_key # Extract API key from header manually since we're not using dependency injection x_api_key = request.headers.get("X-API-Key") try: # Call with extracted header value await verify_api_key(request, x_api_key) except HTTPException as e: # Authentication failed - verify_api_key raised HTTP exception from fastapi.responses import JSONResponse return JSONResponse( status_code=e.status_code, content={"detail": e.detail}, headers=e.headers or {}, ) # Authentication passed - continue with request response = await call_next(request) return response # Path doesn't require authentication - continue with request response = await call_next(request) return response from src.api.middleware.usage_tracking import UsageTrackingMiddleware app.add_middleware(UsageTrackingMiddleware) # Token-Aware Middleware for response optimization (prevents context window overflow) from src.api.middleware.token_aware_middleware import TokenAwareMiddleware app.add_middleware(TokenAwareMiddleware) app.add_middleware(MCPAuthMiddleware) # Usage Tracking Middleware (T047: Track API usage for billing/metrics) @app.get("/health") async def health_check() -> dict: """Health check endpoint for monitoring and deployment verification. Returns: Status message with timestamp, version, and context protection metrics """ from datetime import datetime from src.services.telemetry_service import get_telemetry_service telemetry = get_telemetry_service() metrics = telemetry.get_metrics() return { "status": "healthy", "timestamp": datetime.now(UTC).isoformat(), "version": "0.1.0", "service": "hostaway-mcp", "context_protection": { "total_requests": metrics["total_requests"], "pagination_adoption": metrics["pagination_adoption"], "summarization_adoption": metrics["summarization_adoption"], "avg_response_size_bytes": metrics["avg_response_size"], "avg_latency_ms": metrics["avg_latency_ms"], "oversized_events": metrics["oversized_events"], "uptime_seconds": metrics["uptime_seconds"], }, } @app.get("/") async def root() -> dict[str, str]: """Root endpoint with service information. Returns: Service metadata """ return { "service": "Hostaway MCP Server", "version": "0.1.0", "mcp_endpoint": "/mcp", "docs": "/docs", } # Register authentication routes BEFORE MCP initialization # (so they're automatically exposed as MCP tools) from src.api.routes import auth app.include_router(auth.router, prefix="/auth", tags=["Authentication"]) # Register listings routes from src.api.routes import listings app.include_router(listings.router, prefix="/api", tags=["Listings"]) # Register bookings routes from src.api.routes import bookings app.include_router(bookings.router, prefix="/api", tags=["Bookings"]) # Register financial routes from src.api.routes import financial app.include_router(financial.router, prefix="/api", tags=["Financial"]) # Register analytics routes (T057: AI-powered MCP operations) from src.api.routes import analytics app.include_router(analytics.router, prefix="/api", tags=["Analytics"]) # Initialize MCP server (wraps the FastAPI app) # This must come AFTER all routes are registered mcp = initialize_mcp(app)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/darrentmorgan/hostaway-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server