mcp-code-indexer 3.1.4__py3-none-any.whl → 3.1.6__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.
@@ -13,71 +13,82 @@ from typing import Any, Callable, Dict, List
13
13
  import aiosqlite
14
14
  from mcp import types
15
15
 
16
- from mcp_code_indexer.error_handler import ErrorHandler, MCPError
17
- from mcp_code_indexer.logging_config import get_logger, log_tool_usage, log_performance_metrics
16
+ from mcp_code_indexer.error_handler import ErrorHandler
17
+ from mcp_code_indexer.logging_config import (
18
+ get_logger,
19
+ log_tool_usage,
20
+ log_performance_metrics,
21
+ )
18
22
 
19
23
  logger = get_logger(__name__)
20
24
 
21
25
 
22
26
  class ToolMiddleware:
23
27
  """Middleware for MCP tool error handling and logging."""
24
-
28
+
25
29
  def __init__(self, error_handler: ErrorHandler):
26
30
  """Initialize middleware with error handler."""
27
31
  self.error_handler = error_handler
28
-
32
+
29
33
  def wrap_tool_handler(self, tool_name: str):
30
34
  """
31
35
  Decorator to wrap tool handlers with error handling and logging.
32
-
36
+
33
37
  Args:
34
38
  tool_name: Name of the MCP tool
35
-
39
+
36
40
  Returns:
37
41
  Decorator function
38
42
  """
43
+
39
44
  def decorator(func: Callable) -> Callable:
40
45
  @functools.wraps(func)
41
46
  async def wrapper(arguments: Dict[str, Any]) -> List[types.TextContent]:
42
47
  start_time = time.time()
43
48
  success = False
44
49
  result_size = 0
45
-
50
+
46
51
  try:
47
52
  # Log tool invocation
48
- logger.info(f"Tool {tool_name} called", extra={
49
- "structured_data": {
50
- "tool_invocation": {
51
- "tool_name": tool_name,
52
- "arguments_count": len(arguments)
53
+ logger.info(
54
+ f"Tool {tool_name} called",
55
+ extra={
56
+ "structured_data": {
57
+ "tool_invocation": {
58
+ "tool_name": tool_name,
59
+ "arguments_count": len(arguments),
60
+ }
53
61
  }
54
- }
55
- })
56
-
62
+ },
63
+ )
64
+
57
65
  # Call the actual tool handler
58
66
  result = await func(arguments)
59
-
67
+
60
68
  # Calculate result size
61
69
  if isinstance(result, list):
62
- result_size = sum(len(item.text) if hasattr(item, 'text') else 0 for item in result)
63
-
70
+ result_size = sum(
71
+ len(item.text) if hasattr(item, "text") else 0
72
+ for item in result
73
+ )
74
+
64
75
  success = True
65
76
  duration = time.time() - start_time
66
-
77
+
67
78
  # Log performance metrics
68
79
  log_performance_metrics(
69
80
  logger,
70
81
  f"tool_{tool_name}",
71
82
  duration,
72
83
  result_size=result_size,
73
- arguments_count=len(arguments)
84
+ arguments_count=len(arguments),
74
85
  )
75
-
86
+
76
87
  return result
77
-
88
+
78
89
  except Exception as e:
79
90
  duration = time.time() - start_time
80
-
91
+
81
92
  # Enhanced SQLite error handling
82
93
  if self._is_database_locking_error(e):
83
94
  logger.warning(
@@ -88,26 +99,26 @@ class ToolMiddleware:
88
99
  "tool_name": tool_name,
89
100
  "error_type": type(e).__name__,
90
101
  "error_message": str(e),
91
- "duration": duration
102
+ "duration": duration,
92
103
  }
93
104
  }
94
- }
105
+ },
95
106
  )
96
-
107
+
97
108
  # Log the error
98
109
  self.error_handler.log_error(
99
110
  e,
100
111
  context={"arguments_count": len(arguments)},
101
- tool_name=tool_name
112
+ tool_name=tool_name,
102
113
  )
