qdrant-loader-mcp-server 0.3.0b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,6 @@
1
+ """Entry point for the MCP Server package."""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,336 @@
1
+ """CLI module for QDrant Loader MCP Server."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import signal
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import tomli
13
+ from click.decorators import option
14
+ from click.types import Choice
15
+ from click.types import Path as ClickPath
16
+
17
+ from .config import Config
18
+ from .mcp import MCPHandler
19
+ from .search.engine import SearchEngine
20
+ from .search.processor import QueryProcessor
21
+ from .utils import LoggingConfig
22
+
23
+ # Suppress asyncio debug messages
24
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
25
+
26
+
27
+ def _get_version() -> str:
28
+ """Get version from pyproject.toml."""
29
+ try:
30
+ # Try to find pyproject.toml in the package directory or parent directories
31
+ current_dir = Path(__file__).parent
32
+ for _ in range(5): # Look up to 5 levels up
33
+ pyproject_path = current_dir / "pyproject.toml"
34
+ if pyproject_path.exists():
35
+ with open(pyproject_path, "rb") as f:
36
+ pyproject = tomli.load(f)
37
+ return pyproject["project"]["version"]
38
+ current_dir = current_dir.parent
39
+
40
+ # If not found, try the workspace root
41
+ workspace_root = Path.cwd()
42
+ for package_dir in ["packages/qdrant-loader-mcp-server", "."]:
43
+ pyproject_path = workspace_root / package_dir / "pyproject.toml"
44
+ if pyproject_path.exists():
45
+ with open(pyproject_path, "rb") as f:
46
+ pyproject = tomli.load(f)
47
+ return pyproject["project"]["version"]
48
+ except Exception:
49
+ pass
50
+ return "Unknown"
51
+
52
+
53
+ def _setup_logging(log_level: str) -> None:
54
+ """Set up logging configuration."""
55
+ try:
56
+ # Check if console logging is disabled
57
+ disable_console_logging = (
58
+ os.getenv("MCP_DISABLE_CONSOLE_LOGGING", "").lower() == "true"
59
+ )
60
+
61
+ if not disable_console_logging:
62
+ LoggingConfig.setup(level=log_level.upper(), format="console")
63
+ else:
64
+ LoggingConfig.setup(level=log_level.upper(), format="json")
65
+ except Exception as e:
66
+ print(f"Failed to setup logging: {e}", file=sys.stderr)
67
+
68
+
69
+ async def read_stdin():
70
+ """Read from stdin asynchronously."""
71
+ loop = asyncio.get_running_loop()
72
+ reader = asyncio.StreamReader()
73
+ protocol = asyncio.StreamReaderProtocol(reader)
74
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
75
+ return reader
76
+
77
+
78
+ async def shutdown(loop: asyncio.AbstractEventLoop):
79
+ """Handle graceful shutdown."""
80
+ logger = LoggingConfig.get_logger(__name__)
81
+ logger.info("Shutting down...")
82
+
83
+ # Get all tasks except the current one
84
+ tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
85
+
86
+ # Cancel all tasks
87
+ for task in tasks:
88
+ task.cancel()
89
+
90
+ # Wait for all tasks to complete
91
+ try:
92
+ await asyncio.gather(*tasks, return_exceptions=True)
93
+ except Exception:
94
+ logger.error("Error during shutdown", exc_info=True)
95
+
96
+ # Stop the event loop
97
+ loop.stop()
98
+
99
+
100
+ async def handle_stdio(config: Config, log_level: str):
101
+ """Handle stdio communication with Cursor."""
102
+ logger = LoggingConfig.get_logger(__name__)
103
+
104
+ try:
105
+ # Check if console logging is disabled
106
+ disable_console_logging = (
107
+ os.getenv("MCP_DISABLE_CONSOLE_LOGGING", "").lower() == "true"
108
+ )
109
+
110
+ if not disable_console_logging:
111
+ logger.info("Setting up stdio handler...")
112
+
113
+ # Initialize components
114
+ search_engine = SearchEngine()
115
+ query_processor = QueryProcessor(config.openai)
116
+ mcp_handler = MCPHandler(search_engine, query_processor)
117
+
118
+ # Initialize search engine
119
+ try:
120
+ await search_engine.initialize(config.qdrant, config.openai)
121
+ if not disable_console_logging:
122
+ logger.info("Search engine initialized successfully")
123
+ except Exception as e:
124
+ logger.error("Failed to initialize search engine", exc_info=True)
125
+ raise RuntimeError("Failed to initialize search engine") from e
126
+
127
+ reader = await read_stdin()
128
+ if not disable_console_logging:
129
+ logger.info("Server ready to handle requests")
130
+
131
+ while True:
132
+ try:
133
+ # Read a line from stdin
134
+ if not disable_console_logging:
135
+ logger.debug("Waiting for input...")
136
+ try:
137
+ line = await reader.readline()
138
+ if not line:
139
+ if not disable_console_logging:
140
+ logger.warning("No input received, breaking")
141
+ break
142
+ except asyncio.CancelledError:
143
+ if not disable_console_logging:
144
+ logger.info("Read operation cancelled during shutdown")
145
+ break
146
+
147
+ # Log the raw input
148
+ raw_input = line.decode().strip()
149
+ if not disable_console_logging:
150
+ logger.debug("Received raw input", raw_input=raw_input)
151
+
152
+ # Parse the request
153
+ try:
154
+ request = json.loads(raw_input)
155
+ if not disable_console_logging:
156
+ logger.debug("Parsed request", request=request)
157
+ except json.JSONDecodeError as e:
158
+ if not disable_console_logging:
159
+ logger.error("Invalid JSON received", error=str(e))
160
+ # Send error response for invalid JSON
161
+ response = {
162
+ "jsonrpc": "2.0",
163
+ "id": None,
164
+ "error": {
165
+ "code": -32700,
166
+ "message": "Parse error",
167
+ "data": f"Invalid JSON received: {str(e)}",
168
+ },
169
+ }
170
+ sys.stdout.write(json.dumps(response) + "\n")
171
+ sys.stdout.flush()
172
+ continue
173
+
174
+ # Validate request format
175
+ if not isinstance(request, dict):
176
+ if not disable_console_logging:
177
+ logger.error("Request must be a JSON object")
178
+ response = {
179
+ "jsonrpc": "2.0",
180
+ "id": None,
181
+ "error": {
182
+ "code": -32600,
183
+ "message": "Invalid Request",
184
+ "data": "Request must be a JSON object",
185
+ },
186
+ }
187
+ sys.stdout.write(json.dumps(response) + "\n")
188
+ sys.stdout.flush()
189
+ continue
190
+
191
+ if "jsonrpc" not in request or request["jsonrpc"] != "2.0":
192
+ if not disable_console_logging:
193
+ logger.error("Invalid JSON-RPC version")
194
+ response = {
195
+ "jsonrpc": "2.0",
196
+ "id": request.get("id"),
197
+ "error": {
198
+ "code": -32600,
199
+ "message": "Invalid Request",
200
+ "data": "Invalid JSON-RPC version",
201
+ },
202
+ }
203
+ sys.stdout.write(json.dumps(response) + "\n")
204
+ sys.stdout.flush()
205
+ continue
206
+
207
+ # Process the request
208
+ try:
209
+ response = await mcp_handler.handle_request(request)
210
+ if not disable_console_logging:
211
+ logger.debug("Sending response", response=response)
212
+ # Only write to stdout if response is not empty (not a notification)
213
+ if response:
214
+ sys.stdout.write(json.dumps(response) + "\n")
215
+ sys.stdout.flush()
216
+ except Exception as e:
217
+ if not disable_console_logging:
218
+ logger.error("Error processing request", exc_info=True)
219
+ response = {
220
+ "jsonrpc": "2.0",
221
+ "id": request.get("id"),
222
+ "error": {
223
+ "code": -32603,
224
+ "message": "Internal error",
225
+ "data": str(e),
226
+ },
227
+ }
228
+ sys.stdout.write(json.dumps(response) + "\n")
229
+ sys.stdout.flush()
230
+
231
+ except asyncio.CancelledError:
232
+ if not disable_console_logging:
233
+ logger.info("Request handling cancelled during shutdown")
234
+ break
235
+ except Exception:
236
+ if not disable_console_logging:
237
+ logger.error("Error handling request", exc_info=True)
238
+ continue
239
+
240
+ # Cleanup
241
+ await search_engine.cleanup()
242
+
243
+ except Exception:
244
+ if not disable_console_logging:
245
+ logger.error("Error in stdio handler", exc_info=True)
246
+ raise
247
+
248
+
249
+ @click.command(name="mcp-qdrant-loader")
250
+ @option(
251
+ "--log-level",
252
+ type=Choice(
253
+ ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False
254
+ ),
255
+ default="INFO",
256
+ help="Set the logging level.",
257
+ )
258
+ @option(
259
+ "--config",
260
+ type=ClickPath(exists=True, path_type=Path),
261
+ help="Path to configuration file (currently not implemented).",
262
+ )
263
+ @click.version_option(
264
+ version=_get_version(),
265
+ message="QDrant Loader MCP Server v%(version)s",
266
+ )
267
+ def cli(log_level: str = "INFO", config: Path | None = None) -> None:
268
+ """QDrant Loader MCP Server.
269
+
270
+ A Model Context Protocol (MCP) server that provides RAG capabilities
271
+ to Cursor and other LLM applications using Qdrant vector database.
272
+
273
+ The server communicates via JSON-RPC over stdio and provides semantic
274
+ search capabilities for documents stored in Qdrant.
275
+
276
+ Environment Variables:
277
+ QDRANT_URL: URL of your QDrant instance (required)
278
+ QDRANT_API_KEY: API key for QDrant authentication
279
+ QDRANT_COLLECTION_NAME: Name of the collection to use (default: "documents")
280
+ OPENAI_API_KEY: OpenAI API key for embeddings (required)
281
+ MCP_DISABLE_CONSOLE_LOGGING: Set to "true" to disable console logging
282
+
283
+ Examples:
284
+ # Start the MCP server
285
+ mcp-qdrant-loader
286
+
287
+ # Start with debug logging
288
+ mcp-qdrant-loader --log-level DEBUG
289
+
290
+ # Show help
291
+ mcp-qdrant-loader --help
292
+
293
+ # Show version
294
+ mcp-qdrant-loader --version
295
+ """
296
+ try:
297
+ # Setup logging
298
+ _setup_logging(log_level)
299
+
300
+ # Initialize configuration
301
+ config_obj = Config()
302
+
303
+ # Create and set the event loop
304
+ loop = asyncio.new_event_loop()
305
+ asyncio.set_event_loop(loop)
306
+
307
+ # Set up signal handlers
308
+ for sig in (signal.SIGTERM, signal.SIGINT):
309
+ loop.add_signal_handler(sig, lambda: asyncio.create_task(shutdown(loop)))
310
+
311
+ # Start the stdio handler
312
+ loop.run_until_complete(handle_stdio(config_obj, log_level))
313
+ except Exception:
314
+ logger = LoggingConfig.get_logger(__name__)
315
+ logger.error("Error in main", exc_info=True)
316
+ sys.exit(1)
317
+ finally:
318
+ try:
319
+ # Cancel all remaining tasks
320
+ pending = asyncio.all_tasks(loop)
321
+ for task in pending:
322
+ task.cancel()
323
+
324
+ # Run the loop until all tasks are done
325
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
326
+ except Exception:
327
+ logger = LoggingConfig.get_logger(__name__)
328
+ logger.error("Error during final cleanup", exc_info=True)
329
+ finally:
330
+ loop.close()
331
+ logger = LoggingConfig.get_logger(__name__)
332
+ logger.info("Server shutdown complete")
333
+
334
+
335
+ if __name__ == "__main__":
336
+ cli()
@@ -0,0 +1,62 @@
1
+ """Configuration settings for the RAG MCP Server."""
2
+
3
+ import os
4
+
5
+ from dotenv import load_dotenv
6
+ from pydantic import BaseModel
7
+
8
+ # Load environment variables from .env file
9
+ load_dotenv()
10
+
11
+
12
+ class ServerConfig(BaseModel):
13
+ """Server configuration settings."""
14
+
15
+ host: str = "0.0.0.0"
16
+ port: int = 8000
17
+ log_level: str = "INFO"
18
+
19
+
20
+ class QdrantConfig(BaseModel):
21
+ """Qdrant configuration settings."""
22
+
23
+ url: str = "http://localhost:6333"
24
+ api_key: str | None = None
25
+ collection_name: str = "documents"
26
+
27
+ def __init__(self, **data):
28
+ """Initialize with environment variables if not provided."""
29
+ if "url" not in data:
30
+ data["url"] = os.getenv("QDRANT_URL", "http://localhost:6333")
31
+ if "api_key" not in data:
32
+ data["api_key"] = os.getenv("QDRANT_API_KEY")
33
+ if "collection_name" not in data:
34
+ data["collection_name"] = os.getenv("QDRANT_COLLECTION_NAME", "documents")
35
+ super().__init__(**data)
36
+
37
+
38
+ class OpenAIConfig(BaseModel):
39
+ """OpenAI configuration settings."""
40
+
41
+ api_key: str
42
+ model: str = "text-embedding-3-small"
43
+ chat_model: str = "gpt-3.5-turbo"
44
+
45
+
46
+ class Config(BaseModel):
47
+ """Main configuration class."""
48
+
49
+ server: ServerConfig
50
+ qdrant: QdrantConfig
51
+ openai: OpenAIConfig
52
+
53
+ def __init__(self, **data):
54
+ """Initialize configuration with environment variables."""
55
+ # Initialize sub-configs if not provided
56
+ if "server" not in data:
57
+ data["server"] = ServerConfig()
58
+ if "qdrant" not in data:
59
+ data["qdrant"] = QdrantConfig()
60
+ if "openai" not in data:
61
+ data["openai"] = {"api_key": os.getenv("OPENAI_API_KEY")}
62
+ super().__init__(**data)
@@ -0,0 +1,12 @@
1
+ """Main application entry point for RAG MCP Server."""
2
+
3
+ from .cli import cli
4
+
5
+
6
+ def main():
7
+ """Main entry point that delegates to the CLI."""
8
+ cli()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,7 @@
1
+ """MCP (Model Context Protocol) implementation for RAG server."""
2
+
3
+ from .handler import MCPHandler
4
+ from .models import MCPRequest, MCPResponse
5
+ from .protocol import MCPProtocol
6
+
7
+ __all__ = ["MCPProtocol", "MCPHandler", "MCPRequest", "MCPResponse"]