Skip to content

Commit 5ae2eba

Browse files
authored
Merge pull request #287 from DevanshuNEU/feat/mcp-write-tools
feat: MCP write tools -- add_repository, index, directories, delete (OPE-165)
2 parents c20384e + 429a7ba commit 5ae2eba

4 files changed

Lines changed: 205 additions & 1 deletion

File tree

mcp-server/api_client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ async def api_post(path: str, json: dict, **kwargs: Any) -> dict:
5858
return response.json()
5959

6060

61+
async def api_delete(path: str, **kwargs: Any) -> dict:
62+
"""Make a DELETE request to the backend API."""
63+
client = await get_client()
64+
response = await client.delete(path, **kwargs)
65+
response.raise_for_status()
66+
if response.status_code == 204 or not response.content:
67+
return {}
68+
return response.json()
69+
70+
6171
async def close_client() -> None:
6272
"""Close the persistent client. Call on server shutdown."""
6373
global _client

mcp-server/handlers.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
logger = logging.getLogger(__name__)
1414

15-
from api_client import api_get, api_post
15+
from api_client import api_get, api_post, api_delete
1616
from formatters import (
1717
format_codebase_dna,
1818
format_code_style,
@@ -79,6 +79,99 @@ async def _handle_dna(args: dict[str, Any]) -> str:
7979
return format_codebase_dna(result)
8080

8181

82+
# --- Write tool handlers ---
83+
84+
async def _handle_add_repository(args: dict[str, Any]) -> str:
85+
payload = {
86+
"name": args["name"],
87+
"git_url": args["git_url"],
88+
"branch": args.get("branch", "main"),
89+
}
90+
result = await api_post("/repos", json=payload)
91+
repo_id = result.get("repo_id", "unknown")
92+
name = result.get("name", args["name"])
93+
status = result.get("status", "added")
94+
needs_selection = result.get("needs_directory_selection", False)
95+
lines = [
96+
f"Repository '{name}' added successfully.",
97+
f"ID: `{repo_id}`",
98+
f"Status: {status}",
99+
]
100+
if needs_selection:
101+
lines.append(
102+
"\nThis repo may benefit from subset indexing. "
103+
"Use get_repo_directories to see available directories, "
104+
"then index_repository with include_paths."
105+
)
106+
else:
107+
lines.append(
108+
f"\nReady to index. Run: index_repository(repo_id='{repo_id}')"
109+
)
110+
return "\n".join(lines)
111+
112+
113+
async def _handle_get_repo_directories(args: dict[str, Any]) -> str:
114+
result = await api_get(f"/repos/{args['repo_id']}/directories")
115+
dirs = result.get("directories", [])
116+
if not dirs:
117+
return "No directories found (repo may be flat or not yet cloned)."
118+
lines = ["# Repository Directories\n"]
119+
for d in dirs:
120+
name = d.get("name", d.get("path", "unknown"))
121+
count = d.get("file_count", 0)
122+
lines.append(f"- **{name}/** -- {count} code files")
123+
lines.append(
124+
"\nTo index specific directories, use index_repository "
125+
"with include_paths=['dir1', 'dir2']."
126+
)
127+
return "\n".join(lines)
128+
129+
130+
async def _handle_index_repository(args: dict[str, Any]) -> str:
131+
repo_id = args["repo_id"]
132+
include_paths = args.get("include_paths")
133+
134+
if include_paths is not None and len(include_paths) == 0:
135+
return "Error: include_paths cannot be empty. Omit it to index the full repo, or provide directory names."
136+
137+
if include_paths:
138+
# Async endpoint supports include_paths for monorepo subset indexing
139+
result = await api_post(
140+
f"/repos/{repo_id}/index/async",
141+
json={"include_paths": include_paths},
142+
)
143+
status = result.get("status", "accepted")
144+
return (
145+
f"Async indexing started for subset: {', '.join(include_paths)}\n"
146+
f"Status: {status}\n"
147+
f"Repo ID: `{repo_id}`\n"
148+
"\nIndexing runs in the background. Use list_repositories "
149+
"to check when status changes to 'indexed'."
150+
)
151+
152+
# Sync endpoint for full-repo indexing
153+
result = await api_post(f"/repos/{repo_id}/index", json={})
154+
status = result.get("status", "unknown")
155+
fn_count = result.get("functions", 0)
156+
lines = [
157+
"Indexing complete.",
158+
f"Status: {status}",
159+
f"Functions extracted: {fn_count}",
160+
]
161+
lines.append(
162+
f"\nYou can now use search_code(repo_id='{repo_id}') "
163+
"to search this codebase."
164+
)
165+
return "\n".join(lines)
166+
167+
168+
async def _handle_delete_repository(args: dict[str, Any]) -> str:
169+
repo_id = args["repo_id"]
170+
result = await api_delete(f"/repos/{repo_id}")
171+
msg = result.get("message", "Repository deleted.")
172+
return f"{msg}\nRepo ID `{repo_id}` has been removed."
173+
174+
82175
# Tool name -> handler mapping
83176
_HANDLERS: dict[str, Any] = {
84177
"search_code": _handle_search,
@@ -88,6 +181,10 @@ async def _handle_dna(args: dict[str, Any]) -> str:
88181
"analyze_impact": _handle_impact,
89182
"get_repository_insights": _handle_insights,
90183
"get_codebase_dna": _handle_dna,
184+
"add_repository": _handle_add_repository,
185+
"get_repo_directories": _handle_get_repo_directories,
186+
"index_repository": _handle_index_repository,
187+
"delete_repository": _handle_delete_repository,
91188
}
92189

93190

mcp-server/tests/test_tools.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
"analyze_impact",
1616
"get_repository_insights",
1717
"get_codebase_dna",
18+
"add_repository",
19+
"get_repo_directories",
20+
"index_repository",
21+
"delete_repository",
1822
}
1923