103
-
114
+
104
115
  # Create error response
105
116
  error_response = self.error_handler.create_mcp_error_response(
106
117
  e, tool_name, arguments
107
118
  )
108
-
119
+
109
120
  return [error_response]
110
-
121
+
111
122
  finally:
112
123
  # Always log tool usage
113
124
  log_tool_usage(
@@ -116,58 +127,73 @@ class ToolMiddleware:
116
127
  arguments,
117
128
  success,
118
129
  time.time() - start_time,
119
- result_size if success else None
130
+ result_size if success else None,
120
131
  )
121
-
132
+
122
133
  return wrapper
134
+
123
135
  return decorator
124
-
125
- def validate_tool_arguments(self, required_fields: List[str], optional_fields: List[str] = None):
136
+
137
+ def validate_tool_arguments(
138
+ self, required_fields: List[str], optional_fields: List[str] = None
139
+ ):
126
140
  """
127
141
  Decorator to validate tool arguments.
128
-
142
+
129
143
  Args:
130
144
  required_fields: List of required argument names
131
145
  optional_fields: List of optional argument names
132
-
146
+
133
147
  Returns:
134
148
  Decorator function
135
149
  """
150
+
136
151
  def decorator(func: Callable) -> Callable:
137
152
  @functools.wraps(func)
138
153
  async def wrapper(arguments: Dict[str, Any]) -> Any:
139
154
  from ..error_handler import ValidationError
140
-
155
+
141
156
  # Check required fields
142
- missing_fields = [field for field in required_fields if field not in arguments]
157
+ missing_fields = [
158
+ field for field in required_fields if field not in arguments
159
+ ]
143
160
  if missing_fields:
144
161
  raise ValidationError(
145
162
  f"Missing required fields: {', '.join(missing_fields)}",
146
- details={"missing_fields": missing_fields, "provided_fields": list(arguments.keys())}
163
+ details={
164
+ "missing_fields": missing_fields,
165
+ "provided_fields": list(arguments.keys()),
166
+ },
147
167
  )
148
-
168
+
149
169
  # Check for unexpected fields if optional_fields is provided
150
170
  if optional_fields is not None:
151
171
  all_fields = set(required_fields + optional_fields)
152
- unexpected_fields = [field for field in arguments.keys() if field not in all_fields]
172
+ unexpected_fields = [
173
+ field for field in arguments.keys() if field not in all_fields
174
+ ]
153
175
  if unexpected_fields:
154
176
  raise ValidationError(
155
177
  f"Unexpected fields: {', '.join(unexpected_fields)}",
156
- details={"unexpected_fields": unexpected_fields, "allowed_fields": list(all_fields)}
178
+ details={
179
+ "unexpected_fields": unexpected_fields,
180
+ "allowed_fields": list(all_fields),
181
+ },
157
182
  )
158
-
183
+
159
184
  return await func(arguments)
160
-
185
+
161
186
  return wrapper
187
+
162
188
  return decorator
163
-
189
+
164
190
  def _is_database_locking_error(self, error: Exception) -> bool:
165
191
  """
166
192
  Check if an error is related to database locking.
167
-
193
+
168
194
  Args:
169
195
  error: Exception to check
170
-
196
+
171
197
  Returns:
172
198
  True if this is a database locking error
173
199
  """
@@ -179,74 +205,71 @@ class ToolMiddleware:
179
205
  "database is busy",
180
206
  "sqlite_busy",
181
207
  "sqlite_locked",
182
- "cannot start a transaction within a transaction"
208
+ "cannot start a transaction within a transaction",
183
209
  ]
184
210
  return any(keyword in error_message for keyword in locking_keywords)
185
-
211
+
186
212
  return False
187
213
 
188
214
 
189
215
  class AsyncTaskManager:
190
216
  """Manages async tasks with proper error handling."""
191
-
217
+
192
218
  def __init__(self, error_handler: ErrorHandler):
193
219
  """Initialize task manager."""
194
220
  self.error_handler = error_handler
195
221
  self._tasks: List[asyncio.Task] = []
