mcp-code-indexer 3.1.4__py3-none-any.whl → 3.1.5__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.
- mcp_code_indexer/__init__.py +8 -6
- mcp_code_indexer/ask_handler.py +105 -75
- mcp_code_indexer/claude_api_handler.py +125 -82
- mcp_code_indexer/cleanup_manager.py +107 -81
- mcp_code_indexer/database/connection_health.py +212 -161
- mcp_code_indexer/database/database.py +529 -415
- mcp_code_indexer/database/exceptions.py +167 -118
- mcp_code_indexer/database/models.py +54 -19
- mcp_code_indexer/database/retry_executor.py +139 -103
- mcp_code_indexer/deepask_handler.py +178 -140
- mcp_code_indexer/error_handler.py +88 -76
- mcp_code_indexer/file_scanner.py +163 -141
- mcp_code_indexer/git_hook_handler.py +352 -261
- mcp_code_indexer/logging_config.py +76 -94
- mcp_code_indexer/main.py +406 -320
- mcp_code_indexer/middleware/error_middleware.py +106 -71
- mcp_code_indexer/query_preprocessor.py +40 -40
- mcp_code_indexer/server/mcp_server.py +785 -470
- mcp_code_indexer/token_counter.py +54 -47
- {mcp_code_indexer-3.1.4.dist-info → mcp_code_indexer-3.1.5.dist-info}/METADATA +3 -3
- mcp_code_indexer-3.1.5.dist-info/RECORD +37 -0
- mcp_code_indexer-3.1.4.dist-info/RECORD +0 -37
- {mcp_code_indexer-3.1.4.dist-info → mcp_code_indexer-3.1.5.dist-info}/WHEEL +0 -0
- {mcp_code_indexer-3.1.4.dist-info → mcp_code_indexer-3.1.5.dist-info}/entry_points.txt +0 -0
- {mcp_code_indexer-3.1.4.dist-info → mcp_code_indexer-3.1.5.dist-info}/licenses/LICENSE +0 -0
- {mcp_code_indexer-3.1.4.dist-info → mcp_code_indexer-3.1.5.dist-info}/top_level.txt +0 -0
@@ -15,7 +15,12 @@ from typing import Dict, List, Optional, Any
|
|
15
15
|
from pathlib import Path
|
16
16
|
|
17
17
|
import aiohttp
|
18
|
-
from tenacity import
|
18
|
+
from tenacity import (
|
19
|
+
retry,
|
20
|
+
wait_exponential,
|
21
|
+
stop_after_attempt,
|
22
|
+
retry_if_exception_type,
|
23
|
+
)
|
19
24
|
|
20
25
|
from .database.database import DatabaseManager
|
21
26
|
from .token_counter import TokenCounter
|
@@ -23,11 +28,13 @@ from .token_counter import TokenCounter
|
|
23
28
|
|
24
29
|
class ClaudeAPIError(Exception):
|
25
30
|
"""Base exception for Claude API operations."""
|
31
|
+
|
26
32
|
pass
|
27
33
|
|
28
34
|
|
29
35
|
class ClaudeRateLimitError(ClaudeAPIError):
|
30
36
|
"""Exception for rate limiting scenarios."""
|
37
|
+
|
31
38
|
def __init__(self, message: str, retry_after: int = 60):
|
32
39
|
super().__init__(message)
|
33
40
|
self.retry_after = retry_after
|
@@ -35,12 +42,14 @@ class ClaudeRateLimitError(ClaudeAPIError):
|
|
35
42
|
|
36
43
|
class ClaudeValidationError(ClaudeAPIError):
|
37
44
|
"""Exception for response validation failures."""
|
45
|
+
|
38
46
|
pass
|
39
47
|
|
40
48
|
|
41
49
|
@dataclass
|
42
50
|
class ClaudeConfig:
|
43
51
|
"""Configuration for Claude API calls."""
|
52
|
+
|
44
53
|
model: str = "anthropic/claude-sonnet-4"
|
45
54
|
max_tokens: int = 24000
|
46
55
|
temperature: float = 0.3
|
@@ -51,6 +60,7 @@ class ClaudeConfig:
|
|
51
60
|
@dataclass
|
52
61
|
class ClaudeResponse:
|
53
62
|
"""Structured response from Claude API."""
|
63
|
+
|
54
64
|
content: str
|
55
65
|
usage: Optional[Dict[str, Any]] = None
|
56
66
|
model: Optional[str] = None
|
@@ -59,20 +69,25 @@ class ClaudeResponse:
|
|
59
69
|
class ClaudeAPIHandler:
|
60
70
|
"""
|
61
71
|
Base handler for Claude API interactions via OpenRouter.
|
62
|
-
|
72
|
+
|
63
73
|
Provides shared functionality for:
|
64
74
|
- Token counting and limit validation
|
65
75
|
- API request/response handling with retry logic
|
66
76
|
- Response validation and parsing
|
67
77
|
- Error handling and logging
|
68
78
|
"""
|
69
|
-
|
79
|
+
|
70
80
|
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
71
|
-
|
72
|
-
def __init__(
|
81
|
+
|
82
|
+
def __init__(
|
83
|
+
self,
|
84
|
+
db_manager: DatabaseManager,
|
85
|
+
cache_dir: Path,
|
86
|
+
logger: Optional[logging.Logger] = None,
|
87
|
+
):
|
73
88
|
"""
|
74
89
|
Initialize Claude API Handler.
|
75
|
-
|
90
|
+
|
76
91
|
Args:
|
77
92
|
db_manager: Database manager instance
|
78
93
|
cache_dir: Cache directory for temporary files
|
@@ -82,64 +97,68 @@ class ClaudeAPIHandler:
|
|
82
97
|
self.cache_dir = cache_dir
|
83
98
|
self.logger = logger if logger is not None else logging.getLogger(__name__)
|
84
99
|
self.token_counter = TokenCounter()
|
85
|
-
|
100
|
+
|
86
101
|
# Initialize configuration
|
87
102
|
self.config = ClaudeConfig(
|
88
103
|
model=os.getenv("MCP_CLAUDE_MODEL", "anthropic/claude-sonnet-4"),
|
89
104
|
max_tokens=int(os.getenv("MCP_CLAUDE_MAX_TOKENS", "24000")),
|
90
105
|
temperature=float(os.getenv("MCP_CLAUDE_TEMPERATURE", "0.3")),
|
91
106
|
timeout=int(os.getenv("MCP_CLAUDE_TIMEOUT", "600")), # 10 minutes
|
92
|
-
token_limit=int(os.getenv("MCP_CLAUDE_TOKEN_LIMIT", "180000"))
|
107
|
+
token_limit=int(os.getenv("MCP_CLAUDE_TOKEN_LIMIT", "180000")),
|
93
108
|
)
|
94
|
-
|
109
|
+
|
95
110
|
# Validate API key
|
96
111
|
self.api_key = os.getenv("OPENROUTER_API_KEY")
|
97
112
|
if not self.api_key:
|
98
113
|
raise ClaudeAPIError("OPENROUTER_API_KEY environment variable is required")
|
99
|
-
|
114
|
+
|
100
115
|
def validate_token_limit(self, prompt: str, context: str = "") -> bool:
|
101
116
|
"""
|
102
117
|
Validate that prompt + context fits within token limit.
|
103
|
-
|
118
|
+
|
104
119
|
Args:
|
105
120
|
prompt: Main prompt text
|
106
121
|
context: Additional context (project overview, file descriptions, etc.)
|
107
|
-
|
122
|
+
|
108
123
|
Returns:
|
109
124
|
True if within limits, False otherwise
|
110
125
|
"""
|
111
126
|
combined_text = f"{prompt}\n\n{context}"
|
112
127
|
token_count = self.token_counter.count_tokens(combined_text)
|
113
|
-
|
114
|
-
self.logger.debug(
|
115
|
-
|
128
|
+
|
129
|
+
self.logger.debug(
|
130
|
+
f"Token count validation: {token_count}/{self.config.token_limit}"
|
131
|
+
)
|
132
|
+
|
116
133
|
if token_count > self.config.token_limit:
|
117
134
|
self.logger.warning(
|
118
135
|
f"Token limit exceeded: {token_count} > {self.config.token_limit}. "
|
119
136
|
f"Consider using shorter context or ask for a more specific question."
|
120
137
|
)
|
121
138
|
return False
|
122
|
-
|
139
|
+
|
123
140
|
return True
|
124
|
-
|
141
|
+
|
125
142
|
def get_token_count(self, text: str) -> int:
|
126
143
|
"""Get token count for given text."""
|
127
144
|
return self.token_counter.count_tokens(text)
|
128
|
-
|
145
|
+
|
129
146
|
@retry(
|
130
147
|
wait=wait_exponential(multiplier=1, min=1, max=60),
|
131
148
|
stop=stop_after_attempt(5),
|
132
149
|
retry=retry_if_exception_type(ClaudeRateLimitError),
|
133
|
-
reraise=True
|
150
|
+
reraise=True,
|
134
151
|
)
|
135
|
-
async def _call_claude_api(
|
152
|
+
async def _call_claude_api(
|
153
|
+
self, prompt: str, system_prompt: Optional[str] = None
|
154
|
+
) -> ClaudeResponse:
|
136
155
|
"""
|
137
156
|
Make API call to Claude via OpenRouter with retry logic.
|
138
|
-
|
157
|
+
|
139
158
|
Args:
|
140
159
|
prompt: User prompt
|
141
160
|
system_prompt: Optional system prompt
|
142
|
-
|
161
|
+
|
143
162
|
Returns:
|
144
163
|
ClaudeResponse with parsed response data
|
145
164
|
"""
|
@@ -147,113 +166,126 @@ class ClaudeAPIHandler:
|
|
147
166
|
"Authorization": f"Bearer {self.api_key}",
|
148
167
|
"HTTP-Referer": "https://github.com/fluffypony/mcp-code-indexer",
|
149
168
|
"X-Title": "MCP Code Indexer",
|
150
|
-
"Content-Type": "application/json"
|
169
|
+
"Content-Type": "application/json",
|
151
170
|
}
|
152
|
-
|
171
|
+
|
153
172
|
messages = []
|
154
173
|
if system_prompt:
|
155
174
|
messages.append({"role": "system", "content": system_prompt})
|
156
175
|
messages.append({"role": "user", "content": prompt})
|
157
|
-
|
176
|
+
|
158
177
|
payload = {
|
159
178
|
"model": self.config.model,
|
160
179
|
"messages": messages,
|
161
180
|
"temperature": self.config.temperature,
|
162
181
|
"max_tokens": self.config.max_tokens,
|
163
182
|
}
|
164
|
-
|
183
|
+
|
165
184
|
timeout = aiohttp.ClientTimeout(total=self.config.timeout)
|
166
|
-
|
167
|
-
self.logger.info(
|
185
|
+
|
186
|
+
self.logger.info("Sending request to Claude API via OpenRouter...")
|
168
187
|
self.logger.info(f" Model: {self.config.model}")
|
169
188
|
self.logger.info(f" Temperature: {self.config.temperature}")
|
170
189
|
self.logger.info(f" Max tokens: {self.config.max_tokens}")
|
171
190
|
self.logger.info(f" Timeout: {self.config.timeout}s")
|
172
|
-
|
191
|
+
|
173
192
|
try:
|
174
193
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
175
194
|
async with session.post(
|
176
|
-
self.OPENROUTER_API_URL,
|
177
|
-
headers=headers,
|
178
|
-
json=payload
|
195
|
+
self.OPENROUTER_API_URL, headers=headers, json=payload
|
179
196
|
) as response:
|
180
|
-
|
197
|
+
|
181
198
|
self.logger.info(f"Claude API response status: {response.status}")
|
182
|
-
|
199
|
+
|
183
200
|
if response.status == 429:
|
184
201
|
retry_after = int(response.headers.get("Retry-After", 60))
|
185
|
-
self.logger.warning(
|
186
|
-
|
187
|
-
|
202
|
+
self.logger.warning(
|
203
|
+
f"Rate limited by OpenRouter, retry after {retry_after}s"
|
204
|
+
)
|
205
|
+
raise ClaudeRateLimitError(
|
206
|
+
f"Rate limited. Retry after {retry_after}s", retry_after
|
207
|
+
)
|
208
|
+
|
188
209
|
response.raise_for_status()
|
189
|
-
|
210
|
+
|
190
211
|
response_data = await response.json()
|
191
|
-
|
212
|
+
|
192
213
|
if "choices" not in response_data:
|
193
|
-
self.logger.error(
|
194
|
-
|
195
|
-
|
214
|
+
self.logger.error(
|
215
|
+
f"Invalid API response format: {response_data}"
|
216
|
+
)
|
217
|
+
raise ClaudeAPIError(
|
218
|
+
f"Invalid API response format: {response_data}"
|
219
|
+
)
|
220
|
+
|
196
221
|
content = response_data["choices"][0]["message"]["content"]
|
197
222
|
usage = response_data.get("usage")
|
198
223
|
model = response_data.get("model")
|
199
|
-
|
200
|
-
self.logger.info(
|
224
|
+
|
225
|
+
self.logger.info(
|
226
|
+
f"Claude response content length: {len(content)} characters"
|
227
|
+
)
|
201
228
|
if usage:
|
202
229
|
self.logger.info(f"Token usage: {usage}")
|
203
|
-
|
230
|
+
|
204
231
|
return ClaudeResponse(content=content, usage=usage, model=model)
|
205
|
-
|
232
|
+
|
206
233
|
except aiohttp.ClientError as e:
|
207
234
|
self.logger.error(f"Claude API request failed: {e}")
|
208
235
|
raise ClaudeAPIError(f"Claude API request failed: {e}")
|
209
|
-
except asyncio.TimeoutError
|
210
|
-
self.logger.error(
|
236
|
+
except asyncio.TimeoutError:
|
237
|
+
self.logger.error(
|
238
|
+
f"Claude API request timed out after {self.config.timeout}s"
|
239
|
+
)
|
211
240
|
raise ClaudeAPIError("Claude API request timed out")
|
212
|
-
|
213
|
-
def validate_json_response(
|
241
|
+
|
242
|
+
def validate_json_response(
|
243
|
+
self, response_text: str, required_keys: List[str] = None
|
244
|
+
) -> Dict[str, Any]:
|
214
245
|
"""
|
215
246
|
Validate and parse JSON response from Claude.
|
216
|
-
|
247
|
+
|
217
248
|
Args:
|
218
249
|
response_text: Raw response content
|
219
250
|
required_keys: List of required keys in the JSON response
|
220
|
-
|
251
|
+
|
221
252
|
Returns:
|
222
253
|
Validated JSON data
|
223
254
|
"""
|
255
|
+
|
224
256
|
def extract_json_from_response(text: str) -> str:
|
225
257
|
"""Extract JSON from response that might have extra text before/after."""
|
226
258
|
text = text.strip()
|
227
|
-
|
259
|
+
|
228
260
|
# Try to find JSON in the response
|
229
261
|
json_start = -1
|
230
262
|
json_end = -1
|
231
|
-
|
263
|
+
|
232
264
|
# Look for opening brace
|
233
265
|
for i, char in enumerate(text):
|
234
|
-
if char ==
|
266
|
+
if char == "{":
|
235
267
|
json_start = i
|
236
268
|
break
|
237
|
-
|
269
|
+
|
238
270
|
if json_start == -1:
|
239
271
|
return text # No JSON found, return original
|
240
|
-
|
272
|
+
|
241
273
|
# Find matching closing brace
|
242
274
|
brace_count = 0
|
243
275
|
for i in range(json_start, len(text)):
|
244
|
-
if text[i] ==
|
276
|
+
if text[i] == "{":
|
245
277
|
brace_count += 1
|
246
|
-
elif text[i] ==
|
278
|
+
elif text[i] == "}":
|
247
279
|
brace_count -= 1
|
248
280
|
if brace_count == 0:
|
249
281
|
json_end = i + 1
|
250
282
|
break
|
251
|
-
|
283
|
+
|
252
284
|
if json_end == -1:
|
253
285
|
return text # No matching brace found, return original
|
254
|
-
|
286
|
+
|
255
287
|
return text[json_start:json_end]
|
256
|
-
|
288
|
+
|
257
289
|
try:
|
258
290
|
# First try parsing as-is
|
259
291
|
try:
|
@@ -264,15 +296,17 @@ class ClaudeAPIHandler:
|
|
264
296
|
if extracted_json != response_text.strip():
|
265
297
|
self.logger.debug(f"Extracted JSON from response: {extracted_json}")
|
266
298
|
data = json.loads(extracted_json)
|
267
|
-
|
299
|
+
|
268
300
|
# Validate required keys if specified
|
269
301
|
if required_keys:
|
270
302
|
missing_keys = [key for key in required_keys if key not in data]
|
271
303
|
if missing_keys:
|
272
|
-
raise ClaudeValidationError(
|
273
|
-
|
304
|
+
raise ClaudeValidationError(
|
305
|
+
f"Missing required keys in response: {missing_keys}"
|
306
|
+
)
|
307
|
+
|
274
308
|
return data
|
275
|
-
|
309
|
+
|
276
310
|
except json.JSONDecodeError as e:
|
277
311
|
self.logger.error(f"Failed to parse JSON response: {e}")
|
278
312
|
self.logger.error(f"Response text: {response_text}")
|
@@ -280,46 +314,51 @@ class ClaudeAPIHandler:
|
|
280
314
|
except Exception as e:
|
281
315
|
self.logger.error(f"Response validation failed: {e}")
|
282
316
|
raise ClaudeValidationError(f"Response validation failed: {e}")
|
283
|
-
|
317
|
+
|
284
318
|
def format_error_response(self, error: Exception, context: str = "") -> str:
|
285
319
|
"""
|
286
320
|
Format error for user-friendly display.
|
287
|
-
|
321
|
+
|
288
322
|
Args:
|
289
323
|
error: The exception that occurred
|
290
324
|
context: Additional context about the operation
|
291
|
-
|
325
|
+
|
292
326
|
Returns:
|
293
327
|
Formatted error message
|
294
328
|
"""
|
295
329
|
if isinstance(error, ClaudeRateLimitError):
|
296
|
-
return
|
330
|
+
return (
|
331
|
+
f"Rate limited by Claude API. Please wait {error.retry_after} "
|
332
|
+
"seconds and try again."
|
333
|
+
)
|
297
334
|
elif isinstance(error, ClaudeValidationError):
|
298
335
|
return f"Invalid response from Claude API: {str(error)}"
|
299
336
|
elif isinstance(error, ClaudeAPIError):
|
300
337
|
return f"Claude API error: {str(error)}"
|
301
338
|
else:
|
302
339
|
return f"Unexpected error during {context}: {str(error)}"
|
303
|
-
|
340
|
+
|
304
341
|
async def find_existing_project_by_name(self, project_name: str) -> Optional[Any]:
|
305
342
|
"""
|
306
343
|
Find existing project by name for CLI usage.
|
307
|
-
|
344
|
+
|
308
345
|
Args:
|
309
346
|
project_name: Name of the project to find
|
310
|
-
|
347
|
+
|
311
348
|
Returns:
|
312
349
|
Project object if found, None otherwise
|
313
350
|
"""
|
314
351
|
try:
|
315
352
|
all_projects = await self.db_manager.get_all_projects()
|
316
353
|
normalized_name = project_name.lower()
|
317
|
-
|
354
|
+
|
318
355
|
for project in all_projects:
|
319
356
|
if project.name.lower() == normalized_name:
|
320
|
-
self.logger.info(
|
357
|
+
self.logger.info(
|
358
|
+
f"Found existing project: {project.name} (ID: {project.id})"
|
359
|
+
)
|
321
360
|
return project
|
322
|
-
|
361
|
+
|
323
362
|
self.logger.warning(f"No existing project found with name: {project_name}")
|
324
363
|
return None
|
325
364
|
except Exception as e:
|
@@ -329,21 +368,25 @@ class ClaudeAPIHandler:
|
|
329
368
|
async def get_project_overview(self, project_info: Dict[str, str]) -> str:
|
330
369
|
"""
|
331
370
|
Get project overview from database.
|
332
|
-
|
371
|
+
|
333
372
|
Args:
|
334
373
|
project_info: Project information dict with projectName, folderPath, etc.
|
335
|
-
|
374
|
+
|
336
375
|
Returns:
|
337
376
|
Project overview text or empty string if not found
|
338
377
|
"""
|
339
378
|
try:
|
340
379
|
# Try to find existing project by name first
|
341
|
-
project = await self.find_existing_project_by_name(
|
342
|
-
|
380
|
+
project = await self.find_existing_project_by_name(
|
381
|
+
project_info["projectName"]
|
382
|
+
)
|
383
|
+
|
343
384
|
if not project:
|
344
|
-
self.logger.warning(
|
385
|
+
self.logger.warning(
|
386
|
+
f"Project '{project_info['projectName']}' not found in database"
|
387
|
+
)
|
345
388
|
return ""
|
346
|
-
|
389
|
+
|
347
390
|
# Get overview for the project using project.id
|
348
391
|
overview_result = await self.db_manager.get_project_overview(project.id)
|
349
392
|
if overview_result:
|