Skip to content

Commit fc281f7

Browse files
committed
feat: MCP write tools -- add_repository, index, directories, delete (OPE-165)
4 new MCP tools so we can manage repos without leaving the conversation: - add_repository: POST /repos (name, git_url, branch) - get_repo_directories: GET /repos/{repo_id}/directories - index_repository: POST /repos/{repo_id}/index (with optional include_paths) - delete_repository: DELETE /repos/{repo_id} Also adds api_delete to the HTTP client. All 45 tests pass, lint clean.
1 parent c20384e commit fc281f7

4 files changed

Lines changed: 193 additions & 1 deletion

File tree

mcp-server/api_client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ 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+
return response.json()
67+
68+
6169
async def close_client() -> None:
6270
"""Close the persistent client. Call on server shutdown."""
6371
global _client

mcp-server/handlers.py

Lines changed: 89 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,90 @@ 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("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+
# Build query params
134+
params = {}
135+
if include_paths:
136+
params["include_paths"] = include_paths
137+
result = await api_post(
138+
f"/repos/{repo_id}/index",
139+
json=params if params else {},
140+
)
141+
status = result.get("status", "unknown")
142+
fn_count = result.get("function_count", result.get("functions_indexed", 0))
143+
file_count = result.get("file_count", result.get("files_indexed", 0))
144+
lines = [
145+
"Indexing complete.",
146+
f"Status: {status}",
147+
f"Files indexed: {file_count}",
148+
f"Functions extracted: {fn_count}",
149+
]
150+
if include_paths:
151+
lines.append(f"Subset: {', '.join(include_paths)}")
152+
lines.append(
153+
f"\nYou can now use search_code(repo_id='{repo_id}') "
154+
"to search this codebase."
155+
)
156+
return "\n".join(lines)
157+
158+
159+
async def _handle_delete_repository(args: dict[str, Any]) -> str:
160+
repo_id = args["repo_id"]
161+
result = await api_delete(f"/repos/{repo_id}")
162+
msg = result.get("message", "Repository deleted.")
163+
return f"{msg}\nRepo ID `{repo_id}` has been removed."
164+
165+
82166
# Tool name -> handler mapping
83167
_HANDLERS: dict[str, Any] = {
84168
"search_code": _handle_search,
@@ -88,6 +172,10 @@ async def _handle_dna(args: dict[str, Any]) -> str:
88172
"analyze_impact": _handle_impact,
89173
"get_repository_insights": _handle_insights,
90174
"get_codebase_dna": _handle_dna,
175+
"add_repository": _handle_add_repository,
176+
"get_repo_directories": _handle_get_repo_directories,
177+
"index_repository": _handle_index_repository,
178+
"delete_repository": _handle_delete_repository,
91179
}
92180

93181

mcp-server/tests/test_tools.py

Lines changed: 4 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

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)