196
-
222
+
197
223
  def create_task(self, coro, name: str = None) -> asyncio.Task:
198
224
  """
199
225
  Create a managed async task.
200
-
226
+
201
227
  Args:
202
228
  coro: Coroutine to run
203
229
  name: Optional task name for logging
204
-
230
+
205
231
  Returns:
206
232
  Created task
207
233
  """
208
234
  task = asyncio.create_task(coro, name=name)
209
235
  self._tasks.append(task)
210
-
236
+
211
237
  # Add done callback for error handling
212
238
  task.add_done_callback(
213
239
  lambda t: asyncio.create_task(
214
240
  self._handle_task_completion(t, name or "unnamed_task")
215
241
  )
216
242
  )
217
-
243
+
218
244
  return task
219
-
245
+
220
246
  async def _handle_task_completion(self, task: asyncio.Task, task_name: str) -> None:
221
247
  """Handle task completion and errors."""
222
248
  try:
223
249
  if task.done() and not task.cancelled():
224
250
  exception = task.exception()
225
251
  if exception:
226
- await self.error_handler.handle_async_task_error(
227
- task, task_name
228
- )
252
+ await self.error_handler.handle_async_task_error(task, task_name)
229
253
  except Exception as e:
230
254
  logger.error(f"Error handling task completion for {task_name}: {e}")
231
255
  finally:
232
256
  # Remove completed task from tracking
233
257
  if task in self._tasks:
234
258
  self._tasks.remove(task)
235
-
259
+
236
260
  async def wait_for_all(self, timeout: float = None) -> None:
237
261
  """
238
262
  Wait for all managed tasks to complete.
239
-
263
+
240
264
  Args:
241
265
  timeout: Maximum time to wait in seconds
242
266
  """
243
267
  if not self._tasks:
244
268
  return
245
-
269
+
246
270
  try:
247
271
  await asyncio.wait_for(
248
- asyncio.gather(*self._tasks, return_exceptions=True),
249
- timeout=timeout
272
+ asyncio.gather(*self._tasks, return_exceptions=True), timeout=timeout
250
273
  )
251
274
  except asyncio.TimeoutError:
252
275
  logger.warning(f"Timeout waiting for {len(self._tasks)} tasks")
@@ -256,14 +279,14 @@ class AsyncTaskManager:
256
279
  task.cancel()
257
280
  except Exception as e:
258
281
  logger.error(f"Error waiting for tasks: {e}")
259
-
282
+
260
283
  def cancel_all(self) -> None:
261
284
  """Cancel all managed tasks."""
262
285
  for task in self._tasks:
263
286
  if not task.done():
264
287
  task.cancel()
265
288
  self._tasks.clear()
266
-
289
+
267
290
  @property
268
291
  def active_task_count(self) -> int:
269
292
  """Get count of active tasks."""
@@ -273,10 +296,10 @@ class AsyncTaskManager:
273
296
  def create_tool_middleware(error_handler: ErrorHandler) -> ToolMiddleware:
274
297
  """
275
298
  Create tool middleware instance.
276
-
299
+
277
300
  Args:
278
301
  error_handler: Error handler instance
279
-
302
+
280
303
  Returns:
281
304
  Configured ToolMiddleware
282
305
  """
@@ -285,43 +308,55 @@ def create_tool_middleware(error_handler: ErrorHandler) -> ToolMiddleware:
285
308
 
286
309
  # Convenience decorators for common patterns
287
310
 
311
+
288
312
  def require_fields(*required_fields):
289
313
  """Decorator that requires specific fields in arguments."""
314
+
290
315
  def decorator(func):
291
316
  @functools.wraps(func)
292
317
  async def wrapper(self, arguments: Dict[str, Any]):
293
318
  from ..error_handler import ValidationError
294
-
319
+
295
320
  missing = [field for field in required_fields if field not in arguments]
296
321
  if missing:
297
322
  raise ValidationError(f"Missing required fields: {', '.join(missing)}")
298
-
323
+
299
324
  return await func(self, arguments)
