emdash-core 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. emdash_core/__init__.py +3 -0
  2. emdash_core/agent/__init__.py +37 -0
  3. emdash_core/agent/agents.py +225 -0
  4. emdash_core/agent/code_reviewer.py +476 -0
  5. emdash_core/agent/compaction.py +143 -0
  6. emdash_core/agent/context_manager.py +140 -0
  7. emdash_core/agent/events.py +338 -0
  8. emdash_core/agent/handlers.py +224 -0
  9. emdash_core/agent/inprocess_subagent.py +377 -0
  10. emdash_core/agent/mcp/__init__.py +50 -0
  11. emdash_core/agent/mcp/client.py +346 -0
  12. emdash_core/agent/mcp/config.py +302 -0
  13. emdash_core/agent/mcp/manager.py +496 -0
  14. emdash_core/agent/mcp/tool_factory.py +213 -0
  15. emdash_core/agent/prompts/__init__.py +38 -0
  16. emdash_core/agent/prompts/main_agent.py +104 -0
  17. emdash_core/agent/prompts/subagents.py +131 -0
  18. emdash_core/agent/prompts/workflow.py +136 -0
  19. emdash_core/agent/providers/__init__.py +34 -0
  20. emdash_core/agent/providers/base.py +143 -0
  21. emdash_core/agent/providers/factory.py +80 -0
  22. emdash_core/agent/providers/models.py +220 -0
  23. emdash_core/agent/providers/openai_provider.py +463 -0
  24. emdash_core/agent/providers/transformers_provider.py +217 -0
  25. emdash_core/agent/research/__init__.py +81 -0
  26. emdash_core/agent/research/agent.py +143 -0
  27. emdash_core/agent/research/controller.py +254 -0
  28. emdash_core/agent/research/critic.py +428 -0
  29. emdash_core/agent/research/macros.py +469 -0
  30. emdash_core/agent/research/planner.py +449 -0
  31. emdash_core/agent/research/researcher.py +436 -0
  32. emdash_core/agent/research/state.py +523 -0
  33. emdash_core/agent/research/synthesizer.py +594 -0
  34. emdash_core/agent/reviewer_profile.py +475 -0
  35. emdash_core/agent/rules.py +123 -0
  36. emdash_core/agent/runner.py +601 -0
  37. emdash_core/agent/session.py +262 -0
  38. emdash_core/agent/spec_schema.py +66 -0
  39. emdash_core/agent/specification.py +479 -0
  40. emdash_core/agent/subagent.py +397 -0
  41. emdash_core/agent/subagent_prompts.py +13 -0
  42. emdash_core/agent/toolkit.py +482 -0
  43. emdash_core/agent/toolkits/__init__.py +64 -0
  44. emdash_core/agent/toolkits/base.py +96 -0
  45. emdash_core/agent/toolkits/explore.py +47 -0
  46. emdash_core/agent/toolkits/plan.py +55 -0
  47. emdash_core/agent/tools/__init__.py +141 -0
  48. emdash_core/agent/tools/analytics.py +436 -0
  49. emdash_core/agent/tools/base.py +131 -0
  50. emdash_core/agent/tools/coding.py +484 -0
  51. emdash_core/agent/tools/github_mcp.py +592 -0
  52. emdash_core/agent/tools/history.py +13 -0
  53. emdash_core/agent/tools/modes.py +153 -0
  54. emdash_core/agent/tools/plan.py +206 -0
  55. emdash_core/agent/tools/plan_write.py +135 -0
  56. emdash_core/agent/tools/search.py +412 -0
  57. emdash_core/agent/tools/spec.py +341 -0
  58. emdash_core/agent/tools/task.py +262 -0
  59. emdash_core/agent/tools/task_output.py +204 -0
  60. emdash_core/agent/tools/tasks.py +454 -0
  61. emdash_core/agent/tools/traversal.py +588 -0
  62. emdash_core/agent/tools/web.py +179 -0
  63. emdash_core/analytics/__init__.py +5 -0
  64. emdash_core/analytics/engine.py +1286 -0
  65. emdash_core/api/__init__.py +5 -0
  66. emdash_core/api/agent.py +308 -0
  67. emdash_core/api/agents.py +154 -0
  68. emdash_core/api/analyze.py +264 -0
  69. emdash_core/api/auth.py +173 -0
  70. emdash_core/api/context.py +77 -0
  71. emdash_core/api/db.py +121 -0
  72. emdash_core/api/embed.py +131 -0
  73. emdash_core/api/feature.py +143 -0
  74. emdash_core/api/health.py +93 -0
  75. emdash_core/api/index.py +162 -0
  76. emdash_core/api/plan.py +110 -0
  77. emdash_core/api/projectmd.py +210 -0
  78. emdash_core/api/query.py +320 -0
  79. emdash_core/api/research.py +122 -0
  80. emdash_core/api/review.py +161 -0
  81. emdash_core/api/router.py +76 -0
  82. emdash_core/api/rules.py +116 -0
  83. emdash_core/api/search.py +119 -0
  84. emdash_core/api/spec.py +99 -0
  85. emdash_core/api/swarm.py +223 -0
  86. emdash_core/api/tasks.py +109 -0
  87. emdash_core/api/team.py +120 -0
  88. emdash_core/auth/__init__.py +17 -0
  89. emdash_core/auth/github.py +389 -0
  90. emdash_core/config.py +74 -0
  91. emdash_core/context/__init__.py +52 -0
  92. emdash_core/context/models.py +50 -0
  93. emdash_core/context/providers/__init__.py +11 -0
  94. emdash_core/context/providers/base.py +74 -0
  95. emdash_core/context/providers/explored_areas.py +183 -0
  96. emdash_core/context/providers/touched_areas.py +360 -0
  97. emdash_core/context/registry.py +73 -0
  98. emdash_core/context/reranker.py +199 -0
  99. emdash_core/context/service.py +260 -0
  100. emdash_core/context/session.py +352 -0
  101. emdash_core/core/__init__.py +104 -0
  102. emdash_core/core/config.py +454 -0
  103. emdash_core/core/exceptions.py +55 -0
  104. emdash_core/core/models.py +265 -0
  105. emdash_core/core/review_config.py +57 -0
  106. emdash_core/db/__init__.py +67 -0
  107. emdash_core/db/auth.py +134 -0
  108. emdash_core/db/models.py +91 -0
  109. emdash_core/db/provider.py +222 -0
  110. emdash_core/db/providers/__init__.py +5 -0
  111. emdash_core/db/providers/supabase.py +452 -0
  112. emdash_core/embeddings/__init__.py +24 -0
  113. emdash_core/embeddings/indexer.py +534 -0
  114. emdash_core/embeddings/models.py +192 -0
  115. emdash_core/embeddings/providers/__init__.py +7 -0
  116. emdash_core/embeddings/providers/base.py +112 -0
  117. emdash_core/embeddings/providers/fireworks.py +141 -0
  118. emdash_core/embeddings/providers/openai.py +104 -0
  119. emdash_core/embeddings/registry.py +146 -0
  120. emdash_core/embeddings/service.py +215 -0
  121. emdash_core/graph/__init__.py +26 -0
  122. emdash_core/graph/builder.py +134 -0
  123. emdash_core/graph/connection.py +692 -0
  124. emdash_core/graph/schema.py +416 -0
  125. emdash_core/graph/writer.py +667 -0
  126. emdash_core/ingestion/__init__.py +7 -0
  127. emdash_core/ingestion/change_detector.py +150 -0
  128. emdash_core/ingestion/git/__init__.py +5 -0
  129. emdash_core/ingestion/git/commit_analyzer.py +196 -0
  130. emdash_core/ingestion/github/__init__.py +6 -0
  131. emdash_core/ingestion/github/pr_fetcher.py +296 -0
  132. emdash_core/ingestion/github/task_extractor.py +100 -0
  133. emdash_core/ingestion/orchestrator.py +540 -0
  134. emdash_core/ingestion/parsers/__init__.py +10 -0
  135. emdash_core/ingestion/parsers/base_parser.py +66 -0
  136. emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
  137. emdash_core/ingestion/parsers/class_extractor.py +154 -0
  138. emdash_core/ingestion/parsers/function_extractor.py +202 -0
  139. emdash_core/ingestion/parsers/import_analyzer.py +119 -0
  140. emdash_core/ingestion/parsers/python_parser.py +123 -0
  141. emdash_core/ingestion/parsers/registry.py +72 -0
  142. emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
  143. emdash_core/ingestion/parsers/typescript_parser.py +278 -0
  144. emdash_core/ingestion/repository.py +346 -0
  145. emdash_core/models/__init__.py +38 -0
  146. emdash_core/models/agent.py +68 -0
  147. emdash_core/models/index.py +77 -0
  148. emdash_core/models/query.py +113 -0
  149. emdash_core/planning/__init__.py +7 -0
  150. emdash_core/planning/agent_api.py +413 -0
  151. emdash_core/planning/context_builder.py +265 -0
  152. emdash_core/planning/feature_context.py +232 -0
  153. emdash_core/planning/feature_expander.py +646 -0
  154. emdash_core/planning/llm_explainer.py +198 -0
  155. emdash_core/planning/similarity.py +509 -0
  156. emdash_core/planning/team_focus.py +821 -0
  157. emdash_core/server.py +153 -0
  158. emdash_core/sse/__init__.py +5 -0
  159. emdash_core/sse/stream.py +196 -0
  160. emdash_core/swarm/__init__.py +17 -0
  161. emdash_core/swarm/merge_agent.py +383 -0
  162. emdash_core/swarm/session_manager.py +274 -0
  163. emdash_core/swarm/swarm_runner.py +226 -0
  164. emdash_core/swarm/task_definition.py +137 -0
  165. emdash_core/swarm/worker_spawner.py +319 -0
  166. emdash_core/swarm/worktree_manager.py +278 -0
  167. emdash_core/templates/__init__.py +10 -0
  168. emdash_core/templates/defaults/agent-builder.md.template +82 -0
  169. emdash_core/templates/defaults/focus.md.template +115 -0
  170. emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
  171. emdash_core/templates/defaults/pr-review.md.template +80 -0
  172. emdash_core/templates/defaults/project.md.template +85 -0
  173. emdash_core/templates/defaults/research_critic.md.template +112 -0
  174. emdash_core/templates/defaults/research_planner.md.template +85 -0
  175. emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
  176. emdash_core/templates/defaults/reviewer.md.template +81 -0
  177. emdash_core/templates/defaults/spec.md.template +41 -0
  178. emdash_core/templates/defaults/tasks.md.template +78 -0
  179. emdash_core/templates/loader.py +296 -0
  180. emdash_core/utils/__init__.py +45 -0
  181. emdash_core/utils/git.py +84 -0
  182. emdash_core/utils/image.py +502 -0
  183. emdash_core/utils/logger.py +51 -0
  184. emdash_core-0.1.7.dist-info/METADATA +35 -0
  185. emdash_core-0.1.7.dist-info/RECORD +187 -0
  186. emdash_core-0.1.7.dist-info/WHEEL +4 -0
  187. emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,346 @@
