Skip to content

Commit 194b9bb

Browse files
committed
refactor: restructure MCP server from 1 file to 6 focused modules (OPE-94, OPE-91)
Split 389-line server.py into focused modules: - config.py (16 lines) -- env config, no decorative headers - api_client.py (59 lines) -- persistent httpx client, reused across calls - tools.py (146 lines) -- tool schema definitions - formatters.py (175 lines) -- response -> markdown, independently testable - handlers.py (104 lines) -- dispatch + API calls + safe error messages - server.py (54 lines) -- bootstrap only Security fixes: - Remove hardcoded 'dev-secret-key' default (empty string, fails loud) - Remove 'dev-secret-key' from .env.example - Add local .gitignore for venv/, __pycache__/, .env - Safe error messages: no httpx internals leaked, includes tool name + repo_id CLAUDE.md compliance: - Remove emoji from cached result indicators (was lightning bolt) - All files under 200 lines Note: search endpoint is already correct (/api/v1/search uses query expansion + reranking). The 'uses v1 search' finding in OPE-91 is stale -- there is only one search endpoint and it already uses the latest engine.
1 parent 1de8893 commit 194b9bb

8 files changed

Lines changed: 535 additions & 378 deletions

File tree

mcp-server/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Backend API Configuration
22
BACKEND_API_URL=http://localhost:8000
3-
API_KEY=dev-secret-key
3+
API_KEY=your-api-key-here

mcp-server/.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Python
2+
__pycache__/
3+
*.pyc
4+
*.pyo
5+
6+
# Virtual environment
7+
venv/
8+
9+
# Environment (secrets)
10+
.env
11+
.env.local

mcp-server/api_client.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Persistent HTTP client for backend API communication.
2+
3+
Uses a module-level client to avoid creating new TCP connections per tool call.
4+
The client is initialized lazily on first use and reused for all subsequent calls.
5+
"""
6+
from typing import Any, Optional
7+
8+
import httpx
9+
10+
from config import BACKEND_API_URL, API_KEY
11+
12+
13+
# Persistent client reused across all tool calls
14+
_client: Optional[httpx.AsyncClient] = None
15+
16+
17+
def _get_headers() -> dict[str, str]:
18+
"""Build auth headers. Warns if no API key is configured."""
19+
if not API_KEY:
20+
raise ValueError(
21+
"No API_KEY configured. Set API_KEY in .env or environment."
22+
)
23+
return {"Authorization": f"Bearer {API_KEY}"}
24+
25+
26+
async def get_client() -> httpx.AsyncClient:
27+
"""Get or create the persistent HTTP client."""
28+
global _client
29+
if _client is None or _client.is_closed:
30+
_client = httpx.AsyncClient(
31+
base_url=BACKEND_API_URL,
32+
timeout=120.0,
33+
headers=_get_headers(),
34+
)
35+
return _client
36+
37+
38+
async def api_get(path: str, **kwargs: Any) -> dict:
39+
"""Make a GET request to the backend API."""
40+
client = await get_client()
41+
response = await client.get(path, **kwargs)
42+
response.raise_for_status()
43+
return response.json()
44+
45+
46+
async def api_post(path: str, json: dict, **kwargs: Any) -> dict:
47+
"""Make a POST request to the backend API."""
48+
client = await get_client()
49+
response = await client.post(path, json=json, **kwargs)
50+
response.raise_for_status()
51+
return response.json()
52+
53+
54+
async def close_client() -> None:
55+
"""Close the persistent client. Call on server shutdown."""
56+
global _client
57+
if _client and not _client.is_closed:
58+
await _client.aclose()
59+
_client = None

mcp-server/config.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
1-
"""
2-
API Configuration - Single Source of Truth for API Versioning
1+
"""MCP server configuration from environment variables."""
2+
import os
33