325
+
300
326
  return wrapper
327
+
301
328
  return decorator
302
329
 
303
330
 
304
331
  def handle_file_operations(func):
305
332
  """Decorator for file operation error handling."""
333
+
306
334
  @functools.wraps(func)
307
335
  async def wrapper(*args, **kwargs):
308
336
  try:
309
337
  return await func(*args, **kwargs)
310
338
  except (FileNotFoundError, PermissionError, OSError) as e:
311
339
  from ..error_handler import FileSystemError
340
+
312
341
  raise FileSystemError(f"File operation failed: {e}") from e
342
+
313
343
  return wrapper
314
344
 
315
345
 
316
346
  def handle_database_operations(func):
317
347
  """Decorator for database operation error handling."""
348
+
318
349
  @functools.wraps(func)
319
350
  async def wrapper(*args, **kwargs):
320
351
  try:
321
352
  return await func(*args, **kwargs)
322
353
  except Exception as e:
323
- if any(keyword in str(e).lower() for keyword in ["database", "sqlite", "sql"]):
354
+ if any(
355
+ keyword in str(e).lower() for keyword in ["database", "sqlite", "sql"]
356
+ ):
324
357
  from ..error_handler import DatabaseError
358
+
325
359
  raise DatabaseError(f"Database operation failed: {e}") from e
326
360
  raise
361
+
327
362
  return wrapper
@@ -6,8 +6,10 @@ to enable multi-word search with case insensitive matching, whole word enforceme
6
6
  and proper handling of FTS5 operators as literal search terms.
7
7
 
8
8
  Key features:
9
- - Multi-word queries: "grpc proto" becomes "grpc" AND "proto" for order-agnostic matching
10
- - FTS5 operator escaping: "AND OR" becomes '"AND" AND "OR"' to treat operators as literals
9
+ - Multi-word queries: "grpc proto" becomes "grpc" AND "proto" for
10
+ order-agnostic matching
11
+ - FTS5 operator escaping: "AND OR" becomes '"AND" AND "OR"' to treat
12
+ operators as literals
11
13
  - Whole word matching: prevents partial matches by relying on proper tokenization
12
14
  - Case insensitive: leverages FTS5 default behavior
13
15
  - Special character handling: preserves special characters in quoted terms
@@ -23,30 +25,28 @@ logger = logging.getLogger(__name__)
23
25
  class QueryPreprocessor:
24
26
  """
25
27
  Preprocesses user queries for optimal FTS5 search performance.
26
-
28
+
27
29
  Handles multi-word queries, operator escaping, and special character preservation
28
30
  while maintaining BM25 ranking performance.
29
31
  """
30
-
32
+
31
33
  # FTS5 operators that need to be escaped when used as literal search terms
32
- FTS5_OPERATORS: Set[str] = {
33
- 'AND', 'OR', 'NOT', 'NEAR'
34
- }
35
-
34
+ FTS5_OPERATORS: Set[str] = {"AND", "OR", "NOT", "NEAR"}
35
+
36
36
  def __init__(self):
37
37
  """Initialize the query preprocessor."""
38
38
  pass
39
-
39
+
40
40
  def preprocess_query(self, query: str) -> str:
41
41
  """
42
42
  Preprocess a user query for FTS5 search.
43
-
43
+
44
44
  Args:
45
45
  query: Raw user query string
46
-
46
+
47
47
  Returns:
48
48
  Preprocessed query string optimized for FTS5
49
-
49
+
50
50
  Examples:
51
51
  >>> preprocessor = QueryPreprocessor()
52
52
  >>> preprocessor.preprocess_query("grpc proto")
@@ -58,74 +58,74 @@ class QueryPreprocessor:
58
58
  """
59
59
  if not query or not query.strip():
60
60
  return ""
61
-
61
+
62
62
  # Normalize whitespace
63
63
  query = query.strip()
64
-
64
+
65
65
  # Split into terms while preserving quoted phrases
66
66
  terms = self._split_terms(query)
67
-
67
+
68
68
  if not terms:
