Skip to content

Commit dfe732d

Browse files
committed
feat: upgrade search to v2 API, add 37 tests, bump to 0.4.0 (OPE-91)
- handlers: switch from /search to /search/v2, map max_results to top_k - formatters: support v2 fields (qualified_name, signature, match_reason) - formatters: maintain backward compat with v1 response shape - tests: 37 tests covering config, formatters, handlers, tool schemas - tests: verify no emoji, error message sanitization, dispatch logic - config: bump version 0.3.0 -> 0.4.0 - requirements: add pytest, pytest-asyncio as test dependencies
1 parent 194b9bb commit dfe732d

10 files changed

Lines changed: 442 additions & 9 deletions

File tree

mcp-server/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
API_KEY = os.getenv("API_KEY", "")
1414

1515
SERVER_NAME = "codeintel-mcp"
16-
SERVER_VERSION = "0.3.0"
16+
SERVER_VERSION = "0.4.0"

mcp-server/formatters.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@
66

77

88
def format_search_results(result: dict) -> str:
9-
"""Format semantic search results as markdown."""
10-
count = result.get("count", 0)
9+
"""Format semantic search results as markdown.
10+
11+
Supports both v1 (count/results) and v2 (total/results) response shapes
12+
so the formatter stays resilient across API versions.
13+
"""
14+
total = result.get("total") or result.get("count", 0)
1115
cached = " (cached)" if result.get("cached") else ""
12-
output = f"# Code Search Results\n\nFound {count} results{cached}\n\n"
16+
version = result.get("search_version", "v1")
17+
output = f"# Code Search Results ({version})\n\nFound {total} results{cached}\n\n"
1318

1419
if not result.get("results"):
1520
return output + "No results found.\n"
@@ -18,17 +23,29 @@ def format_search_results(result: dict) -> str:
1823
score = res.get("score", 0) * 100
1924
name = res.get("name", "unknown")
2025
file_path = res.get("file_path", "unknown")
21-
file_type = res.get("type", "unknown")
2226
lang = res.get("language", "unknown")
2327
line_start = res.get("line_start", 0)
2428
line_end = res.get("line_end", 0)
2529
code = res.get("code", "")
2630

2731
output += f"## {idx}. {name} ({score:.0f}% match)\n"
2832
output += f"**File:** `{file_path}`\n"
29-
output += f"**Type:** {file_type} | **Language:** {lang}\n"
30-
output += f"**Lines:** {line_start}-{line_end}\n\n"
31-
output += f"```{lang}\n{code}\n```\n\n"
33+
34+
# v2 adds qualified_name and signature
35+
qualified = res.get("qualified_name")
36+
if qualified and qualified != name:
37+
output += f"**Qualified:** `{qualified}`\n"
38+
signature = res.get("signature")
39+
if signature:
40+
output += f"**Signature:** `{signature}`\n"
41+
42+
output += f"**Language:** {lang} | **Lines:** {line_start}-{line_end}\n"
43+
44+
reason = res.get("match_reason")
45+
if reason:
46+
output += f"**Why:** {reason}\n"
47+
48+
output += f"\n```{lang}\n{code}\n```\n\n"
3249

3350
return output
3451

mcp-server/handlers.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,14 @@
2222

2323

2424
async def _handle_search(args: dict[str, Any]) -> str:
25-
result = await api_post("/search", json=args)
25+
# Map tool schema's max_results to v2 API's top_k
26+
payload = {
27+
"query": args["query"],
28+
"repo_id": args["repo_id"],
29+
"top_k": args.get("max_results", 10),
30+
"use_reranking": True,
31+
}
32+
result = await api_post("/search/v2", json=payload)
2633
return format_search_results(result)
2734

2835

mcp-server/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[tool.pytest.ini_options]
2+
asyncio_mode = "auto"
3+
testpaths = ["tests"]

