Coverage for src/qdrant_loader_mcp_server/fastmcp_app.py: 47%
51 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
1"""FastMCP application for the Qdrant Loader MCP server.
3This is the entry point for both transports: ``cli.py`` serves it over stdio
4(``mcp.run``) and over HTTP (the module-level ``http_app`` under uvicorn).
6Heavy resources (SearchEngine, QueryProcessor, the Search/Intelligence handlers)
7are created inside the lifespan — after the event loop is up — not at import time,
8so importing this module stays cheap.
9"""
11from __future__ import annotations
13import os
14from collections.abc import AsyncIterator
15from contextlib import asynccontextmanager
16from pathlib import Path
17from typing import Any
19from fastmcp import FastMCP
20from starlette.middleware import Middleware
21from starlette.middleware.cors import CORSMiddleware
22from starlette.responses import JSONResponse
24from .utils import LoggingConfig
26logger = LoggingConfig.get_logger(__name__)
29@asynccontextmanager
30async def _lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
31 """Build heavy resources on startup; clean them up on shutdown
32 Whatever this yields becomes ``ctx.lifespan_context`` inside every tool.
33 """
34 # Lazy imports keep module import cheap
35 from .config_loader import load_config, resolve_config_path
36 from .mcp.intelligence_handler import IntelligenceHandler
37 from .mcp.protocol import MCPProtocol
38 from .mcp.search_handler import SearchHandler
39 from .search.engine import SearchEngine
40 from .search.processor import QueryProcessor
42 config_path_env = os.getenv("MCP_CONFIG")
43 cli_config_path = Path(config_path_env) if config_path_env else None
44 # Resolve once so the graph store (initialized lazily, per-request) uses the
45 # exact same config file as the search engine below instead of re-deriving
46 # its own path from Path.cwd(), which can silently diverge from this one.
47 resolved_config_path = resolve_config_path(cli_config_path)
48 config, _, _ = load_config(resolved_config_path or cli_config_path)
50 search_engine = SearchEngine()
51 query_processor = QueryProcessor(config.openai)
52 initialized = False
54 try:
55 await search_engine.initialize(config.qdrant, config.openai, config.search)
56 initialized = True
57 logger.info("FastMCP search engine initialized", pid=os.getpid())
59 # Reuse the existing handler for its reranker
60 # Built after initialize() so the hybrid pipeline exists
61 search_handler = SearchHandler(
62 search_engine=search_engine,
63 query_processor=query_processor,
64 protocol=MCPProtocol(),
65 reranking_config=config.reranking,
66 )
68 # Stateful: holds the cluster store shared with expand_cluster
69 # build one instance for reuse
70 intelligence_handler = IntelligenceHandler(
71 search_engine=search_engine,
72 protocol=MCPProtocol(),
73 config_path=resolved_config_path,
74 )
76 yield {
77 "search_engine": search_engine,
78 "query_processor": query_processor,
79 "config": config,
80 "search_handler": search_handler,
81 "intelligence_handler": intelligence_handler,
82 }
83 finally:
84 if initialized:
85 try:
86 await search_engine.cleanup()
87 except Exception:
88 logger.error(
89 "Error during FastMCP search engine cleanup", exc_info=True
90 )
93# The FastMCP app. Imported by entry points later
94mcp = FastMCP("Qdrant Loader MCP Server", lifespan=_lifespan)
96# Register tools onto the instance
97# tool modules pull only fastmcp/pydantic, not the search engine
98from .fastmcp_tools import register_all # noqa: E402
100register_all(mcp)
103# ---- HTTP transport surface (used only when served as an ASGI app) ----
106@mcp.custom_route("/health", methods=["GET"])
107async def health_check(request):
108 """Liveness probe. The lifespan completes before uvicorn serves traffic,
109 so reaching this means the engine is initialized."""
110 return JSONResponse(
111 {
112 "status": "healthy",
113 "transport": "http",
114 "protocol": "mcp",
115 "pid": os.getpid(),
116 }
117 )
120# CORS, secure-by-default
121_cors_origins = os.getenv("CORS_ORIGINS", "")
122if _cors_origins.strip():
123 _allow_origins = [o.strip() for o in _cors_origins.split(",") if o.strip()]
124else:
125 _allow_origins = ["http://localhost", "http://127.0.0.1"]
127_http_middleware = [
128 Middleware(
129 CORSMiddleware,
130 allow_origins=_allow_origins,
131 allow_credentials=("*" not in _allow_origins),
132 allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
133 allow_headers=["*"],
134 )
135]
137# Module-level ASGI app so uvicorn can import it with workers=N
138# Tools and /health route must be registered first
139http_app = mcp.http_app(
140 path="/mcp",
141 middleware=_http_middleware,
142 # HTTP transport profile: plain JSON responses (no SSE) and stateless
143 # request/response (no initialize/session handshake). This is the intended
144 # production behavior for simple HTTP clients; stdio is unaffected.
145 json_response=True,
146 stateless_http=True,
147)