69
69
  return ""
70
-
70
+
71
71
  # Process each term: escape operators and add quotes
72
72
  processed_terms = []
73
73
  for term in terms:
74
74
  processed_term = self._process_term(term)
75
75
  if processed_term: # Skip empty terms
76
76
  processed_terms.append(processed_term)
77
-
77
+
78
78
  if not processed_terms:
79
79
  return ""
80
-
80
+
81
81
  # Join with AND for multi-word matching
82
82
  result = " AND ".join(processed_terms)
83
-
83
+
84
84
  logger.debug(f"Preprocessed query: '{query}' -> '{result}'")
85
85
  return result
86
-
86
+
87
87
  def _split_terms(self, query: str) -> List[str]:
88
88
  """
89
89
  Split query into terms while preserving quoted phrases.
90
-
90
+
91
91
  Args:
92
92
  query: Input query string
93
-
93
+
94
94
  Returns:
95
95
  List of terms and quoted phrases
96
-
96
+
97
97
  Examples:
98
98
  'grpc proto' -> ['grpc', 'proto']
99
99
  'config "file system"' -> ['config', '"file system"']
100
100
  'error AND handling' -> ['error', 'AND', 'handling']
101
101
  """
102
102
  terms = []
103
-
103
+
104
104
  # Regex to match quoted phrases or individual words
105
105
  # This pattern captures:
106
106
  # 1. Double-quoted strings (including the quotes)
107
107
  # 2. Single words (sequences of non-whitespace characters)
108
108
  pattern = r'"[^"]*"|\S+'
109
-
109
+
110
110
  matches = re.findall(pattern, query)
111
-
111
+
112
112
  for match in matches:
113
113
  # Skip empty matches
114
114
  if match.strip():
115
115
  terms.append(match)
116
-
116
+
117
117
  return terms
118
-
118
+
119
119
  def _process_term(self, term: str) -> str:
120
120
  """
121
121
  Process a single term: escape operators and ensure proper quoting.
122
-
122
+
123
123
  Args:
124
124
  term: Single term or quoted phrase
125
-
125
+
126
126
  Returns:
127
127
  Processed term ready for FTS5
128
-
128
+
129
129
  Examples:
130
130
  'grpc' -> '"grpc"'
131
131
  'AND' -> '"AND"'
@@ -134,31 +134,31 @@ class QueryPreprocessor:
134
134
  """
135
135
  if not term:
136
136
  return ""
137
-
137
+
138
138
  # If already quoted, return as-is (user intentional phrase)
139
139
  if term.startswith('"') and term.endswith('"') and len(term) >= 2:
140
140
  return term
141
-
141
+
142
142
  # Check if term is an FTS5 operator (case-insensitive)
143
143
  if term.upper() in self.FTS5_OPERATORS:
144
144
  # Escape operator by quoting
145
145
  escaped_term = f'"{term}"'
146
146
  logger.debug(f"Escaped FTS5 operator: '{term}' -> '{escaped_term}'")
147
147
  return escaped_term
148
-
148
+
149
149
  # Quote all terms to ensure whole-word matching and handle special characters
150
150
  return f'"{term}"'
151
-
151
+
152
152
  def _escape_quotes_in_term(self, term: str) -> str:
153
153
  """
154
154
  Escape internal quotes in a term for FTS5 compatibility.
155
-
155
+
156
156
  Args:
157
157
  term: Term that may contain quotes
158
-
158
+
159
159
  Returns:
160
160
  Term with escaped quotes
161
-
161
+
162
162
  Examples:
163
163
  'say "hello"' -> 'say ""hello""'
164
164
  "test's file" -> "test's file"
@@ -170,10 +170,10 @@ class QueryPreprocessor:
170
170
  def preprocess_search_query(query: str) -> str:
171
171
  """
172
172
  Convenience function for preprocessing search queries.
173
-
173
+
174
174
  Args:
175
175
  query: Raw user query
176
-
176
+
177
177
  Returns:
178
178
  Preprocessed query ready for FTS5
179
179
  """