4-
Change API_VERSION here to update all API calls across the MCP server.
5-
Example: "v1" -> "v2" will change /api/v1/* to /api/v2/*
6-
"""
4+
from dotenv import load_dotenv
75

8-
# =============================================================================
9-
# API VERSION CONFIGURATION
10-
# =============================================================================
6+
load_dotenv()
117

128
API_VERSION = "v1"
9+
API_PREFIX = f"/api/{API_VERSION}"
1310

14-
# =============================================================================
15-
# DERIVED PREFIXES (auto-calculated from version)
16-
# =============================================================================
11+
BACKEND_BASE_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000")
12+
BACKEND_API_URL = f"{BACKEND_BASE_URL}{API_PREFIX}"
13+
API_KEY = os.getenv("API_KEY", "")
1714

18-
# Current versioned API prefix: /api/v1
19-
API_PREFIX = f"/api/{API_VERSION}"
15+
SERVER_NAME = "codeintel-mcp"
16+
SERVER_VERSION = "0.3.0"

mcp-server/formatters.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Response formatters that convert API responses to markdown.
2+
3+
Each formatter is a pure function: takes API response dict, returns markdown string.
4+
This makes them independently testable without any HTTP calls.
5+
"""
6+
7+
8+
def format_search_results(result: dict) -> str:
9+
"""Format semantic search results as markdown."""
10+
count = result.get("count", 0)
11+
cached = " (cached)" if result.get("cached") else ""
12+
output = f"# Code Search Results\n\nFound {count} results{cached}\n\n"
13+
14+
if not result.get("results"):
15+
return output + "No results found.\n"
16+
17+
for idx, res in enumerate(result["results"], 1):
18+
score = res.get("score", 0) * 100
19+
name = res.get("name", "unknown")
20+
file_path = res.get("file_path", "unknown")
21+
file_type = res.get("type", "unknown")
22+
lang = res.get("language", "unknown")
23+
line_start = res.get("line_start", 0)
24+
line_end = res.get("line_end", 0)
25+
code = res.get("code", "")
26+
27+
output += f"## {idx}. {name} ({score:.0f}% match)\n"
28+
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"
32+
33+
return output
34+
35+
36+
def format_repositories(result: dict) -> str:
37+
"""Format repository listing as markdown."""
38+
output = "# Indexed Repositories\n\n"
39+
40+
if not result.get("repositories"):
41+
return output + "No repositories indexed yet.\n"
42+
43+
for repo in result["repositories"]:
44+
output += f"### {repo.get('name', 'unknown')}\n"
45+
output += f"- **ID:** `{repo.get('id')}`\n"
46+
output += f"- **Status:** {repo.get('status', 'unknown')}\n"
47+
output += f"- **Functions:** {repo.get('file_count', 0):,}\n"
48+
output += f"- **Branch:** {repo.get('branch', 'main')}\n\n"
49+
50+
return output
51+
52+
53+
def format_dependency_graph(result: dict) -> str:
54+
"""Format dependency graph analysis as markdown."""
55+
nodes = result.get("nodes", [])
56+
edges = result.get("edges", [])
57+
metrics = result.get("metrics", {})
58+
59+
output = "# Dependency Graph Analysis\n\n"
60+
output += f"**Total Files:** {len(nodes)}\n"
61+
output += f"**Total Dependencies:** {metrics.get('total_edges', len(edges))}\n"
62+
output += f"**Avg Dependencies per File:** {metrics.get('avg_dependencies', 0):.1f}\n\n"
63+
64+
# Most-imported files (highest number of dependents)
65+
dependent_count: dict[str, int] = {}
66+
for edge in edges:
67+
target = edge.get("target", "")
68+
dependent_count[target] = dependent_count.get(target, 0) + 1
69+
70+
if dependent_count:
71+
sorted_deps = sorted(
72+
dependent_count.items(), key=lambda x: x[1], reverse=True
73+
)[:5]
74+
output += "## Most Critical Files (High Impact)\n\n"
75+
for file, count in sorted_deps:
76+
output += f"- `{file}` - **{count} dependents**\n"
77+
output += "\n"
78+
79+
high_import = [n for n in nodes if n.get("imports", 0) >= 3]
80+
if high_import:
81+
output += "## Files with Most Imports\n\n"
82+
for f in sorted(high_import, key=lambda x: x.get("imports", 0), reverse=True)[:5]:
83+
output += f"- `{f['id']}` - imports {f['imports']} files\n"
84+
85+
return output
86+
87+
88+
def format_code_style(result: dict) -> str:
89+
"""Format code style analysis as markdown."""
90+
summary = result.get("summary", {})
91+
output = "# Code Style Analysis\n\n"
92+
output += f"**Files Analyzed:** {summary.get('total_files_analyzed', 0)}\n"
93+
output += f"**Functions:** {summary.get('total_functions', 0)}\n"
94+
output += f"**Async Adoption:** {summary.get('async_adoption', '0%')}\n"
95+
output += f"**Type Hints:** {summary.get('type_hints_usage', '0%')}\n\n"
96+
97+
naming = result.get("naming_conventions", {}).get("functions")
98+
if naming:
99+
output += "## Function Naming Conventions\n\n"
100+
for conv, info in naming.items():
101+
output += f"- **{conv}:** {info['percentage']} ({info['count']} functions)\n"
102+
output += "\n"
103+
104+
top_imports = result.get("top_imports")
105+
if top_imports:
106+
output += "## Most Common Imports\n\n"
107+
for item in top_imports[:10]:
108+
output += f"- `{item['module']}` (used {item['count']}x)\n"
109+
110+
return output
111+
112+
113+
def format_impact_analysis(result: dict) -> str:
114+
"""Format file impact analysis as markdown."""
115+
output = f"# Impact Analysis: {result.get('file', 'unknown')}\n\n"
116+
output += f"**Risk Level:** {result.get('risk_level', 'unknown').upper()}\n"
117+
output += f"**Impact Summary:** {result.get('impact_summary', '')}\n\n"
118+
119+
deps = result.get("direct_dependencies", [])
120+
output += f"## Dependencies ({len(deps)})\n"
121+
output += "Files this file imports:\n"
122+
for dep in deps[:10]:
123+
output += f"- `{dep}`\n"
124+
output += "\n"
125+
126+
dependents = result.get("all_dependents", [])
127+
output += f"## Dependents ({len(dependents)})\n"
128+
output += "Files that would be affected by changes:\n"
129+
for dep in dependents[:15]:
130+
output += f"- `{dep}`\n"
131+
132+
test_files = result.get("test_files")
133+
if test_files:
134+
output += "\n## Related Tests\n"
135+
for test in test_files:
136+
output += f"- `{test}`\n"
137+
138+
return output
139+
140+
141+
def format_repository_insights(result: dict) -> str:
142+
"""Format repository insights as markdown."""
143+
output = f"# Repository Insights: {result.get('name', 'unknown')}\n\n"
144+
output += f"**Status:** {result.get('status', 'unknown')}\n"
145+
output += f"**Functions Indexed:** {result.get('functions_indexed', 0):,}\n"
146+
output += f"**Total Files:** {result.get('total_files', 0)}\n"
147+
output += f"**Total Dependencies:** {result.get('total_dependencies', 0)}\n\n"
148+
149+
metrics = result.get("graph_metrics", {})
150+
critical = metrics.get("most_critical_files")
151+
if critical:
152+
output += "## Most Critical Files\n"
153+
for item in critical[:5]:
154+
output += f"- `{item['file']}` ({item['dependents']} dependents)\n"
155+
156+
return output
157+
158+
159+
def format_codebase_dna(result: dict) -> str:
160+
"""Format codebase DNA extraction as markdown."""
161+
dna_markdown = result.get("dna", "")
162+
cached = " (cached)" if result.get("cached") else ""
163+
164+
output = f"# Codebase DNA{cached}\n\n"
165+
output += "**Use this information to write code that matches existing patterns.**\n\n"
166+
output += dna_markdown
167+
output += "\n---\n"
168+
output += "**Instructions:** When generating code for this codebase:\n"
169+
output += "1. Follow the auth patterns shown above\n"
170+
output += "2. Use the service layer structure (singletons in dependencies.py)\n"
171+
output += "3. Match the database conventions (ID types, timestamps, RLS)\n"
172+
output += "4. Use the logging patterns shown\n"
173+
output += "5. Follow the naming conventions\n"
174+
175+
return output

mcp-server/handlers.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Tool handler dispatch.
2+
3+
Maps tool names to their API calls and response formatters.
4+
Each handler follows the same pattern: call API, format response.
5+
Error handling is centralized in call_tool() so individual handlers stay clean.
6+
"""
7+
from typing import Any
8+
9+
import httpx
10+
import mcp.types as types
11+
12+
from api_client import api_get, api_post
13+
from formatters import (
14+
format_codebase_dna,
15+
format_code_style,
16+
format_dependency_graph,
17+
format_impact_analysis,
18+
format_repositories,
19+
format_repository_insights,
20+
format_search_results,
21+
)
22+
23+
24+
async def _handle_search(args: dict[str, Any]) -> str:
25+
result = await api_post("/search", json=args)
26+
return format_search_results(result)
27+
28+
29+
async def _handle_list_repositories(args: dict[str, Any]) -> str:
30+
result = await api_get("/repos")
31+
return format_repositories(result)
32+
33+
34+
async def _handle_dependency_graph(args: dict[str, Any]) -> str:
35+
result = await api_get(f"/repos/{args['repo_id']}/dependencies")
36+
return format_dependency_graph(result)
37+
38+
39+
async def _handle_code_style(args: dict[str, Any]) -> str:
40+
result = await api_get(f"/repos/{args['repo_id']}/style-analysis")
41+
return format_code_style(result)
42+
43+
44+
async def _handle_impact(args: dict[str, Any]) -> str:
45+
result = await api_post(
46+
f"/repos/{args['repo_id']}/impact",
47+
json={"repo_id": args["repo_id"], "file_path": args["file_path"]},
48+
)
49+
return format_impact_analysis(result)
50+
51+
52+
async def _handle_insights(args: dict[str, Any]) -> str:
53+
result = await api_get(f"/repos/{args['repo_id']}/insights")
54+
return format_repository_insights(result)
55+
56+
57+
async def _handle_dna(args: dict[str, Any]) -> str:
58+
result = await api_get(f"/repos/{args['repo_id']}/dna?format=markdown")
59+
return format_codebase_dna(result)
60+
61+
62+
# Tool name -> handler mapping
63+
_HANDLERS: dict[str, Any] = {
64+
"search_code": _handle_search,
65+
"list_repositories": _handle_list_repositories,
66+
"get_dependency_graph": _handle_dependency_graph,
67+
"analyze_code_style": _handle_code_style,
68+
"analyze_impact": _handle_impact,
69+
"get_repository_insights": _handle_insights,
70+
"get_codebase_dna": _handle_dna,
71+
}
72+
73+
74+
def _safe_error_message(tool_name: str, args: dict[str, Any], error: Exception) -> str:
75+
"""Build error message with context but without leaking internal details."""
76+
repo_id = args.get("repo_id", "unknown")
77+
if isinstance(error, httpx.HTTPStatusError):
78+
status = error.response.status_code
79+
return f"Backend returned {status} for tool '{tool_name}' (repo: {repo_id})"
80+
if isinstance(error, httpx.TimeoutException):
81+
return f"Request timed out for tool '{tool_name}' (repo: {repo_id})"
82+
if isinstance(error, httpx.ConnectError):
83+
return f"Cannot connect to backend for tool '{tool_name}'. Is the server running?"
84+
if isinstance(error, ValueError):
85+
return str(error)
86+
return f"Unexpected error in tool '{tool_name}' (repo: {repo_id})"
87+
88+
89+
async def call_tool(
90+
name: str, arguments: dict[str, Any] | None
91+
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
92+
"""Dispatch a tool call to the appropriate handler."""
93+
args = arguments or {}
94+
95+
handler = _HANDLERS.get(name)
96+
if handler is None:
97+
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
98+
99+
try:
100+
text = await handler(args)
101+
return [types.TextContent(type="text", text=text)]
102+
except Exception as e:
103+
msg = _safe_error_message(name, args, e)
104+
return [types.TextContent(type="text", text=msg)]

0 commit comments

Comments
 (0)