2024

@@ -53,6 +57,7 @@ def test_repo_tools_require_repo_id(self):
5357
repo_tools = [
5458
"get_dependency_graph", "analyze_code_style",
5559
"analyze_impact", "get_repository_insights", "get_codebase_dna",
60+
"get_repo_directories", "index_repository", "delete_repository",
5661
]
5762
for name in repo_tools:
5863
required = schemas[name].inputSchema.get("required", [])

mcp-server/tools.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,96 @@ def get_tool_schemas() -> list[types.Tool]:
145145
"required": ["repo_id"],
146146
},
147147
),
148+
# --- Write tools ---
149+
types.Tool(
150+
name="add_repository",
151+
description=(
152+
"Add a new repository for indexing. Clones the repo and analyzes "
153+
"its structure. After adding, use get_repo_directories to see "
154+
"available directories, then index_repository to start indexing."
155+
),
156+
inputSchema={
157+
"type": "object",
158+
"properties": {
159+
"git_url": {
160+
"type": "string",
161+
"description": (
162+
"Git clone URL. "
163+
"Example: https://github.com/owner/repo.git"
164+
),
165+
},
166+
"name": {
167+
"type": "string",
168+
"description": "Short name for the repository",
169+
},
170+
"branch": {
171+
"type": "string",
172+
"description": "Branch to clone (default: main)",
173+
"default": "main",
174+
},
175+
},
176+
"required": ["git_url", "name"],
177+
},
178+
),
179+
types.Tool(
180+
name="get_repo_directories",
181+
description=(
182+
"List top-level directories in a cloned repository with file "
183+
"counts. Use this after add_repository to decide which "
184+
"directories to index (useful for monorepos)."
185+
),
186+
inputSchema={
187+
"type": "object",
188+
"properties": {
189+
"repo_id": {
190+
"type": "string",
191+
"description": "Repository identifier",
192+
}
193+
},
194+
"required": ["repo_id"],
195+
},
196+
),
197+
types.Tool(
198+
name="index_repository",
199+
description=(
200+
"Trigger indexing for a repository. Extracts functions, builds "
201+
"embeddings, and enables semantic search. For monorepos, pass "
202+
"include_paths to index only specific directories."
203+
),
204+
inputSchema={
205+
"type": "object",
206+
"properties": {
207+
"repo_id": {
208+
"type": "string",
209+
"description": "Repository identifier",
210+
},
211+
"include_paths": {
212+
"type": "array",
213+
"items": {"type": "string"},
214+
"description": (
215+
"Optional list of directories to index "
216+
"(e.g. ['src', 'lib']). Omit to index everything."
217+
),
218+
},
219+
},
220+
"required": ["repo_id"],
221+
},
222+
),
223+
types.Tool(
224+
name="delete_repository",
225+
description=(
226+
"Delete a repository and all its indexed data. This is "
227+
"irreversible -- the repo must be re-added and re-indexed."
228+
),
229+
inputSchema={
230+
"type": "object",
231+
"properties": {
232+
"repo_id": {
233+
"type": "string",
234+
"description": "Repository identifier",
235+
}
236+
},
237+
"required": ["repo_id"],
238+
},
239+
),
148240
]

0 commit comments

Comments
 (0)