1
+ """Generic MCP client for communicating with MCP servers.
2
+
3
+ This module provides a generic client that can communicate with any
4
+ MCP-compliant server over stdio.
5
+ """
6
+
7
+ import json
8
+ import subprocess
9
+ import threading
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Optional
12
+ from queue import Queue, Empty
13
+
14
+ from ...utils.logger import log
15
+
16
+
17
+ class MCPError(Exception):
18
+ """Error from MCP server communication."""
19
+ pass
20
+
21
+
22
+ @dataclass
23
+ class MCPToolInfo:
24
+ """Information about a tool provided by an MCP server.
25
+
26
+ Attributes:
27
+ name: Tool name
28
+ description: Tool description
29
+ input_schema: JSON Schema for tool input
30
+ """
31
+ name: str
32
+ description: str
33
+ input_schema: dict = field(default_factory=dict)
34
+
35
+
36
+ @dataclass
37
+ class MCPResponse:
38
+ """Response from an MCP tool call.
39
+
40
+ Attributes:
41
+ content: Response content (list of content items)
42
+ is_error: Whether this is an error response
43
+ """
44
+ content: list[dict] = field(default_factory=list)
45
+ is_error: bool = False
46
+
47
+ def get_text(self) -> str:
48
+ """Extract text content from response.
49
+
50
+ Returns:
51
+ Concatenated text content
52
+ """
53
+ texts = []
54
+ for item in self.content:
55
+ if item.get("type") == "text":
56
+ texts.append(item.get("text", ""))
57
+ return "\n".join(texts)
58
+
59
+
60
+ class GenericMCPClient:
61
+ """Generic client for MCP servers.
62
+
63
+ Communicates with MCP servers over stdio using JSON-RPC.
64
+
65
+ Example:
66
+ client = GenericMCPClient(
67
+ name="github",
68
+ command="github-mcp-server",
69
+ args=["stdio"],
70
+ env={"GITHUB_TOKEN": "..."},
71
+ )
72
+ client.start()
73
+
74
+ tools = client.list_tools()
75
+ result = client.call_tool("search_code", {"query": "auth"})
76
+
77
+ client.stop()
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ name: str,
83
+ command: str,
84
+ args: list[str] = None,
85
+ env: dict[str, str] = None,
86
+ timeout: int = 30,
87
+ ):
88
+ """Initialize the MCP client.
89
+
90
+ Args:
91
+ name: Name for this client (for logging)
92
+ command: Command to run the server
93
+ args: Command arguments
94
+ env: Environment variables
95
+ timeout: Timeout for operations in seconds
96
+ """
97
+ self.name = name
98
+ self.command = command
99
+ self.args = args or []
100
+ self.env = env or {}
101
+ self.timeout = timeout
102
+
103
+ self._process: Optional[subprocess.Popen] = None
104
+ self._request_id = 0
105
+ self._pending: dict[int, Queue] = {}
106
+ self._reader_thread: Optional[threading.Thread] = None
107
+ self._running = False
108
+ self._tools: list[MCPToolInfo] = []
109
+
110
+ @property
111
+ def is_running(self) -> bool:
112
+ """Check if the client is running."""
113
+ return self._running and self._process is not None
114
+
115
+ def start(self) -> None:
116
+ """Start the MCP server process."""
117
+ if self._running:
118
+ return
119
+
120
+ import os
121
+
122
+ # Build environment
123
+ full_env = os.environ.copy()
124
+ full_env.update(self.env)
125
+
126
+ try:
127
+ self._process = subprocess.Popen(
128
+ [self.command] + self.args,
129
+ stdin=subprocess.PIPE,
130
+ stdout=subprocess.PIPE,
131
+ stderr=subprocess.PIPE,
132
+ env=full_env,
133
+ text=True,
134
+ bufsize=1,
135
+ )
136
+ except FileNotFoundError:
137
+ raise MCPError(f"MCP server command not found: {self.command}")
138
+ except Exception as e:
139
+ raise MCPError(f"Failed to start MCP server: {e}")
140
+
141
+ self._running = True
142
+
143
+ # Start reader thread
144
+ self._reader_thread = threading.Thread(target=self._read_responses, daemon=True)
145
+ self._reader_thread.start()
146
+
147
+ # Initialize the connection
148
+ self._initialize()
149
+
150
+ log.info(f"Started MCP server: {self.name}")
151
+
152
+ def stop(self) -> None:
153
+ """Stop the MCP server process."""
154
+ self._running = False
155
+
156
+ if self._process:
157
+ try:
158
+ self._process.terminate()
159
+ self._process.wait(timeout=5)
160
+ except subprocess.TimeoutExpired:
161
+ self._process.kill()
162
+ except Exception:
163
+ pass
164
+ finally:
165
+ self._process = None
166
+
167
+ self._tools = []
168
+ log.info(f"Stopped MCP server: {self.name}")
169
+
170
+ def _initialize(self) -> None:
171
+ """Initialize the MCP connection."""
172
+ # Send initialize request
173
+ response = self._send_request("initialize", {
174
+ "protocolVersion": "2024-11-05",
175
+ "capabilities": {},
176
+ "clientInfo": {
177
+ "name": "emdash",
178
+ "version": "1.0.0",
179
+ },
180
+ })
181
+
182
+ # Send initialized notification
183
+ self._send_notification("notifications/initialized", {})
184
+
185
+ def _send_request(self, method: str, params: dict) -> dict:
186
+ """Send a JSON-RPC request and wait for response.
187
+
188
+ Args:
189
+ method: RPC method name
190
+ params: Method parameters
191
+
192
+ Returns:
193
+ Response result
194
+
195
+ Raises:
196
+ MCPError: On communication error or error response
197
+ """
198
+ if not self._process or not self._running:
199
+ raise MCPError("MCP client not running")
200
+
201
+ self._request_id += 1
202
+ request_id = self._request_id
203
+
204
+ request = {
205
+ "jsonrpc": "2.0",
206
+ "id": request_id,
207
+ "method": method,
208
+ "params": params,
209
+ }
210
+
211
+ # Create response queue
212
+ response_queue: Queue = Queue()
213
+ self._pending[request_id] = response_queue
214
+
215
+ try:
216
+ # Send request
217
+ request_line = json.dumps(request) + "\n"
218
+ self._process.stdin.write(request_line)
219
+ self._process.stdin.flush()
220
+
221
+ # Wait for response
222
+ try:
223
+ response = response_queue.get(timeout=self.timeout)
224
+ except Empty:
225
+ raise MCPError(f"Timeout waiting for response to {method}")
226
+
227
+ if "error" in response:
228
+ error = response["error"]
229
+ raise MCPError(f"MCP error: {error.get('message', 'Unknown error')}")
230
+
231
+ return response.get("result", {})
232
+
233
+ finally:
234
+ del self._pending[request_id]
235
+
236
+ def _send_notification(self, method: str, params: dict) -> None:
237
+ """Send a JSON-RPC notification (no response expected).
238
+
239
+ Args:
240
+ method: Notification method
241
+ params: Method parameters
242
+ """
243
+ if not self._process or not self._running:
244
+ return
245
+
246
+ notification = {
247
+ "jsonrpc": "2.0",
248
+ "method": method,
249
+ "params": params,
250
+ }
251
+
252
+ notification_line = json.dumps(notification) + "\n"
253
+ self._process.stdin.write(notification_line)
254
+ self._process.stdin.flush()
255
+
256
+ def _read_responses(self) -> None:
257
+ """Background thread to read responses from the server."""
258
+ while self._running and self._process:
259
+ try:
260
+ line = self._process.stdout.readline()
261
+ if not line:
262
+ break
263
+
264
+ try:
265
+ response = json.loads(line.strip())
266
+ except json.JSONDecodeError:
267
+ continue
268
+
269
+ # Route to pending request
270
+ request_id = response.get("id")
271
+ if request_id and request_id in self._pending:
272
+ self._pending[request_id].put(response)
273
+
274
+ except Exception as e:
275
+ if self._running:
276
+ log.warning(f"Error reading MCP response: {e}")
277
+ break
278
+
279
+ def list_tools(self) -> list[MCPToolInfo]:
280
+ """List available tools from the server.
281
+
282
+ Returns:
283
+ List of MCPToolInfo
284
+ """
285
+ if self._tools:
286
+ return self._tools
287
+
288
+ result = self._send_request("tools/list", {})
289
+
290
+ tools = []
291
+ for tool_data in result.get("tools", []):
292
+ tools.append(MCPToolInfo(
293
+ name=tool_data.get("name", ""),
294
+ description=tool_data.get("description", ""),
295
+ input_schema=tool_data.get("inputSchema", {}),
296
+ ))
297
+
298
+ self._tools = tools
299
+ return tools
300
+
301
+ def call_tool(self, name: str, arguments: dict) -> MCPResponse:
302
+ """Call a tool on the server.
303
+
304
+ Args:
305
+ name: Tool name
306
+ arguments: Tool arguments
307
+
308
+ Returns:
309
+ MCPResponse with tool result
310
+ """
311
+ result = self._send_request("tools/call", {
312
+ "name": name,
313
+ "arguments": arguments,
314
+ })
315
+
316
+ return MCPResponse(
317
+ content=result.get("content", []),
318
+ is_error=result.get("isError", False),
319
+ )
320
+
321
+
322
+ # Legacy client for backward compatibility
323
+ class GitHubMCPClient(GenericMCPClient):
324
+ """GitHub-specific MCP client.
325
+
326
+ Deprecated: Use GenericMCPClient directly with appropriate config.
327
+ """
328
+
329
+ def __init__(self, token: Optional[str] = None, timeout: int = 30):
330
+ """Initialize GitHub MCP client.
331
+
332
+ Args:
333
+ token: GitHub token. If None, reads from environment.
334
+ timeout: Operation timeout in seconds.
335
+ """
336
+ import os
337
+
338
+ github_token = token or os.getenv("GITHUB_TOKEN") or os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN")
339
+
340
+ super().__init__(
341
+ name="github",
342
+ command="github-mcp-server",
343
+ args=["stdio"],
344
+ env={"GITHUB_PERSONAL_ACCESS_TOKEN": github_token or ""},
345
+ timeout=timeout,
346
+ )
@@ -0,0 +1,302 @@
1
+ """MCP configuration schema and loading.
2
+
3
+ This module provides configuration classes for managing MCP servers,
4
+ compatible with Claude Desktop's mcp_config.json format.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from ...utils.logger import log
15
+
16
+
17
+ @dataclass
18
+ class MCPServerConfig:
19
+ """Configuration for a single MCP server.
20
+
21
+ Attributes:
22
+ name: Unique identifier for this server
23
+ command: Command to run (e.g., "npx", "github-mcp-server")
24
+ args: Arguments to pass to the command
25
+ env: Environment variables (supports ${VAR} syntax)
26
+ enabled: Whether this server is enabled
27
+ timeout: Timeout in seconds for tool calls
28
+ """
29
+
30
+ name: str
31
+ command: str
32
+ args: list[str] = field(default_factory=list)
33
+ env: dict[str, str] = field(default_factory=dict)
34
+ enabled: bool = True
35
+ timeout: int = 30
36
+
37
+ def get_resolved_env(self) -> dict[str, str]:
38
+ """Resolve environment variable references like ${VAR_NAME}.
39
+
40
+ For GitHub tokens, also checks Rove auth config first.
41
+ For graph DB path, uses emdash config default.
42
+
43
+ Returns:
44
+ Dictionary with resolved environment values
45
+ """
46
+ resolved = {}
47
+ pattern = re.compile(r"\$\{([^}]+)\}")
48
+
49
+ def get_env_value(var_name: str) -> str:
50
+ """Get environment value, checking emdash config for special vars."""
51
+ # Check for GitHub token - use emdash auth if available
52
+ if var_name in ("GITHUB_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN"):
53
+ try:
54
+ from ...auth import get_github_token
55
+ token = get_github_token()
56
+ if token:
57
+ return token
58
+ except ImportError:
59
+ pass
60
+ # Check for graph DB path - use emdash config default
61
+ if var_name == "EMDASH_GRAPH_DB_PATH":
62
+ env_val = os.getenv(var_name)
63
+ if env_val:
64
+ return env_val
65
+ # Default to .emdash/index/kuzu_db in cwd
66
+ default_path = Path.cwd() / ".emdash" / "index" / "kuzu_db"
67
+ return str(default_path)
68
+ # Fall back to environment variable
69
+ return os.getenv(var_name, "")
70
+
71
+ for key, value in self.env.items():
72
+ match = pattern.fullmatch(value)
73
+ if match:
74
+ env_var = match.group(1)
75
+ env_value = get_env_value(env_var)
76
+ if not env_value:
77
+ log.warning(f"Environment variable {env_var} not set for MCP server {self.name}")
78
+ resolved[key] = env_value
79
+ else:
80
+ # Check for partial substitution like "prefix_${VAR}_suffix"
81
+ def replace_var(m):
82
+ return get_env_value(m.group(1))
83
+
84
+ resolved[key] = pattern.sub(replace_var, value)
85
+
86
+ return resolved
87
+
88
+ def to_dict(self) -> dict:
89
+ """Convert to dictionary for JSON serialization."""
90
+ return {
91
+ "command": self.command,
92
+ "args": self.args,
93
+ "env": self.env,
94
+ "enabled": self.enabled,
95
+ "timeout": self.timeout,
96
+ }
97
+
98
+ @classmethod
99
+ def from_dict(cls, name: str, data: dict) -> "MCPServerConfig":
100
+ """Create from dictionary."""
101
+ return cls(
102
+ name=name,
103
+ command=data.get("command", ""),
104
+ args=data.get("args", []),
105
+ env=data.get("env", {}),
106
+ enabled=data.get("enabled", True),
107
+ timeout=data.get("timeout", 30),
108
+ )
109
+
110
+
111
+ @dataclass
112
+ class MCPConfigFile:
113
+ """Root configuration for all MCP servers.
114
+
115
+ This class handles loading and saving the MCP configuration file,
116
+ which uses Claude Desktop's format:
117
+
118
+ {
119
+ "mcpServers": {
120
+ "server-name": {
121
+ "command": "...",
122
+ "args": [...],
123
+ "env": {...}
124
+ }
125
+ }
126
+ }
127
+ """
128
+
129
+ servers: dict[str, MCPServerConfig] = field(default_factory=dict)
130
+
131
+ @classmethod
132
+ def load(cls, path: Path) -> "MCPConfigFile":
133
+ """Load MCP configuration from file.
134
+
135
+ Args:
136
+ path: Path to the configuration file
137
+
138
+ Returns:
139
+ MCPConfigFile instance (empty if file doesn't exist)
140
+ """
141
+ if not path.exists():
142
+ log.debug(f"MCP config file not found: {path}")
143
+ return cls()
144
+
145
+ try:
146
+ with open(path) as f:
147
+ data = json.load(f)
148
+ except json.JSONDecodeError as e:
149
+ log.error(f"Invalid JSON in MCP config file {path}: {e}")
150
+ return cls()
151
+ except Exception as e:
152
+ log.error(f"Failed to read MCP config file {path}: {e}")
153
+ return cls()
154
+
155
+ servers = {}
156
+ for name, config in data.get("mcpServers", {}).items():
157
+ try:
158
+ servers[name] = MCPServerConfig.from_dict(name, config)
159
+ except Exception as e:
160
+ log.warning(f"Invalid MCP server config '{name}': {e}")
161
+
162
+ log.info(f"Loaded MCP config with {len(servers)} servers from {path}")
163
+ return cls(servers=servers)
164
+
165
+ def save(self, path: Path) -> None:
166
+ """Save configuration to file.
167
+
168
+ Args:
169
+ path: Path to save the configuration file
170
+ """
171
+ data = {"mcpServers": {}}
172
+ for name, server in self.servers.items():
173
+ data["mcpServers"][name] = server.to_dict()
174
+
175
+ # Ensure directory exists
176
+ path.parent.mkdir(parents=True, exist_ok=True)
177
+
178
+ with open(path, "w") as f:
179
+ json.dump(data, f, indent=2)
180
+
181
+ log.info(f"Saved MCP config to {path}")
182
+
183
+ def get_enabled_servers(self) -> list[MCPServerConfig]:
184
+ """Get list of enabled server configurations.
185
+
186
+ Returns:
187
+ List of enabled MCPServerConfig instances
188
+ """
189
+ return [s for s in self.servers.values() if s.enabled]
190
+
191
+ def add_server(self, config: MCPServerConfig) -> None:
192
+ """Add or update a server configuration.
193
+
194
+ Args:
195
+ config: Server configuration to add
196
+ """
197
+ self.servers[config.name] = config
198
+
199
+ def remove_server(self, name: str) -> bool:
200
+ """Remove a server configuration.
201
+
202
+ Args:
203
+ name: Name of the server to remove
204
+
205
+ Returns:
206
+ True if server was removed, False if not found
207
+ """
208
+ if name in self.servers:
209
+ del self.servers[name]
210
+ return True
211
+ return False
212
+
213
+ def get_server(self, name: str) -> Optional[MCPServerConfig]:
214
+ """Get a server configuration by name.
215
+
216
+ Args:
217
+ name: Name of the server
218
+
219
+ Returns:
220
+ MCPServerConfig or None if not found
221
+ """
222
+ return self.servers.get(name)
223
+
224
+
225
+ def get_default_mcp_config_path(repo_root: Optional[Path] = None) -> Path:
226
+ """Get the default path for the MCP configuration file.
227
+
228
+ Args:
229
+ repo_root: Repository root directory (uses cwd if not provided)
230
+
231
+ Returns:
232
+ Path to .emdash/mcp.json
233
+ """
234
+ if repo_root is None:
235
+ repo_root = Path.cwd()
236
+ return repo_root / ".emdash" / "mcp.json"
237
+
238
+
239
+ def get_default_mcp_servers() -> dict[str, MCPServerConfig]:
240
+ """Get the default MCP servers that ship with Rove.
241
+
242
+ Returns:
243
+ Dictionary of default server configurations
244
+ """
245
+ # Check if graph MCP is enabled via env flag
246
+ enable_graph_mcp = os.getenv("ENABLE_GRAPH_MCP", "false").lower() == "true"
247
+
248
+ return {
249
+ "github": MCPServerConfig(
250
+ name="github",
251
+ command="github-mcp-server",
252
+ args=["stdio"],
253
+ env={
254
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}",
255
+ },
256
+ enabled=False, # Disabled - use local tools for codebase exploration
257
+ timeout=30,
258
+ ),
259
+ "emdash-graph": MCPServerConfig(
260
+ name="emdash-graph",
261
+ command="emdash-graph-mcp",
262
+ args=["--db-path", "${EMDASH_GRAPH_DB_PATH}"],
263
+ env={
264
+ "EMDASH_GRAPH_DB_PATH": "${EMDASH_GRAPH_DB_PATH}",
265
+ },
266
+ enabled=enable_graph_mcp,
267
+ timeout=30,
268
+ ),
269
+ }
270
+
271
+
272
+ def create_default_mcp_config(path: Path) -> MCPConfigFile:
273
+ """Create a default MCP config file with pre-configured servers.
274
+
275
+ This creates the .emdash/mcp.json file with GitHub MCP enabled
276
+ by default for all new Rove installations.
277
+
278
+ Args:
279
+ path: Path to save the config file
280
+
281
+ Returns:
282
+ The created MCPConfigFile
283
+ """
284
+ config = MCPConfigFile(servers=get_default_mcp_servers())
285
+ config.save(path)
286
+ log.info(f"Created default MCP config with GitHub MCP at {path}")
287
+ return config
288
+
289
+
290
+ def ensure_mcp_config(path: Path) -> MCPConfigFile:
291
+ """Ensure MCP config exists, creating default if needed.
292
+
293
+ Args:
294
+ path: Path to the config file
295
+
296
+ Returns:
297
+ MCPConfigFile (loaded or newly created)
298
+ """
299
+ if path.exists():
300
+ return MCPConfigFile.load(path)
301
+ else:
302
+ return create_default_mcp_config(path)