mcp-server/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ mcp>=1.0.0
33
httpx>=0.27.0
44
python-dotenv>=1.0.0
55
pydantic>=2.0.0
6+
7+
# Test Dependencies
8+
pytest>=8.0.0
9+
pytest-asyncio>=0.23.0

mcp-server/tests/__init__.py

Whitespace-only changes.

mcp-server/tests/test_config.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Tests for MCP server configuration."""
2+
import pytest
3+
import sys
4+
import os
5+
6+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7+
8+
from config import API_PREFIX, SERVER_NAME, SERVER_VERSION, BACKEND_API_URL
9+
10+
11+
class TestConfig:
12+
def test_api_prefix_format(self):
13+
"""API prefix must match /api/v{n} pattern."""
14+
assert API_PREFIX.startswith("/api/v")
15+
16+
def test_server_name(self):
17+
assert SERVER_NAME == "codeintel-mcp"
18+
19+
def test_server_version_semver(self):
20+
"""Version should be semver-like (x.y.z)."""
21+
parts = SERVER_VERSION.split(".")
22+
assert len(parts) == 3
23+
assert all(p.isdigit() for p in parts)
24+
25+
def test_backend_url_has_prefix(self):
26+
"""BACKEND_API_URL should include the API prefix for direct use."""
27+
assert BACKEND_API_URL.endswith(API_PREFIX)
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Tests for response formatters.
2+
3+
Formatters are pure functions (dict -> str) so they're straightforward to test
4+
without any mocking or network calls.
5+
"""
6+
import pytest
7+
import sys
8+
import os
9+
10+
# Add parent directory to path so we can import the modules
11+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12+
13+
from formatters import (
14+
format_search_results,
15+
format_repositories,
16+
format_dependency_graph,
17+
format_code_style,
18+
format_impact_analysis,
19+
format_repository_insights,
20+
format_codebase_dna,
21+
)
22+
23+
24+
# -- Search results (v2 format) --
25+
26+
class TestFormatSearchResults:
27+
def test_empty_results(self):
28+
result = {"total": 0, "results": [], "cached": False, "search_version": "v2"}
29+
output = format_search_results(result)
30+
assert "Found 0 results" in output
31+
assert "No results found" in output
32+
33+
def test_basic_result(self):
34+
result = {
35+
"total": 1,
36+
"cached": False,
37+
"search_version": "v2",
38+
"results": [{
39+
"name": "authenticate",
40+
"qualified_name": "auth.service.authenticate",
41+
"file_path": "backend/auth.py",
42+
"code": "def authenticate(token): ...",
43+
"signature": "def authenticate(token: str) -> User",
44+
"language": "python",
45+
"score": 0.95,
46+
"line_start": 10,
47+
"line_end": 20,
48+
"match_reason": "Semantic match on authentication logic",
49+
}],
50+
}
51+
output = format_search_results(result)
52+
assert "authenticate" in output
53+
assert "95% match" in output
54+
assert "backend/auth.py" in output
55+
assert "auth.service.authenticate" in output
56+
assert "Signature" in output
57+
assert "Why:" in output
58+
assert "(v2)" in output
59+
60+
def test_cached_flag(self):
61+
result = {"total": 0, "results": [], "cached": True, "search_version": "v2"}
62+
output = format_search_results(result)
63+
assert "(cached)" in output
64+
65+
def test_v1_fallback(self):
66+
"""Formatter handles v1-style response with 'count' field."""
67+
result = {"count": 0, "results": []}
68+
output = format_search_results(result)
69+
assert "Found 0 results" in output
70+
assert "(v1)" in output
71+
72+
def test_no_emoji_in_output(self):
73+
"""CLAUDE.md violation check: no emojis anywhere in formatted output."""
74+
result = {"total": 1, "cached": True, "search_version": "v2", "results": [{
75+
"name": "test", "file_path": "test.py", "code": "pass",
76+
"language": "python", "score": 0.5, "line_start": 1, "line_end": 1,
77+
}]}
78+
output = format_search_results(result)
79+
# Lightning bolt was the specific emoji found in OPE-91 audit
80+
assert "\u26a1" not in output
81+
82+
83+
# -- Repositories --
84+
85+
class TestFormatRepositories:
86+
def test_no_repos(self):
87+
output = format_repositories({"repositories": []})
88+
assert "No repositories indexed" in output
89+
90+
def test_repo_listing(self):
91+
output = format_repositories({
92+
"repositories": [{
93+
"id": "abc-123",
94+
"name": "my-project",
95+
"status": "indexed",
96+
"file_count": 1500,
97+
"branch": "main",
98+
}]
99+
})
100+
assert "my-project" in output
101+
assert "abc-123" in output
102+
assert "indexed" in output
103+
assert "1,500" in output
104+
105+
106+
# -- Dependency graph --
107+
108+
class TestFormatDependencyGraph:
109+
def test_empty_graph(self):
110+
output = format_dependency_graph({"nodes": [], "edges": [], "metrics": {}})
111+
assert "Total Files:** 0" in output
112+
113+
def test_critical_files_ranked(self):
114+
output = format_dependency_graph({
115+
"nodes": [{"id": "a.py"}, {"id": "b.py"}],
116+
"edges": [
117+
{"source": "b.py", "target": "a.py"},
118+
{"source": "c.py", "target": "a.py"},
119+
],
120+
"metrics": {"total_edges": 2, "avg_dependencies": 1.0},
121+
})
122+
assert "a.py" in output
123+
assert "2 dependents" in output
124+
125+
126+
# -- Code style --
127+
128+
class TestFormatCodeStyle:
129+
def test_basic_summary(self):
130+
output = format_code_style({
131+
"summary": {
132+
"total_files_analyzed": 50,
133+
"total_functions": 200,
134+
"async_adoption": "35%",
135+
"type_hints_usage": "80%",
136+
},
137+
})
138+
assert "50" in output
139+
assert "200" in output
140+
assert "35%" in output
141+
assert "80%" in output
142+
143+
144+
# -- Impact analysis --
145+
146+
class TestFormatImpactAnalysis:
147+
def test_high_risk(self):
148+
output = format_impact_analysis({
149+
"file": "core/engine.py",
150+
"risk_level": "high",
151+
"impact_summary": "Central dependency",
152+
"direct_dependencies": ["utils.py"],
153+
"all_dependents": ["api.py", "cli.py"],
154+
"test_files": ["test_engine.py"],
155+
})
156+
assert "core/engine.py" in output
157+
assert "HIGH" in output
158+
assert "utils.py" in output
159+
assert "test_engine.py" in output
160+
161+
162+
# -- Repository insights --
163+
164+
class TestFormatRepositoryInsights:
165+
def test_basic_insights(self):
166+
output = format_repository_insights({
167+
"name": "opencodeintel",
168+
"status": "indexed",
169+
"functions_indexed": 500,
170+
"total_files": 80,
171+
"total_dependencies": 120,
172+
})
173+
assert "opencodeintel" in output
174+
assert "500" in output
175+
176+
177+
# -- Codebase DNA --
178+
179+
class TestFormatCodebaseDna:
180+
def test_dna_output(self):
181+
output = format_codebase_dna({
182+
"dna": "## Patterns\n- Uses FastAPI\n- SQLAlchemy ORM",
183+
"cached": False,
184+
})
185+
assert "Codebase DNA" in output
186+
assert "FastAPI" in output
187+
assert "Follow the auth patterns" in output
188+
189+
def test_dna_cached(self):
190+
output = format_codebase_dna({"dna": "test", "cached": True})
191+
assert "(cached)" in output
192+
193+
def test_no_emoji_in_dna(self):
194+
output = format_codebase_dna({"dna": "test", "cached": True})
195+
assert "\u26a1" not in output

0 commit comments

Comments
 (0)