Skip to content

Commit 8ad1461

Browse files
committed
fix(search): paginate tool responses (#48)
1 parent 956be90 commit 8ad1461

File tree

6 files changed

+279
-13
lines changed

6 files changed

+279
-13
lines changed

src/code_index_mcp/server.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import logging
1616
from contextlib import asynccontextmanager
1717
from dataclasses import dataclass
18-
from typing import AsyncIterator, Dict, Any, List
18+
from typing import AsyncIterator, Dict, Any, List, Optional
1919

2020
# Third-party imports
2121
from mcp.server.fastmcp import FastMCP, Context
@@ -164,10 +164,12 @@ def search_code_advanced(
164164
context_lines: int = 0,
165165
file_pattern: str = None,
166166
fuzzy: bool = False,
167-
regex: bool = None
167+
regex: bool = None,
168+
start_index: int = 0,
169+
max_results: Optional[int] = 10
168170
) -> Dict[str, Any]:
169171
"""
170-
Search for a code pattern in the project using an advanced, fast tool.
172+
Search for a code pattern in the project using an advanced, fast tool with pagination support.
171173
172174
This tool automatically selects the best available command-line search tool
173175
(like ugrep, ripgrep, ag, or grep) for maximum performance.
@@ -195,9 +197,15 @@ def search_code_advanced(
195197
- If False, forces literal string search
196198
- If None (default), automatically detects regex patterns and enables regex for patterns like "ERROR|WARN"
197199
The pattern will always be validated for safety to prevent ReDoS attacks.
200+
start_index: Zero-based offset into the flattened match list. Use to fetch subsequent pages.
201+
max_results: Maximum number of matches to return (default 10). Pass None to retrieve all matches.
198202
199203
Returns:
200-
A dictionary containing the search results or an error message.
204+
A dictionary containing:
205+
- results: List of matches with file, line, and text keys.
206+
- pagination: Metadata with total_matches, returned, start_index, end_index, has_more,
207+
and optionally max_results.
208+
If an error occurs, an error message is returned instead.
201209
202210
"""
203211
return SearchService(ctx).search_code(
@@ -206,7 +214,9 @@ def search_code_advanced(
206214
context_lines=context_lines,
207215
file_pattern=file_pattern,
208216
fuzzy=fuzzy,
209-
regex=regex
217+
regex=regex,
218+
start_index=start_index,
219+
max_results=max_results
210220
)
211221

212222
@mcp.tool()

src/code_index_mcp/services/search_service.py

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
from pathlib import Path
9-
from typing import Any, Dict, List, Optional
9+
from typing import Any, Dict, List, Optional, Tuple
1010

1111
from .base_service import BaseService
1212
from ..utils import FileFilter, ResponseFormatter, ValidationHelper
@@ -20,14 +20,16 @@ def __init__(self, ctx):
2020
super().__init__(ctx)
2121
self.file_filter = self._create_file_filter()
2222

23-
def search_code( # pylint: disable=too-many-arguments
23+
def search_code( # pylint: disable=too-many-arguments, too-many-locals
2424
self,
2525
pattern: str,
2626
case_sensitive: bool = True,
2727
context_lines: int = 0,
2828
file_pattern: Optional[str] = None,
2929
fuzzy: bool = False,
30-
regex: Optional[bool] = None
30+
regex: Optional[bool] = None,
31+
start_index: int = 0,
32+
max_results: Optional[int] = 10
3133
) -> Dict[str, Any]:
3234
"""Search for code patterns in the project."""
3335
self._require_project_setup()
@@ -44,6 +46,10 @@ def search_code( # pylint: disable=too-many-arguments
4446
if error:
4547
raise ValueError(f"Invalid file pattern: {error}")
4648

49+
pagination_error = ValidationHelper.validate_pagination(start_index, max_results)
50+
if pagination_error:
51+
raise ValueError(pagination_error)
52+
4753
if not self.settings:
4854
raise ValueError("Settings not available")
4955

@@ -64,7 +70,15 @@ def search_code( # pylint: disable=too-many-arguments
6470
regex=regex
6571
)
6672
filtered = self._filter_results(results)
67-
return ResponseFormatter.search_results_response(filtered)
73+
formatted_results, pagination = self._paginate_results(
74+
filtered,
75+
start_index=start_index,
76+
max_results=max_results
77+
)
78+
return ResponseFormatter.search_results_response(
79+
formatted_results,
80+
pagination
81+
)
6882
except Exception as exc:
6983
raise ValueError(f"Search failed using '{strategy.name}': {exc}") from exc
7084

@@ -168,3 +182,88 @@ def _filter_results(self, results: Dict[str, Any]) -> Dict[str, Any]:
168182
continue
169183

170184
return filtered
185+
186+
def _paginate_results(
187+
self,
188+
results: Dict[str, Any],
189+
start_index: int,
190+
max_results: Optional[int]
191+
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
192+
"""Apply pagination to search results and format them for responses."""
193+
total_matches = 0
194+
for matches in results.values():
195+
if isinstance(matches, (list, tuple)):
196+
total_matches += len(matches)
197+
198+
effective_start = min(max(start_index, 0), total_matches)
199+
200+
if total_matches == 0 or effective_start >= total_matches:
201+
pagination = self._build_pagination_metadata(
202+
total_matches=total_matches,
203+
returned=0,
204+
start_index=effective_start,
205+
max_results=max_results
206+
)
207+
return [], pagination
208+
209+
collected: List[Dict[str, Any]] = []
210+
current_index = 0
211+
212+
sorted_items = sorted(
213+
(
214+
(path, matches)
215+
for path, matches in results.items()
216+
if isinstance(path, str) and isinstance(matches, (list, tuple))
217+
),
218+
key=lambda item: item[0]
219+
)
220+
221+
for path, matches in sorted_items:
222+
sorted_matches = sorted(
223+
(match for match in matches if isinstance(match, (list, tuple)) and len(match) >= 2),
224+
key=lambda pair: pair[0]
225+
)
226+
227+
for line_number, content, *_ in sorted_matches:
228+
if current_index >= effective_start:
229+
if max_results is None or len(collected) < max_results:
230+
collected.append({
231+
"file": path,
232+
"line": line_number,
233+
"text": content
234+
})
235+
else:
236+
break
237+
current_index += 1
238+
if max_results is not None and len(collected) >= max_results:
239+
break
240+
241+
pagination = self._build_pagination_metadata(
242+
total_matches=total_matches,
243+
returned=len(collected),
244+
start_index=effective_start,
245+
max_results=max_results
246+
)
247+
return collected, pagination
248+
249+
@staticmethod
250+
def _build_pagination_metadata(
251+
total_matches: int,
252+
returned: int,
253+
start_index: int,
254+
max_results: Optional[int]
255+
) -> Dict[str, Any]:
256+
"""Construct pagination metadata for search responses."""
257+
end_index = start_index + returned
258+
metadata: Dict[str, Any] = {
259+
"total_matches": total_matches,
260+
"returned": returned,
261+
"start_index": start_index,
262+
"has_more": end_index < total_matches
263+
}
264+
265+
if max_results is not None:
266+
metadata["max_results"] = max_results
267+
268+
metadata["end_index"] = end_index
269+
return metadata

src/code_index_mcp/utils/response_formatter.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ def file_list_response(files: List[str], status_message: str) -> Dict[str, Any]:
132132
}
133133

134134
@staticmethod
135-
def search_results_response(results: List[Dict[str, Any]]) -> Dict[str, Any]:
135+
def search_results_response(
136+
results: List[Dict[str, Any]],
137+
pagination: Optional[Dict[str, Any]] = None
138+
) -> Dict[str, Any]:
136139
"""
137140
Format search results response.
138141
@@ -142,9 +145,14 @@ def search_results_response(results: List[Dict[str, Any]]) -> Dict[str, Any]:
142145
Returns:
143146
Formatted search results response
144147
"""
145-
return {
148+
response = {
146149
"results": results
147150
}
151+
152+
if pagination is not None:
153+
response["pagination"] = pagination
154+
155+
return response
148156

149157
@staticmethod
150158
def config_response(config_data: Dict[str, Any]) -> str:
@@ -361,4 +369,4 @@ def settings_info_response(
361369
if message:
362370
response["message"] = message
363371

364-
return response
372+
return response

src/code_index_mcp/utils/validation.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,35 @@ def validate_search_pattern(pattern: str, regex: bool = False) -> Optional[str]:
158158

159159
return None
160160

161+
@staticmethod
162+
def validate_pagination(start_index: int, max_results: Optional[int]) -> Optional[str]:
163+
"""
164+
Validate pagination parameters for search queries.
165+
166+
Args:
167+
start_index: The index of the first result to include.
168+
max_results: The maximum number of results to return.
169+
170+
Returns:
171+
Error message if validation fails, None if valid.
172+
"""
173+
if not isinstance(start_index, int):
174+
return "start_index must be an integer"
175+
176+
if start_index < 0:
177+
return "start_index cannot be negative"
178+
179+
if max_results is None:
180+
return None
181+
182+
if not isinstance(max_results, int):
183+
return "max_results must be an integer when provided"
184+
185+
if max_results <= 0:
186+
return "max_results must be greater than zero when provided"
187+
188+
return None
189+
161190
@staticmethod
162191
def validate_file_extensions(extensions: List[str]) -> Optional[str]:
163192
"""
@@ -204,4 +233,4 @@ def sanitize_file_path(file_path: str) -> str:
204233
# Remove any leading slashes to ensure relative path
205234
sanitized = sanitized.lstrip('/')
206235

207-
return sanitized
236+
return sanitized
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Tests for search result pagination formatting."""
2+
from pathlib import Path as _TestPath
3+
from types import SimpleNamespace
4+
import sys
5+
6+
ROOT = _TestPath(__file__).resolve().parents[2]
7+
SRC_PATH = ROOT / 'src'
8+
if str(SRC_PATH) not in sys.path:
9+
sys.path.insert(0, str(SRC_PATH))
10+
11+
from code_index_mcp.services.search_service import SearchService
12+
13+
14+
def _create_service() -> SearchService:
15+
ctx = SimpleNamespace(
16+
request_context=SimpleNamespace(
17+
lifespan_context=SimpleNamespace(base_path="", settings=None)
18+
)
19+
)
20+
return SearchService(ctx)
21+
22+
23+
def test_paginate_results_default_ordering():
24+
service = _create_service()
25+
26+
raw_results = {
27+
"b/file.py": [(12, "second match"), (3, "first match")],
28+
"a/file.py": [(8, "another file")],
29+
}
30+
31+
formatted, pagination = service._paginate_results(
32+
raw_results,
33+
start_index=0,
34+
max_results=None,
35+
)
36+
37+
assert pagination == {
38+
"total_matches": 3,
39+
"returned": 3,
40+
"start_index": 0,
41+
"has_more": False,
42+
"end_index": 3,
43+
}
44+
45+
assert formatted == [
46+
{"file": "a/file.py", "line": 8, "text": "another file"},
47+
{"file": "b/file.py", "line": 3, "text": "first match"},
48+
{"file": "b/file.py", "line": 12, "text": "second match"},
49+
]
50+
51+
52+
def test_paginate_results_with_start_and_limit():
53+
service = _create_service()
54+
55+
raw_results = {
56+
"b/file.py": [(5, "line five"), (6, "line six")],
57+
"a/file.py": [(1, "line one"), (2, "line two")],
58+
}
59+
60+
formatted, pagination = service._paginate_results(
61+
raw_results,
62+
start_index=1,
63+
max_results=2,
64+
)
65+
66+
assert pagination == {
67+
"total_matches": 4,
68+
"returned": 2,
69+
"start_index": 1,
70+
"has_more": True,
71+
"max_results": 2,
72+
"end_index": 3,
73+
}
74+
75+
assert formatted == [
76+
{"file": "a/file.py", "line": 2, "text": "line two"},
77+
{"file": "b/file.py", "line": 5, "text": "line five"},
78+
]
79+
80+
81+
def test_paginate_results_when_start_beyond_total():
82+
service = _create_service()
83+
84+
formatted, pagination = service._paginate_results(
85+
{"only/file.py": [(1, "match")]},
86+
start_index=10,
87+
max_results=5,
88+
)
89+
90+
assert formatted == []
91+
assert pagination == {
92+
"total_matches": 1,
93+
"returned": 0,
94+
"start_index": 1,
95+
"has_more": False,
96+
"max_results": 5,
97+
"end_index": 1,
98+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Tests for pagination validation helper."""
2+
from pathlib import Path as _TestPath
3+
import sys
4+
5+
ROOT = _TestPath(__file__).resolve().parents[2]
6+
SRC_PATH = ROOT / 'src'
7+
if str(SRC_PATH) not in sys.path:
8+
sys.path.insert(0, str(SRC_PATH))
9+
10+
from code_index_mcp.utils.validation import ValidationHelper
11+
12+
13+
def test_validate_pagination_accepts_valid_values():
14+
assert ValidationHelper.validate_pagination(0, None) is None
15+
assert ValidationHelper.validate_pagination(5, 10) is None
16+
17+
18+
def test_validate_pagination_rejects_invalid_values():
19+
assert ValidationHelper.validate_pagination(-1, None) == "start_index cannot be negative"
20+
assert ValidationHelper.validate_pagination(0, 0) == "max_results must be greater than zero when provided"
21+
assert ValidationHelper.validate_pagination(0, "a") == "max_results must be an integer when provided"
22+
assert ValidationHelper.validate_pagination("a", None) == "start_index must be an integer"

0 commit comments

Comments
 (0)