nexus-dev 3.2.0__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.

Potentially problematic release.


This version of nexus-dev might be problematic. Click here for more details.

Files changed (48) hide show
  1. nexus_dev/__init__.py +4 -0
  2. nexus_dev/agent_templates/__init__.py +26 -0
  3. nexus_dev/agent_templates/api_designer.yaml +26 -0
  4. nexus_dev/agent_templates/code_reviewer.yaml +26 -0
  5. nexus_dev/agent_templates/debug_detective.yaml +26 -0
  6. nexus_dev/agent_templates/doc_writer.yaml +26 -0
  7. nexus_dev/agent_templates/performance_optimizer.yaml +26 -0
  8. nexus_dev/agent_templates/refactor_architect.yaml +26 -0
  9. nexus_dev/agent_templates/security_auditor.yaml +26 -0
  10. nexus_dev/agent_templates/test_engineer.yaml +26 -0
  11. nexus_dev/agents/__init__.py +20 -0
  12. nexus_dev/agents/agent_config.py +97 -0
  13. nexus_dev/agents/agent_executor.py +197 -0
  14. nexus_dev/agents/agent_manager.py +104 -0
  15. nexus_dev/agents/prompt_factory.py +91 -0
  16. nexus_dev/chunkers/__init__.py +168 -0
  17. nexus_dev/chunkers/base.py +202 -0
  18. nexus_dev/chunkers/docs_chunker.py +291 -0
  19. nexus_dev/chunkers/java_chunker.py +343 -0
  20. nexus_dev/chunkers/javascript_chunker.py +312 -0
  21. nexus_dev/chunkers/python_chunker.py +308 -0
  22. nexus_dev/cli.py +1673 -0
  23. nexus_dev/config.py +253 -0
  24. nexus_dev/database.py +558 -0
  25. nexus_dev/embeddings.py +585 -0
  26. nexus_dev/gateway/__init__.py +10 -0
  27. nexus_dev/gateway/connection_manager.py +348 -0
  28. nexus_dev/github_importer.py +247 -0
  29. nexus_dev/mcp_client.py +281 -0
  30. nexus_dev/mcp_config.py +184 -0
  31. nexus_dev/schemas/mcp_config_schema.json +166 -0
  32. nexus_dev/server.py +1866 -0
  33. nexus_dev/templates/pre-commit-hook +33 -0
  34. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/__init__.py +26 -0
  35. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/api_designer.yaml +26 -0
  36. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/code_reviewer.yaml +26 -0
  37. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/debug_detective.yaml +26 -0
  38. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/doc_writer.yaml +26 -0
  39. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/performance_optimizer.yaml +26 -0
  40. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/refactor_architect.yaml +26 -0
  41. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/security_auditor.yaml +26 -0
  42. nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/test_engineer.yaml +26 -0
  43. nexus_dev-3.2.0.data/data/nexus_dev/templates/pre-commit-hook +33 -0
  44. nexus_dev-3.2.0.dist-info/METADATA +636 -0
  45. nexus_dev-3.2.0.dist-info/RECORD +48 -0
  46. nexus_dev-3.2.0.dist-info/WHEEL +4 -0
  47. nexus_dev-3.2.0.dist-info/entry_points.txt +12 -0
  48. nexus_dev-3.2.0.dist-info/licenses/LICENSE +21 -0
nexus_dev/server.py ADDED
@@ -0,0 +1,1866 @@
1
+ """Nexus-Dev MCP Server.
2
+
3
+ This module implements the MCP server using FastMCP, exposing tools for:
4
+ - search_code: Semantic search across indexed code and documentation
5
+ - index_file: Index a file into the knowledge base
6
+ - record_lesson: Store a problem/solution pair
7
+ - get_project_context: Get recent discoveries for a project
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import uuid
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from mcp.server.fastmcp import FastMCP
20
+ from mcp.server.fastmcp.server import Context
21
+
22
+ from .agents import AgentConfig, AgentExecutor, AgentManager
23
+ from .chunkers import ChunkerRegistry, ChunkType, CodeChunk
24
+ from .config import NexusConfig
25
+ from .database import Document, DocumentType, NexusDatabase, generate_document_id
26
+ from .embeddings import EmbeddingProvider, create_embedder
27
+ from .gateway.connection_manager import ConnectionManager
28
+ from .github_importer import GitHubImporter
29
+ from .mcp_config import MCPConfig
30
+
31
+ # Initialize FastMCP server
32
+ mcp = FastMCP("nexus-dev")
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Global state (initialized on startup)
37
+ _config: NexusConfig | None = None
38
+ _embedder: EmbeddingProvider | None = None
39
+ _database: NexusDatabase | None = None
40
+ _mcp_config: MCPConfig | None = None
41
+ _connection_manager: ConnectionManager | None = None
42
+ _agent_manager: AgentManager | None = None
43
+ _project_root: Path | None = None
44
+
45
+
46
+ def _find_project_root() -> Path | None:
47
+ """Find the project root by looking for nexus_config.json.
48
+
49
+ Walks up from the current directory to find nexus_config.json.
50
+ Also checks NEXUS_PROJECT_ROOT environment variable as a fallback.
51
+
52
+ Returns:
53
+ Path to project root if found, None otherwise.
54
+ """
55
+ global _project_root
56
+ if _project_root:
57
+ return _project_root
58
+
59
+ import os
60
+
61
+ # First check environment variable
62
+ env_root = os.environ.get("NEXUS_PROJECT_ROOT")
63
+ if env_root:
64
+ env_path = Path(env_root)
65
+ if (env_path / "nexus_config.json").exists():
66
+ logger.debug("Found project root from NEXUS_PROJECT_ROOT: %s", env_path)
67
+ return env_path
68
+
69
+ current = Path.cwd().resolve()
70
+ logger.debug("Searching for project root from cwd: %s", current)
71
+
72
+ # Walk up the directory tree
73
+ for parent in [current] + list(current.parents):
74
+ if (parent / "nexus_config.json").exists():
75
+ logger.debug("Found project root: %s", parent)
76
+ _project_root = parent
77
+ return parent
78
+ # Stop at filesystem root
79
+ if parent == parent.parent:
80
+ logger.debug("Reached filesystem root without finding nexus_config.json")
81
+ break
82
+
83
+ logger.debug("No project root found (no nexus_config.json in directory tree)")
84
+ return None
85
+
86
+
87
+ def _get_config() -> NexusConfig | None:
88
+ """Get or load configuration.
89
+
90
+ Returns None if no nexus_config.json exists in cwd.
91
+ This allows the MCP server to work without a project-specific config,
92
+ enabling cross-project searches.
93
+ """
94
+ global _config
95
+ if _config is None:
96
+ root = _find_project_root()
97
+ config_path = (root if root else Path.cwd()) / "nexus_config.json"
98
+ if config_path.exists():
99
+ _config = NexusConfig.load(config_path)
100
+ # Don't create default - None means "all projects"
101
+ return _config
102
+
103
+
104
+ def _get_mcp_config() -> MCPConfig | None:
105
+ """Get or load MCP configuration.
106
+
107
+ Returns None if no .nexus/mcp_config.json exists in cwd or project root.
108
+ """
109
+ global _mcp_config
110
+ if _mcp_config is None:
111
+ root = _find_project_root()
112
+ config_path = (root if root else Path.cwd()) / ".nexus" / "mcp_config.json"
113
+ if config_path.exists():
114
+ try:
115
+ _mcp_config = MCPConfig.load(config_path)
116
+ except Exception as e:
117
+ logger.debug("Failed to load MCP config from %s: %s", config_path, e)
118
+ pass
119
+ return _mcp_config
120
+
121
+
122
+ def _get_active_server_names() -> list[str]:
123
+ """Get names of active MCP servers.
124
+
125
+ Returns:
126
+ List of active server names.
127
+ """
128
+ mcp_config = _get_mcp_config()
129
+ if not mcp_config:
130
+ return []
131
+
132
+ # Find the name for each active server config
133
+ active_servers = mcp_config.get_active_servers()
134
+ active_names = []
135
+ for name, config in mcp_config.servers.items():
136
+ if config in active_servers:
137
+ active_names.append(name)
138
+ return active_names
139
+
140
+
141
+ def _get_connection_manager() -> ConnectionManager:
142
+ """Get or create connection manager singleton.
143
+
144
+ Returns:
145
+ ConnectionManager instance for managing MCP server connections.
146
+ """
147
+ global _connection_manager
148
+ if _connection_manager is None:
149
+ _connection_manager = ConnectionManager()
150
+ return _connection_manager
151
+
152
+
153
+ def _get_embedder() -> EmbeddingProvider:
154
+ """Get or create embedding provider."""
155
+ global _embedder
156
+ if _embedder is None:
157
+ config = _get_config()
158
+ if config is None:
159
+ # Create minimal config for embeddings only
160
+ config = NexusConfig.create_new("default")
161
+ _embedder = create_embedder(config)
162
+ return _embedder
163
+
164
+
165
+ def _get_database() -> NexusDatabase:
166
+ """Get or create database connection."""
167
+ global _database
168
+ if _database is None:
169
+ config = _get_config()
170
+ if config is None:
171
+ # Create minimal config for database access
172
+ config = NexusConfig.create_new("default")
173
+ embedder = _get_embedder()
174
+ _database = NexusDatabase(config, embedder)
175
+ _database.connect()
176
+ return _database
177
+
178
+
179
+ async def _index_chunks(
180
+ chunks: list[CodeChunk],
181
+ project_id: str,
182
+ doc_type: DocumentType,
183
+ ) -> list[str]:
184
+ """Index a list of chunks into the database.
185
+
186
+ Args:
187
+ chunks: Code chunks to index.
188
+ project_id: Project identifier.
189
+ doc_type: Type of document.
190
+
191
+ Returns:
192
+ List of document IDs.
193
+ """
194
+ if not chunks:
195
+ return []
196
+
197
+ embedder = _get_embedder()
198
+ database = _get_database()
199
+
200
+ # Generate embeddings for all chunks
201
+ texts = [chunk.get_searchable_text() for chunk in chunks]
202
+ embeddings = await embedder.embed_batch(texts)
203
+
204
+ # Create documents
205
+ documents = []
206
+ for chunk, embedding in zip(chunks, embeddings, strict=True):
207
+ doc_id = generate_document_id(
208
+ project_id,
209
+ chunk.file_path,
210
+ chunk.name,
211
+ chunk.start_line,
212
+ )
213
+
214
+ doc = Document(
215
+ id=doc_id,
216
+ text=chunk.get_searchable_text(),
217
+ vector=embedding,
218
+ project_id=project_id,
219
+ file_path=chunk.file_path,
220
+ doc_type=doc_type,
221
+ chunk_type=chunk.chunk_type.value,
222
+ language=chunk.language,
223
+ name=chunk.name,
224
+ start_line=chunk.start_line,
225
+ end_line=chunk.end_line,
226
+ )
227
+ documents.append(doc)
228
+
229
+ # Upsert documents
230
+ return await database.upsert_documents(documents)
231
+
232
+
233
+ @mcp.tool()
234
+ async def search_knowledge(
235
+ query: str,
236
+ content_type: str = "all",
237
+ project_id: str | None = None,
238
+ limit: int = 5,
239
+ ) -> str:
240
+ """Search all indexed knowledge including code, documentation, and lessons.
241
+
242
+ This is the main search tool that can find relevant information across all
243
+ indexed content types. Use the content_type parameter to filter results.
244
+
245
+ Args:
246
+ query: Natural language search query describing what you're looking for.
247
+ Examples: "function that handles user authentication",
248
+ "how to configure the database", "error with null pointer".
249
+ content_type: Filter by content type. Options:
250
+ - "all": Search everything (default)
251
+ - "code": Only search code (functions, classes, methods)
252
+ - "documentation": Only search docs (markdown, rst, txt)
253
+ - "lesson": Only search recorded lessons
254
+ project_id: Optional project identifier to limit search scope.
255
+ If not provided, searches across all projects.
256
+ limit: Maximum number of results to return (default: 5, max: 20).
257
+
258
+ Returns:
259
+ Formatted search results with file paths, content, and relevance info.
260
+ """
261
+ database = _get_database()
262
+
263
+ # Only filter by project if explicitly specified
264
+ # None = search across all projects
265
+
266
+ # Clamp limit
267
+ limit = min(max(1, limit), 20)
268
+
269
+ # Map content_type to DocumentType
270
+ doc_type_filter = None
271
+ if content_type == "code":
272
+ doc_type_filter = DocumentType.CODE
273
+ elif content_type == "documentation":
274
+ doc_type_filter = DocumentType.DOCUMENTATION
275
+ elif content_type == "lesson":
276
+ doc_type_filter = DocumentType.LESSON
277
+ # "all" means no filter
278
+
279
+ try:
280
+ results = await database.search(
281
+ query=query,
282
+ project_id=project_id, # None = all projects
283
+ doc_type=doc_type_filter,
284
+ limit=limit,
285
+ )
286
+
287
+ if not results:
288
+ return f"No results found for query: '{query}'" + (
289
+ f" (filtered by {content_type})" if content_type != "all" else ""
290
+ )
291
+
292
+ # Format results
293
+ content_label = f" [{content_type.upper()}]" if content_type != "all" else ""
294
+ output_parts = [f"## Search Results{content_label}: '{query}'", ""]
295
+
296
+ for i, result in enumerate(results, 1):
297
+ type_badge = f"[{result.doc_type.upper()}]"
298
+ output_parts.append(f"### Result {i}: {type_badge} {result.name}")
299
+ output_parts.append(f"**File:** `{result.file_path}`")
300
+ output_parts.append(f"**Type:** {result.chunk_type} ({result.language})")
301
+ if result.start_line > 0:
302
+ output_parts.append(f"**Lines:** {result.start_line}-{result.end_line}")
303
+ output_parts.append("")
304
+ output_parts.append("```" + result.language)
305
+ output_parts.append(result.text[:2000]) # Truncate long content
306
+ if len(result.text) > 2000:
307
+ output_parts.append("... (truncated)")
308
+ output_parts.append("```")
309
+ output_parts.append("")
310
+
311
+ return "\n".join(output_parts)
312
+
313
+ except Exception as e:
314
+ return f"Search failed: {e!s}"
315
+
316
+
317
+ @mcp.tool()
318
+ async def search_docs(
319
+ query: str,
320
+ project_id: str | None = None,
321
+ limit: int = 5,
322
+ ) -> str:
323
+ """Search specifically in documentation (Markdown, RST, text files).
324
+
325
+ Use this tool when you need to find information in project documentation,
326
+ README files, or other text documentation. This is more targeted than
327
+ search_knowledge when you know the answer is in the docs.
328
+
329
+ Args:
330
+ query: Natural language search query.
331
+ Examples: "how to install", "API configuration", "usage examples".
332
+ project_id: Optional project identifier. Searches all projects if not specified.
333
+ limit: Maximum number of results (default: 5, max: 20).
334
+
335
+ Returns:
336
+ Formatted documentation search results.
337
+ """
338
+ database = _get_database()
339
+ limit = min(max(1, limit), 20)
340
+
341
+ try:
342
+ results = await database.search(
343
+ query=query,
344
+ project_id=project_id, # None = all projects
345
+ doc_type=DocumentType.DOCUMENTATION,
346
+ limit=limit,
347
+ )
348
+
349
+ if not results:
350
+ return f"No documentation found for: '{query}'"
351
+
352
+ output_parts = [f"## Documentation Search: '{query}'", ""]
353
+
354
+ for i, result in enumerate(results, 1):
355
+ output_parts.append(f"### {i}. {result.name}")
356
+ output_parts.append(f"**Source:** `{result.file_path}`")
357
+ output_parts.append("")
358
+ # For docs, render as markdown directly
359
+ output_parts.append(result.text[:2500])
360
+ if len(result.text) > 2500:
361
+ output_parts.append("\n... (truncated)")
362
+ output_parts.append("")
363
+ output_parts.append("---")
364
+ output_parts.append("")
365
+
366
+ return "\n".join(output_parts)
367
+
368
+ except Exception as e:
369
+ return f"Documentation search failed: {e!s}"
370
+
371
+
372
+ @mcp.tool()
373
+ async def search_lessons(
374
+ query: str,
375
+ project_id: str | None = None,
376
+ limit: int = 5,
377
+ ) -> str:
378
+ """Search in recorded lessons (problems and solutions).
379
+
380
+ Use this tool when you encounter an error or problem that might have been
381
+ solved before. Lessons contain problem descriptions and their solutions,
382
+ making them ideal for troubleshooting similar issues.
383
+
384
+ Args:
385
+ query: Description of the problem or error you're facing.
386
+ Examples: "TypeError with None", "database connection timeout",
387
+ "how to fix import error".
388
+ project_id: Optional project identifier. Searches all projects if not specified,
389
+ enabling cross-project learning.
390
+ limit: Maximum number of results (default: 5, max: 20).
391
+
392
+ Returns:
393
+ Relevant lessons with problems and solutions.
394
+ """
395
+ database = _get_database()
396
+ limit = min(max(1, limit), 20)
397
+
398
+ try:
399
+ results = await database.search(
400
+ query=query,
401
+ project_id=project_id, # None = all projects (cross-project learning)
402
+ doc_type=DocumentType.LESSON,
403
+ limit=limit,
404
+ )
405
+
406
+ if not results:
407
+ return (
408
+ f"No lessons found matching: '{query}'\n\n"
409
+ "Tip: Use record_lesson to save problems and solutions for future reference."
410
+ )
411
+
412
+ output_parts = [f"## Lessons Found: '{query}'", ""]
413
+
414
+ for i, result in enumerate(results, 1):
415
+ output_parts.append(f"### Lesson {i}")
416
+ output_parts.append(f"**ID:** {result.name}")
417
+ output_parts.append(f"**Project:** {result.project_id}")
418
+ output_parts.append("")
419
+ output_parts.append(result.text)
420
+ output_parts.append("")
421
+ output_parts.append("---")
422
+ output_parts.append("")
423
+
424
+ return "\n".join(output_parts)
425
+
426
+ except Exception as e:
427
+ return f"Lesson search failed: {e!s}"
428
+
429
+
430
+ @mcp.tool()
431
+ async def search_code(
432
+ query: str,
433
+ project_id: str | None = None,
434
+ limit: int = 5,
435
+ ) -> str:
436
+ """Search specifically in indexed code (functions, classes, methods).
437
+
438
+ Use this tool when you need to find code implementations, function definitions,
439
+ or class structures. This is more targeted than search_knowledge when you
440
+ specifically need code, not documentation.
441
+
442
+ Args:
443
+ query: Description of the code you're looking for.
444
+ Examples: "function that handles authentication",
445
+ "class for database connections", "method to validate input".
446
+ project_id: Optional project identifier. Searches all projects if not specified.
447
+ limit: Maximum number of results (default: 5, max: 20).
448
+
449
+ Returns:
450
+ Relevant code snippets with file locations.
451
+ """
452
+ database = _get_database()
453
+ limit = min(max(1, limit), 20)
454
+
455
+ try:
456
+ results = await database.search(
457
+ query=query,
458
+ project_id=project_id, # None = all projects
459
+ doc_type=DocumentType.CODE,
460
+ limit=limit,
461
+ )
462
+
463
+ if not results:
464
+ return f"No code found for: '{query}'"
465
+
466
+ output_parts = [f"## Code Search: '{query}'", ""]
467
+
468
+ for i, result in enumerate(results, 1):
469
+ output_parts.append(f"### {i}. {result.chunk_type}: {result.name}")
470
+ output_parts.append(f"**File:** `{result.file_path}`")
471
+ output_parts.append(f"**Lines:** {result.start_line}-{result.end_line}")
472
+ output_parts.append(f"**Language:** {result.language}")
473
+ output_parts.append("")
474
+ output_parts.append("```" + result.language)
475
+ output_parts.append(result.text[:2000])
476
+ if len(result.text) > 2000:
477
+ output_parts.append("... (truncated)")
478
+ output_parts.append("```")
479
+ output_parts.append("")
480
+
481
+ return "\n".join(output_parts)
482
+
483
+ except Exception as e:
484
+ return f"Code search failed: {e!s}"
485
+
486
+
487
+ @mcp.tool()
488
+ async def search_tools(
489
+ query: str,
490
+ server: str | None = None,
491
+ limit: int = 5,
492
+ ) -> str:
493
+ """Search for MCP tools matching a description.
494
+
495
+ Use this tool to find other MCP tools when you need to perform an action
496
+ but don't know which tool to use. Returns tool names, descriptions, and
497
+ parameter schemas.
498
+
499
+ Args:
500
+ query: Natural language description of what you want to do.
501
+ Examples: "create a GitHub issue", "list files in directory",
502
+ "send a notification to Home Assistant"
503
+ server: Optional server name to filter results (e.g., "github").
504
+ limit: Maximum results to return (default: 5, max: 10).
505
+
506
+ Returns:
507
+ Matching tools with server, name, description, and parameters.
508
+ """
509
+ database = _get_database()
510
+ limit = min(max(1, limit), 10)
511
+
512
+ # Search for tools
513
+ results = await database.search(
514
+ query=query,
515
+ doc_type=DocumentType.TOOL,
516
+ limit=limit,
517
+ )
518
+ logger.debug("[%s] Searching tools with query='%s'", "nexus-dev", query)
519
+ try:
520
+ logger.debug("[%s] DB Path in use: %s", "nexus-dev", database.config.get_db_path())
521
+ except Exception as e:
522
+ logger.debug("[%s] Could not print DB path: %s", "nexus-dev", e)
523
+
524
+ logger.debug("[%s] Results found: %d", "nexus-dev", len(results))
525
+ if results:
526
+ logger.debug("[%s] First result: %s (%s)", "nexus-dev", results[0].name, results[0].score)
527
+
528
+ # Filter by server if specified
529
+ if server and results:
530
+ results = [r for r in results if r.server_name == server]
531
+
532
+ if not results:
533
+ if server:
534
+ return f"No tools found matching: '{query}' in server: '{server}'"
535
+ return f"No tools found matching: '{query}'"
536
+
537
+ # Format output
538
+ output_parts = [f"## MCP Tools matching: '{query}'", ""]
539
+
540
+ for i, result in enumerate(results, 1):
541
+ # Parse parameters schema from stored JSON
542
+ params = json.loads(result.parameters_schema) if result.parameters_schema else {}
543
+
544
+ output_parts.append(f"### {i}. {result.server_name}.{result.name}")
545
+ output_parts.append(f"**Description:** {result.text}")
546
+ output_parts.append("")
547
+ if params:
548
+ output_parts.append("**Parameters:**")
549
+ output_parts.append("```json")
550
+ output_parts.append(json.dumps(params, indent=2))
551
+ output_parts.append("```")
552
+ output_parts.append("")
553
+
554
+ return "\n".join(output_parts)
555
+
556
+
557
+ @mcp.tool()
558
+ async def list_servers() -> str:
559
+ """List all configured MCP servers and their status.
560
+
561
+ Returns:
562
+ List of MCP servers with connection status.
563
+ """
564
+ mcp_config = _get_mcp_config()
565
+ if not mcp_config:
566
+ return "No MCP config. Run 'nexus-mcp init' first."
567
+
568
+ output = ["## MCP Servers", ""]
569
+
570
+ active = mcp_config.get_active_servers()
571
+ active_names = {name for name, cfg in mcp_config.servers.items() if cfg in active}
572
+
573
+ output.append("### Active")
574
+ if active_names:
575
+ for name in sorted(active_names):
576
+ server = mcp_config.servers[name]
577
+ details = ""
578
+ if server.transport in ("sse", "http"):
579
+ details = f"{server.transport.upper()}: {server.url}"
580
+ else:
581
+ details = f"Command: {server.command} {' '.join(server.args)}"
582
+ output.append(f"- **{name}**: `{details}`")
583
+ else:
584
+ output.append("*No active servers*")
585
+
586
+ output.append("")
587
+ output.append("### Disabled")
588
+ disabled = [name for name, server in mcp_config.servers.items() if name not in active_names]
589
+ if disabled:
590
+ for name in sorted(disabled):
591
+ server = mcp_config.servers[name]
592
+ status = "disabled" if not server.enabled else "not in profile"
593
+ output.append(f"- {name} ({status})")
594
+ else:
595
+ output.append("*No disabled servers*")
596
+
597
+ return "\n".join(output)
598
+
599
+
600
+ @mcp.tool()
601
+ async def get_tool_schema(server: str, tool: str) -> str:
602
+ """Get the full JSON schema for a specific MCP tool.
603
+
604
+ Use this after search_tools to get complete parameter details
605
+ before calling invoke_tool.
606
+
607
+ Args:
608
+ server: Server name (e.g., "github")
609
+ tool: Tool name (e.g., "create_pull_request")
610
+
611
+ Returns:
612
+ Full JSON schema with parameter types and descriptions.
613
+ """
614
+ mcp_config = _get_mcp_config()
615
+ if not mcp_config:
616
+ return "No MCP config. Run 'nexus-mcp init' first."
617
+
618
+ if server not in mcp_config.servers:
619
+ available = ", ".join(sorted(mcp_config.servers.keys()))
620
+ return f"Server not found: {server}. Available: {available}"
621
+
622
+ server_config = mcp_config.servers[server]
623
+ if not server_config.enabled:
624
+ return f"Server is disabled: {server}"
625
+
626
+ conn_manager = _get_connection_manager()
627
+
628
+ try:
629
+ session = await conn_manager.get_connection(server, server_config)
630
+ tools_result = await session.list_tools()
631
+
632
+ for t in tools_result.tools:
633
+ if t.name == tool:
634
+ return json.dumps(
635
+ {
636
+ "server": server,
637
+ "tool": tool,
638
+ "description": t.description or "",
639
+ "parameters": t.inputSchema or {},
640
+ },
641
+ indent=2,
642
+ )
643
+
644
+ available_tools = [t.name for t in tools_result.tools[:10]]
645
+ hint = f" Available: {', '.join(available_tools)}..." if available_tools else ""
646
+ return f"Tool not found: {server}.{tool}.{hint}"
647
+
648
+ except Exception as e:
649
+ return f"Error connecting to {server}: {e}"
650
+
651
+
652
+ @mcp.tool()
653
+ async def invoke_tool(
654
+ server: str,
655
+ tool: str,
656
+ arguments: dict[str, Any] | None = None,
657
+ ) -> str:
658
+ """Invoke a tool on a backend MCP server.
659
+
660
+ Use search_tools first to find the right tool, then use this
661
+ to execute it.
662
+
663
+ Args:
664
+ server: MCP server name (e.g., "github", "homeassistant")
665
+ tool: Tool name (e.g., "create_issue", "turn_on_light")
666
+ arguments: Tool arguments as dictionary
667
+
668
+ Returns:
669
+ Tool execution result.
670
+
671
+ Example:
672
+ invoke_tool(
673
+ server="github",
674
+ tool="create_issue",
675
+ arguments={
676
+ "owner": "myorg",
677
+ "repo": "myrepo",
678
+ "title": "Bug fix",
679
+ "body": "Fixed the thing"
680
+ }
681
+ )
682
+ """
683
+ mcp_config = _get_mcp_config()
684
+ if not mcp_config:
685
+ return "No MCP config. Run 'nexus-mcp init' first."
686
+
687
+ if server not in mcp_config.servers:
688
+ available = ", ".join(sorted(mcp_config.servers.keys()))
689
+ return f"Server not found: {server}. Available: {available}"
690
+
691
+ server_config = mcp_config.servers[server]
692
+
693
+ if not server_config.enabled:
694
+ return f"Server is disabled: {server}"
695
+
696
+ conn_manager = _get_connection_manager()
697
+
698
+ try:
699
+ result = await conn_manager.invoke_tool(
700
+ server,
701
+ server_config,
702
+ tool,
703
+ arguments or {},
704
+ )
705
+
706
+ # Format result for AI consumption
707
+ if hasattr(result, "content"):
708
+ # MCP CallToolResult object
709
+ contents = []
710
+ for item in result.content:
711
+ if hasattr(item, "text"):
712
+ contents.append(item.text)
713
+ else:
714
+ contents.append(str(item))
715
+ return "\n".join(contents) if contents else "Tool executed successfully (no output)"
716
+
717
+ return str(result)
718
+
719
+ except Exception as e:
720
+ return f"Tool invocation failed: {e}"
721
+
722
+
723
+ @mcp.tool()
724
+ async def index_file(
725
+ file_path: str,
726
+ content: str | None = None,
727
+ project_id: str | None = None,
728
+ ) -> str:
729
+ """Index a file into the knowledge base.
730
+
731
+ Parses the file using language-aware chunking (extracting functions, classes,
732
+ methods) and stores it in the vector database for semantic search.
733
+
734
+ Supported file types:
735
+ - Python (.py, .pyw)
736
+ - JavaScript (.js, .jsx, .mjs, .cjs)
737
+ - TypeScript (.ts, .tsx, .mts, .cts)
738
+ - Java (.java)
739
+ - Markdown (.md, .markdown)
740
+ - RST (.rst)
741
+ - Plain text (.txt)
742
+
743
+ Args:
744
+ file_path: Path to the file (relative or absolute). The file must exist
745
+ unless content is provided.
746
+ content: Optional file content. If not provided, reads from disk.
747
+ project_id: Optional project identifier. Uses current project if not specified.
748
+
749
+ Returns:
750
+ Summary of indexed chunks including count and types.
751
+ """
752
+ config = _get_config()
753
+ if project_id:
754
+ effective_project_id = project_id
755
+ elif config:
756
+ effective_project_id = config.project_id
757
+ else:
758
+ return (
759
+ "Error: No project_id specified and no nexus_config.json found. "
760
+ "Please provide project_id or run 'nexus-init' first."
761
+ )
762
+
763
+ # Resolve file path
764
+ path = Path(file_path)
765
+ if not path.is_absolute():
766
+ path = Path.cwd() / path
767
+
768
+ # Get content
769
+ if content is None:
770
+ if not path.exists():
771
+ return f"Error: File not found: {path}"
772
+ try:
773
+ content = path.read_text(encoding="utf-8")
774
+ except Exception as e:
775
+ return f"Error reading file: {e!s}"
776
+
777
+ # Determine document type
778
+ doc_type = DocumentType.CODE
779
+ ext = path.suffix.lower()
780
+ if ext in (".md", ".markdown", ".rst", ".txt"):
781
+ doc_type = DocumentType.DOCUMENTATION
782
+
783
+ try:
784
+ # Delete existing chunks for this file
785
+ database = _get_database()
786
+ await database.delete_by_file(str(path), effective_project_id)
787
+
788
+ # Special handling for lessons to preserve them as single atomic units
789
+ # Check if file is in .nexus/lessons or has lesson frontmatter
790
+ is_lesson = ".nexus/lessons" in str(path) or (
791
+ content.startswith("---") and "problem:" in content[:200]
792
+ )
793
+
794
+ if is_lesson:
795
+ doc_type = DocumentType.LESSON
796
+ chunks = [
797
+ CodeChunk(
798
+ content=content,
799
+ chunk_type=ChunkType.LESSON,
800
+ name=path.stem,
801
+ start_line=1,
802
+ end_line=content.count("\n") + 1,
803
+ language="markdown",
804
+ file_path=str(path),
805
+ )
806
+ ]
807
+ else:
808
+ # Chunk the file normally
809
+ chunks = ChunkerRegistry.chunk_file(path, content)
810
+
811
+ if not chunks:
812
+ return f"No indexable content found in: {file_path}"
813
+
814
+ # Index chunks
815
+ await _index_chunks(chunks, effective_project_id, doc_type)
816
+
817
+ # Summarize by chunk type
818
+ type_counts: dict[str, int] = {}
819
+ for chunk in chunks:
820
+ ctype = chunk.chunk_type.value
821
+ type_counts[ctype] = type_counts.get(ctype, 0) + 1
822
+
823
+ except Exception as e:
824
+ return f"Error indexing {path.name}: {e}"
825
+
826
+ return f"Indexed {len(chunks)} chunks from {path.name}: {type_counts}"
827
+
828
+
829
+ @mcp.tool()
830
+ async def import_github_issues(
831
+ repo: str,
832
+ owner: str,
833
+ limit: int = 10,
834
+ state: str = "all",
835
+ ) -> str:
836
+ """Import GitHub issues and pull requests into the knowledge base.
837
+
838
+ Imports issues from the specified repository using the 'github' MCP server.
839
+ Items are indexed for semantic search (search_knowledge) and can be filtered
840
+ by 'github_issue' or 'github_pr' content types.
841
+
842
+ Args:
843
+ repo: Repository name (e.g., "nexus-dev").
844
+ owner: Repository owner (e.g., "mmornati").
845
+ limit: Maximum number of issues to import (default: 10).
846
+ state: Issue state filter: "open" (default), "closed", or "all".
847
+
848
+ Returns:
849
+ Summary of imported items.
850
+ """
851
+ database = _get_database()
852
+ config = _get_config()
853
+
854
+ if not config:
855
+ return "Error: No project configuration found. Run 'nexus-init' first."
856
+
857
+ from .mcp_client import MCPClientManager
858
+
859
+ client_manager = MCPClientManager()
860
+ mcp_config = _get_mcp_config()
861
+
862
+ importer = GitHubImporter(database, config.project_id, client_manager, mcp_config)
863
+
864
+ try:
865
+ count = await importer.import_issues(owner, repo, limit, state)
866
+ return f"Successfully imported {count} issues/PRs from {owner}/{repo}."
867
+ except Exception as e:
868
+ return f"Failed to import issues: {e!s}"
869
+
870
+
871
+ @mcp.tool()
872
+ async def record_lesson(
873
+ problem: str,
874
+ solution: str,
875
+ context: str | None = None,
876
+ code_snippet: str | None = None,
877
+ problem_code: str | None = None,
878
+ solution_code: str | None = None,
879
+ project_id: str | None = None,
880
+ ) -> str:
881
+ """Record a learned lesson from debugging or problem-solving.
882
+
883
+ Use this tool to store problems you've encountered and their solutions.
884
+ These lessons will be searchable and can help with similar issues in the future,
885
+ both in this project and across other projects.
886
+
887
+ Args:
888
+ problem: Clear description of the problem encountered.
889
+ Example: "TypeError when passing None to user_service.get_user()"
890
+ solution: How the problem was resolved.
891
+ Example: "Added null check before calling get_user() and return early if None"
892
+ context: Optional additional context like file path, library, error message.
893
+ code_snippet: Optional code snippet that demonstrates the problem or solution.
894
+ (Deprecated: use problem_code and solution_code for better structure)
895
+ problem_code: Code snippet showing the problematic code.
896
+ solution_code: Code snippet showing the fixed code.
897
+ project_id: Optional project identifier. Uses current project if not specified.
898
+
899
+ Returns:
900
+ Confirmation with lesson ID and a summary.
901
+ """
902
+ import yaml
903
+
904
+ config = _get_config()
905
+ if project_id:
906
+ effective_project_id = project_id
907
+ elif config:
908
+ effective_project_id = config.project_id
909
+ else:
910
+ return (
911
+ "Error: No project_id specified and no nexus_config.json found. "
912
+ "Please provide project_id or run 'nexus-init' first."
913
+ )
914
+
915
+ # Create lesson text (with YAML frontmatter)
916
+ frontmatter = {
917
+ "problem": problem,
918
+ "timestamp": datetime.now(UTC).isoformat(),
919
+ "project_id": effective_project_id,
920
+ "context": context or "",
921
+ "problem_code": problem_code or "",
922
+ "solution_code": solution_code or "",
923
+ }
924
+
925
+ lesson_parts = [
926
+ "---",
927
+ yaml.dump(frontmatter, sort_keys=False).strip(),
928
+ "---",
929
+ "",
930
+ "# Lesson: " + (problem[:50] + "..." if len(problem) > 50 else problem),
931
+ "",
932
+ "## Problem",
933
+ problem,
934
+ "",
935
+ "## Solution",
936
+ solution,
937
+ ]
938
+
939
+ if context:
940
+ lesson_parts.extend(["", "## Context", context])
941
+
942
+ if problem_code:
943
+ lesson_parts.extend(["", "## Problem Code", "```", problem_code, "```"])
944
+
945
+ if solution_code:
946
+ lesson_parts.extend(["", "## Solution Code", "```", solution_code, "```"])
947
+
948
+ # Legacy support
949
+ if code_snippet and not (problem_code or solution_code):
950
+ lesson_parts.extend(["", "## Code", "```", code_snippet, "```"])
951
+
952
+ lesson_text = "\n".join(lesson_parts)
953
+
954
+ # Create a unique ID for this lesson
955
+ lesson_id = str(uuid.uuid4())[:8]
956
+ timestamp = datetime.now(UTC).isoformat()
957
+
958
+ try:
959
+ embedder = _get_embedder()
960
+ database = _get_database()
961
+
962
+ # Generate embedding
963
+ embedding = await embedder.embed(lesson_text)
964
+
965
+ # Create document
966
+ doc = Document(
967
+ id=generate_document_id(effective_project_id, "lessons", lesson_id, 0),
968
+ text=lesson_text,
969
+ vector=embedding,
970
+ project_id=effective_project_id,
971
+ file_path=f".nexus/lessons/{lesson_id}.md",
972
+ doc_type=DocumentType.LESSON,
973
+ chunk_type="lesson",
974
+ language="markdown",
975
+ name=f"lesson_{lesson_id}",
976
+ start_line=0,
977
+ end_line=0,
978
+ )
979
+
980
+ await database.upsert_document(doc)
981
+
982
+ # Also save to .nexus/lessons directory if it exists
983
+ lessons_dir = Path.cwd() / ".nexus" / "lessons"
984
+ if lessons_dir.exists():
985
+ lesson_file = lessons_dir / f"{lesson_id}_{timestamp[:10]}.md"
986
+ try:
987
+ lesson_file.write_text(lesson_text, encoding="utf-8")
988
+ except Exception:
989
+ pass # Silently fail if we can't write to disk
990
+
991
+ return (
992
+ f"✅ Lesson recorded!\n"
993
+ f"- ID: {lesson_id}\n"
994
+ f"- Project: {effective_project_id}\n"
995
+ f"- Problem: {problem[:100]}{'...' if len(problem) > 100 else ''}"
996
+ )
997
+
998
+ except Exception as e:
999
+ return f"Failed to record lesson: {e!s}"
1000
+
1001
+
1002
+ @mcp.tool()
1003
+ async def record_insight(
1004
+ category: str,
1005
+ description: str,
1006
+ reasoning: str,
1007
+ correction: str | None = None,
1008
+ files_affected: list[str] | None = None,
1009
+ project_id: str | None = None,
1010
+ ) -> str:
1011
+ """Record an insight from LLM reasoning during development.
1012
+
1013
+ Use this tool to capture:
1014
+ - Mistakes made (wrong version, incompatible library, bad approach)
1015
+ - Discoveries during exploration (useful patterns, gotchas)
1016
+ - Backtracking decisions and their reasons
1017
+ - Optimization opportunities found
1018
+
1019
+ Args:
1020
+ category: Type of insight - "mistake", "discovery", "backtrack", or "optimization"
1021
+ description: What happened (e.g., "Used httpx 0.23 which is incompatible with Python 3.13")
1022
+ reasoning: Why it happened / what you were thinking
1023
+ (e.g., "Assumed latest version would work, didn't check compatibility")
1024
+ correction: How it was fixed (for mistakes/backtracking)
1025
+ files_affected: Optional list of affected file paths
1026
+ project_id: Optional project identifier. Uses current project if not specified.
1027
+
1028
+ Returns:
1029
+ Confirmation with insight ID and summary.
1030
+ """
1031
+ import yaml
1032
+
1033
+ config = _get_config()
1034
+ if project_id:
1035
+ effective_project_id = project_id
1036
+ elif config:
1037
+ effective_project_id = config.project_id
1038
+ else:
1039
+ return (
1040
+ "Error: No project_id specified and no nexus_config.json found. "
1041
+ "Please provide project_id or run 'nexus-init' first."
1042
+ )
1043
+
1044
+ # Validate category
1045
+ valid_categories = {"mistake", "discovery", "backtrack", "optimization"}
1046
+ if category not in valid_categories:
1047
+ return f"Error: category must be one of {valid_categories}, got '{category}'"
1048
+
1049
+ # Create insight text with YAML frontmatter
1050
+ frontmatter = {
1051
+ "category": category,
1052
+ "timestamp": datetime.now(UTC).isoformat(),
1053
+ "project_id": effective_project_id,
1054
+ "files_affected": files_affected or [],
1055
+ }
1056
+
1057
+ insight_parts = [
1058
+ "---",
1059
+ yaml.dump(frontmatter, sort_keys=False).strip(),
1060
+ "---",
1061
+ "",
1062
+ f"# Insight: {category.title()}",
1063
+ "",
1064
+ "## Description",
1065
+ description,
1066
+ "",
1067
+ "## Reasoning",
1068
+ reasoning,
1069
+ ]
1070
+
1071
+ if correction:
1072
+ insight_parts.extend(["", "## Correction", correction])
1073
+
1074
+ if files_affected:
1075
+ insight_parts.extend(["", "## Affected Files", ""])
1076
+ for file_path in files_affected:
1077
+ insight_parts.append(f"- `{file_path}`")
1078
+
1079
+ insight_text = "\n".join(insight_parts)
1080
+
1081
+ # Create unique ID
1082
+ insight_id = str(uuid.uuid4())[:8]
1083
+ timestamp = datetime.now(UTC).isoformat()
1084
+
1085
+ try:
1086
+ embedder = _get_embedder()
1087
+ database = _get_database()
1088
+
1089
+ # Generate embedding
1090
+ embedding = await embedder.embed(insight_text)
1091
+
1092
+ # Create document
1093
+ doc = Document(
1094
+ id=generate_document_id(effective_project_id, "insights", insight_id, 0),
1095
+ text=insight_text,
1096
+ vector=embedding,
1097
+ project_id=effective_project_id,
1098
+ file_path=f".nexus/insights/{insight_id}.md",
1099
+ doc_type=DocumentType.INSIGHT,
1100
+ chunk_type="insight",
1101
+ language="markdown",
1102
+ name=f"{category}_{insight_id}",
1103
+ start_line=0,
1104
+ end_line=0,
1105
+ )
1106
+
1107
+ await database.upsert_document(doc)
1108
+
1109
+ # Save to .nexus/insights directory if it exists
1110
+ insights_dir = Path.cwd() / ".nexus" / "insights"
1111
+ if insights_dir.exists():
1112
+ insight_file = insights_dir / f"{insight_id}_{timestamp[:10]}.md"
1113
+ try:
1114
+ insight_file.write_text(insight_text, encoding="utf-8")
1115
+ except Exception:
1116
+ pass
1117
+
1118
+ return (
1119
+ f"✅ Insight recorded!\n"
1120
+ f"- ID: {insight_id}\n"
1121
+ f"- Category: {category}\n"
1122
+ f"- Project: {effective_project_id}\n"
1123
+ f"- Description: {description[:100]}{'...' if len(description) > 100 else ''}"
1124
+ )
1125
+
1126
+ except Exception as e:
1127
+ return f"Failed to record insight: {e!s}"
1128
+
1129
+
1130
+ @mcp.tool()
1131
+ async def search_insights(
1132
+ query: str,
1133
+ category: str | None = None,
1134
+ project_id: str | None = None,
1135
+ limit: int = 5,
1136
+ ) -> str:
1137
+ """Search recorded insights from past development sessions.
1138
+
1139
+ Use when:
1140
+ - Starting work on similar features
1141
+ - Debugging issues that might have been seen before
1142
+ - Looking for optimization patterns
1143
+ - Checking if a mistake was already made
1144
+
1145
+ Args:
1146
+ query: Description of what you're looking for.
1147
+ Examples: "httpx compatibility", "authentication mistakes",
1148
+ "database optimization patterns"
1149
+ category: Optional filter - "mistake", "discovery", "backtrack", or "optimization"
1150
+ project_id: Optional project identifier. Searches all projects if not specified.
1151
+ limit: Maximum number of results (default: 5, max: 20).
1152
+
1153
+ Returns:
1154
+ Relevant insights with category, description, and reasoning.
1155
+ """
1156
+ database = _get_database()
1157
+ limit = min(max(1, limit), 20)
1158
+
1159
+ # Validate category if provided
1160
+ if category:
1161
+ valid_categories = {"mistake", "discovery", "backtrack", "optimization"}
1162
+ if category not in valid_categories:
1163
+ return f"Error: category must be one of {valid_categories}, got '{category}'"
1164
+
1165
+ try:
1166
+ results = await database.search(
1167
+ query=query,
1168
+ project_id=project_id,
1169
+ doc_type=DocumentType.INSIGHT,
1170
+ limit=limit,
1171
+ )
1172
+
1173
+ # Filter by category if specified
1174
+ if category and results:
1175
+ results = [r for r in results if category in r.name]
1176
+
1177
+ if not results:
1178
+ msg = f"No insights found for: '{query}'"
1179
+ if category:
1180
+ msg += f" (category: {category})"
1181
+ return msg + "\n\nTip: Use record_insight to save insights for future reference."
1182
+
1183
+ output_parts = [f"## Insights Found: '{query}'", ""]
1184
+ if category:
1185
+ output_parts[0] += f" (category: {category})"
1186
+
1187
+ for i, result in enumerate(results, 1):
1188
+ output_parts.append(f"### Insight {i}")
1189
+ output_parts.append(f"**ID:** {result.name}")
1190
+ output_parts.append(f"**Project:** {result.project_id}")
1191
+ output_parts.append("")
1192
+ output_parts.append(result.text)
1193
+ output_parts.append("")
1194
+ output_parts.append("---")
1195
+ output_parts.append("")
1196
+
1197
+ return "\n".join(output_parts)
1198
+
1199
+ except Exception as e:
1200
+ return f"Insight search failed: {e!s}"
1201
+
1202
+
1203
+ @mcp.tool()
1204
+ async def record_implementation(
1205
+ title: str,
1206
+ summary: str,
1207
+ approach: str,
1208
+ design_decisions: list[str],
1209
+ files_changed: list[str],
1210
+ related_plan: str | None = None,
1211
+ project_id: str | None = None,
1212
+ ) -> str:
1213
+ """Record a completed implementation for future reference.
1214
+
1215
+ Use this tool after completing a feature or significant work to capture:
1216
+ - What was built and why
1217
+ - Technical approach used
1218
+ - Key design decisions
1219
+ - Files involved
1220
+
1221
+ Args:
1222
+ title: Short title (e.g., "Add user authentication", "Refactor database layer")
1223
+ summary: What was implemented (1-3 sentences)
1224
+ approach: How it was done - technical approach/architecture used
1225
+ design_decisions: List of key decisions with rationale
1226
+ (e.g., ["Used JWT over sessions for stateless auth",
1227
+ "Chose Redis for session cache due to speed requirements"])
1228
+ files_changed: List of files modified/created
1229
+ related_plan: Optional path or URL to implementation plan
1230
+ project_id: Optional project identifier. Uses current project if not specified.
1231
+
1232
+ Returns:
1233
+ Confirmation with implementation ID and summary.
1234
+ """
1235
+ import yaml
1236
+
1237
+ config = _get_config()
1238
+ if project_id:
1239
+ effective_project_id = project_id
1240
+ elif config:
1241
+ effective_project_id = config.project_id
1242
+ else:
1243
+ return (
1244
+ "Error: No project_id specified and no nexus_config.json found. "
1245
+ "Please provide project_id or run 'nexus-init' first."
1246
+ )
1247
+
1248
+ # Create implementation text with YAML frontmatter
1249
+ frontmatter = {
1250
+ "title": title,
1251
+ "timestamp": datetime.now(UTC).isoformat(),
1252
+ "project_id": effective_project_id,
1253
+ "files_changed": files_changed,
1254
+ "related_plan": related_plan or "",
1255
+ }
1256
+
1257
+ impl_parts = [
1258
+ "---",
1259
+ yaml.dump(frontmatter, sort_keys=False).strip(),
1260
+ "---",
1261
+ "",
1262
+ f"# Implementation: {title}",
1263
+ "",
1264
+ "## Summary",
1265
+ summary,
1266
+ "",
1267
+ "## Technical Approach",
1268
+ approach,
1269
+ "",
1270
+ "## Design Decisions",
1271
+ ]
1272
+
1273
+ for decision in design_decisions:
1274
+ impl_parts.append(f"- {decision}")
1275
+
1276
+ impl_parts.extend(["", "## Files Changed", ""])
1277
+ for file_path in files_changed:
1278
+ impl_parts.append(f"- `{file_path}`")
1279
+
1280
+ impl_text = "\n".join(impl_parts)
1281
+
1282
+ # Create unique ID
1283
+ impl_id = str(uuid.uuid4())[:8]
1284
+ timestamp = datetime.now(UTC).isoformat()
1285
+
1286
+ try:
1287
+ embedder = _get_embedder()
1288
+ database = _get_database()
1289
+
1290
+ # Generate embedding
1291
+ embedding = await embedder.embed(impl_text)
1292
+
1293
+ # Create document
1294
+ doc = Document(
1295
+ id=generate_document_id(effective_project_id, "implementations", impl_id, 0),
1296
+ text=impl_text,
1297
+ vector=embedding,
1298
+ project_id=effective_project_id,
1299
+ file_path=f".nexus/implementations/{impl_id}.md",
1300
+ doc_type=DocumentType.IMPLEMENTATION,
1301
+ chunk_type="implementation",
1302
+ language="markdown",
1303
+ name=f"impl_{impl_id}",
1304
+ start_line=0,
1305
+ end_line=0,
1306
+ )
1307
+
1308
+ await database.upsert_document(doc)
1309
+
1310
+ # Save to .nexus/implementations directory if it exists
1311
+ impl_dir = Path.cwd() / ".nexus" / "implementations"
1312
+ if impl_dir.exists():
1313
+ impl_file = impl_dir / f"{impl_id}_{timestamp[:10]}.md"
1314
+ try:
1315
+ impl_file.write_text(impl_text, encoding="utf-8")
1316
+ except Exception:
1317
+ pass
1318
+
1319
+ return (
1320
+ f"✅ Implementation recorded!\n"
1321
+ f"- ID: {impl_id}\n"
1322
+ f"- Title: {title}\n"
1323
+ f"- Project: {effective_project_id}\n"
1324
+ f"- Files: {len(files_changed)} changed"
1325
+ )
1326
+
1327
+ except Exception as e:
1328
+ return f"Failed to record implementation: {e!s}"
1329
+
1330
+
1331
+ @mcp.tool()
1332
+ async def search_implementations(
1333
+ query: str,
1334
+ project_id: str | None = None,
1335
+ limit: int = 5,
1336
+ ) -> str:
1337
+ """Search recorded implementations.
1338
+
1339
+ Use to find:
1340
+ - How similar features were built
1341
+ - Design patterns used in the project
1342
+ - Past technical approaches
1343
+ - Implementation history
1344
+
1345
+ Args:
1346
+ query: What you're looking for.
1347
+ Examples: "authentication implementation", "database refactor",
1348
+ "API design patterns"
1349
+ project_id: Optional project identifier. Searches all projects if not specified.
1350
+ limit: Maximum number of results (default: 5, max: 20).
1351
+
1352
+ Returns:
1353
+ Relevant implementations with summaries and design decisions.
1354
+ """
1355
+ database = _get_database()
1356
+ limit = min(max(1, limit), 20)
1357
+
1358
+ try:
1359
+ results = await database.search(
1360
+ query=query,
1361
+ project_id=project_id,
1362
+ doc_type=DocumentType.IMPLEMENTATION,
1363
+ limit=limit,
1364
+ )
1365
+
1366
+ if not results:
1367
+ return (
1368
+ f"No implementations found for: '{query}'\n\n"
1369
+ "Tip: Use record_implementation after completing features to save them."
1370
+ )
1371
+
1372
+ output_parts = [f"## Implementations Found: '{query}'", ""]
1373
+
1374
+ for i, result in enumerate(results, 1):
1375
+ output_parts.append(f"### Implementation {i}")
1376
+ output_parts.append(f"**ID:** {result.name}")
1377
+ output_parts.append(f"**Project:** {result.project_id}")
1378
+ output_parts.append("")
1379
+ # Truncate long content
1380
+ output_parts.append(result.text[:3000])
1381
+ if len(result.text) > 3000:
1382
+ output_parts.append("\n... (truncated)")
1383
+ output_parts.append("")
1384
+ output_parts.append("---")
1385
+ output_parts.append("")
1386
+
1387
+ return "\n".join(output_parts)
1388
+
1389
+ except Exception as e:
1390
+ return f"Implementation search failed: {e!s}"
1391
+
1392
+
1393
+ @mcp.resource("mcp://nexus-dev/active-tools")
1394
+ async def get_active_tools_resource() -> str:
1395
+ """List MCP tools from active profile servers.
1396
+
1397
+ Returns a list of tools that are available based on the current
1398
+ profile configuration in .nexus/mcp_config.json.
1399
+ """
1400
+ mcp_config = _get_mcp_config()
1401
+ if not mcp_config:
1402
+ return "No MCP config found. Run 'nexus-mcp init' first."
1403
+
1404
+ database = _get_database()
1405
+ active_servers = _get_active_server_names()
1406
+
1407
+ if not active_servers:
1408
+ return f"No active servers in profile: {mcp_config.active_profile}"
1409
+
1410
+ # Query all tools once from the database
1411
+ all_tools = await database.search(
1412
+ query="",
1413
+ doc_type=DocumentType.TOOL,
1414
+ limit=1000, # Get all tools
1415
+ )
1416
+
1417
+ # Filter tools by active servers
1418
+ tools = [t for t in all_tools if t.server_name in active_servers]
1419
+
1420
+ # Format output
1421
+ output = [f"# Active Tools (profile: {mcp_config.active_profile})", ""]
1422
+
1423
+ for server in active_servers:
1424
+ server_tools = [t for t in tools if t.server_name == server]
1425
+ output.append(f"## {server}")
1426
+ if server_tools:
1427
+ for tool in server_tools:
1428
+ # Truncate description to 100 chars
1429
+ desc = tool.text[:100] + "..." if len(tool.text) > 100 else tool.text
1430
+ output.append(f"- {tool.name}: {desc}")
1431
+ else:
1432
+ output.append("*No tools found*")
1433
+ output.append("")
1434
+
1435
+ return "\n".join(output)
1436
+
1437
+
1438
+ @mcp.tool()
1439
+ async def get_project_context(
1440
+ project_id: str | None = None,
1441
+ limit: int = 10,
1442
+ ) -> str:
1443
+ """Get recent lessons and discoveries for a project.
1444
+
1445
+ Returns a summary of recent lessons learned and indexed content for the
1446
+ specified project. Useful for getting up to speed on a project or
1447
+ reviewing what the AI assistant has learned.
1448
+
1449
+ Args:
1450
+ project_id: Project identifier. Uses current project if not specified.
1451
+ limit: Maximum number of recent items to return (default: 10).
1452
+
1453
+ Returns:
1454
+ Summary of project knowledge including statistics and recent lessons.
1455
+ """
1456
+ config = _get_config()
1457
+ database = _get_database()
1458
+
1459
+ # If no project specified and no config, show stats for all projects
1460
+ if project_id is None and config is None:
1461
+ project_name = "All Projects"
1462
+ effective_project_id = None # Will get stats for all
1463
+ elif project_id is not None:
1464
+ project_name = f"Project {project_id[:8]}..."
1465
+ effective_project_id = project_id
1466
+ else:
1467
+ # config is guaranteed not None here (checked at line 595)
1468
+ assert config is not None
1469
+ project_name = config.project_name
1470
+ effective_project_id = config.project_id
1471
+
1472
+ limit = min(max(1, limit), 50)
1473
+
1474
+ try:
1475
+ # Get project statistics (None = all projects)
1476
+ stats = await database.get_project_stats(effective_project_id)
1477
+
1478
+ # Get recent lessons (None = all projects)
1479
+ recent_lessons = await database.get_recent_lessons(effective_project_id, limit)
1480
+
1481
+ # Format output
1482
+ output_parts = [
1483
+ f"## Project Context: {project_name}",
1484
+ f"**Project ID:** `{effective_project_id or 'all'}`",
1485
+ "",
1486
+ "### Statistics",
1487
+ f"- Total indexed chunks: {stats.get('total', 0)}",
1488
+ f"- Code chunks: {stats.get('code', 0)}",
1489
+ f"- Documentation chunks: {stats.get('documentation', 0)}",
1490
+ f"- Lessons: {stats.get('lesson', 0)}",
1491
+ "",
1492
+ ]
1493
+
1494
+ if recent_lessons:
1495
+ output_parts.append("### Recent Lessons")
1496
+ output_parts.append("")
1497
+
1498
+ for lesson in recent_lessons:
1499
+ import yaml
1500
+
1501
+ output_parts.append(f"#### {lesson.name}")
1502
+ # Extract just the problem summary
1503
+ # Extract problem from frontmatter or text
1504
+ problem = ""
1505
+ if lesson.text.startswith("---"):
1506
+ try:
1507
+ # Extract between first and second ---
1508
+ parts = lesson.text.split("---", 2)
1509
+ if len(parts) >= 3:
1510
+ fm = yaml.safe_load(parts[1])
1511
+ problem = fm.get("problem", "")
1512
+ except Exception:
1513
+ pass
1514
+
1515
+ if not problem:
1516
+ lines = lesson.text.split("\n")
1517
+ for i, line in enumerate(lines):
1518
+ if line.strip() == "## Problem" and i + 1 < len(lines):
1519
+ problem = lines[i + 1].strip()
1520
+ break
1521
+
1522
+ if problem:
1523
+ output_parts.append(f"**Problem:** {problem[:200]}...")
1524
+ output_parts.append("")
1525
+
1526
+ else:
1527
+ output_parts.append("*No lessons recorded yet.*")
1528
+
1529
+ return "\n".join(output_parts)
1530
+
1531
+ except Exception as e:
1532
+ return f"Failed to get project context: {e!s}"
1533
+
1534
+
1535
+ async def _get_project_root_from_session(ctx: Context[Any, Any]) -> Path | None:
1536
+ """Get the project root from MCP session roots.
1537
+
1538
+ Uses session.list_roots() to query the IDE for workspace folders.
1539
+
1540
+ Args:
1541
+ ctx: FastMCP Context with session access.
1542
+
1543
+ Returns:
1544
+ Path to the project root if found, None otherwise.
1545
+ """
1546
+ try:
1547
+ # Query the IDE for workspace roots
1548
+ roots_result = await ctx.session.list_roots()
1549
+
1550
+ if not roots_result.roots:
1551
+ logger.debug("No roots returned from session.list_roots()")
1552
+ return None
1553
+
1554
+ # Look for a root that contains nexus_config.json (indicates a nexus project)
1555
+ for root in roots_result.roots:
1556
+ uri = str(root.uri)
1557
+ # Handle file:// URIs
1558
+ path = Path(uri[7:]) if uri.startswith("file://") else Path(uri)
1559
+
1560
+ if path.exists() and (path / "nexus_config.json").exists():
1561
+ logger.debug("Found nexus project root from session: %s", path)
1562
+ return path
1563
+
1564
+ # Fall back to first root if none have nexus_config.json
1565
+ first_uri = str(roots_result.roots[0].uri)
1566
+ path = Path(first_uri[7:]) if first_uri.startswith("file://") else Path(first_uri)
1567
+
1568
+ if path.exists():
1569
+ logger.debug("Using first root from session: %s", path)
1570
+ return path
1571
+
1572
+ except Exception as e:
1573
+ logger.debug("Failed to get roots from session: %s", e)
1574
+
1575
+ return None
1576
+
1577
+
1578
+ @mcp.tool()
1579
+ async def list_agents(ctx: Context[Any, Any]) -> str:
1580
+ """List available agents in the current workspace.
1581
+
1582
+ Discovers agents from the agents/ directory in the IDE's current workspace.
1583
+ Use ask_agent tool to execute tasks with a specific agent.
1584
+
1585
+ Returns:
1586
+ List of available agents with names and descriptions.
1587
+ """
1588
+ # Try to get project root from session (MCP roots)
1589
+ project_root = await _get_project_root_from_session(ctx)
1590
+
1591
+ # Fall back to environment variable or cwd
1592
+ if not project_root:
1593
+ project_root = _find_project_root()
1594
+
1595
+ if not project_root:
1596
+ return "No project root found. Make sure you have a nexus_config.json in your workspace."
1597
+
1598
+ agents_dir = project_root / "agents"
1599
+ if not agents_dir.exists():
1600
+ return f"No agents directory found at {agents_dir}. Create it and add agent YAML files."
1601
+
1602
+ # Load agents from directory
1603
+ agent_manager = AgentManager(agents_dir=agents_dir)
1604
+
1605
+ if len(agent_manager) == 0:
1606
+ return f"No agents found in {agents_dir}. Add YAML agent configuration files."
1607
+
1608
+ lines = ["# Available Agents", ""]
1609
+ for agent in agent_manager:
1610
+ lines.append(f"## {agent.display_name or agent.name}")
1611
+ lines.append(f"- **Name:** `{agent.name}`")
1612
+ lines.append(f"- **Description:** {agent.description}")
1613
+ if agent.profile:
1614
+ lines.append(f"- **Role:** {agent.profile.role}")
1615
+ lines.append("")
1616
+
1617
+ lines.append("Use `ask_agent` tool with the agent name to execute a task.")
1618
+ return "\n".join(lines)
1619
+
1620
+
1621
+ @mcp.tool()
1622
+ async def ask_agent(agent_name: str, task: str, ctx: Context[Any, Any]) -> str:
1623
+ """Execute a task using a custom agent from the current workspace.
1624
+
1625
+ Loads the specified agent from the workspace's agents/ directory and
1626
+ executes the given task.
1627
+
1628
+ Args:
1629
+ agent_name: Name of the agent to use (e.g., 'nexus_architect').
1630
+ task: The task description to execute.
1631
+
1632
+ Returns:
1633
+ Agent's response.
1634
+ """
1635
+ # Get database
1636
+ database = _get_database()
1637
+ if database is None:
1638
+ return "Database not initialized. Run nexus-init first."
1639
+
1640
+ # Try to get project root from session (MCP roots)
1641
+ project_root = await _get_project_root_from_session(ctx)
1642
+
1643
+ # Fall back to environment variable or cwd
1644
+ if not project_root:
1645
+ project_root = _find_project_root()
1646
+
1647
+ if not project_root:
1648
+ return "No project root found. Make sure you have a nexus_config.json in your workspace."
1649
+
1650
+ agents_dir = project_root / "agents"
1651
+ if not agents_dir.exists():
1652
+ return f"No agents directory found at {agents_dir}."
1653
+
1654
+ # Load agents from directory
1655
+ agent_manager = AgentManager(agents_dir=agents_dir)
1656
+ agent_config = agent_manager.get_agent(agent_name)
1657
+
1658
+ if not agent_config:
1659
+ available = [a.name for a in agent_manager]
1660
+ return f"Agent '{agent_name}' not found. Available agents: {available}"
1661
+
1662
+ # Execute the task
1663
+ try:
1664
+ executor = AgentExecutor(agent_config, database, mcp)
1665
+ config = _get_config()
1666
+ project_id = config.project_id if config else None
1667
+ return await executor.execute(task, project_id)
1668
+ except Exception as e:
1669
+ logger.error("Agent execution failed: %s", e, exc_info=True)
1670
+ return f"Agent execution failed: {e!s}"
1671
+
1672
+
1673
+ @mcp.tool()
1674
+ async def refresh_agents(ctx: Context[Any, Any]) -> str:
1675
+ """Discovers and registers individual agent tools from the current workspace.
1676
+
1677
+ This tool:
1678
+ 1. Queries the IDE for the current workspace root.
1679
+ 2. Scans the 'agents/' directory for agent configurations.
1680
+ 3. Dynamically registers 'ask_<agent_name>' tools for each agent found.
1681
+ 4. Notifies the IDE that the tool list has changed.
1682
+
1683
+ Returns:
1684
+ A report of registered agents or an error message.
1685
+ """
1686
+ project_root = await _get_project_root_from_session(ctx)
1687
+ if not project_root:
1688
+ return "No nexus project root found in workspace (nexus_config.json missing)."
1689
+
1690
+ # Persist the root globally so other tools find it
1691
+ global _project_root
1692
+ _project_root = project_root
1693
+
1694
+ # Reload other configs if they were initialized lazily from /
1695
+ global _config, _mcp_config, _database
1696
+ _config = None
1697
+ _mcp_config = None
1698
+ _database = None
1699
+
1700
+ database = _get_database()
1701
+ if database is None:
1702
+ return "Database not initialized."
1703
+
1704
+ agents_dir = project_root / "agents"
1705
+ if not agents_dir.exists():
1706
+ return f"No agents directory found at {agents_dir}."
1707
+
1708
+ global _agent_manager
1709
+ _agent_manager = AgentManager(agents_dir=agents_dir)
1710
+
1711
+ if len(_agent_manager) == 0:
1712
+ return "No agents found in agents/ directory."
1713
+
1714
+ # Register the tools
1715
+ _register_agent_tools(database, _agent_manager)
1716
+
1717
+ # Notify the client that the tool list has changed
1718
+ try:
1719
+ await ctx.session.send_tool_list_changed()
1720
+ except Exception as e:
1721
+ logger.warning("Failed to send tool_list_changed notification: %s", e)
1722
+
1723
+ agent_names = [a.name for a in _agent_manager]
1724
+ return f"Successfully registered {len(agent_names)} agent tools: {', '.join(agent_names)}"
1725
+
1726
+
1727
+ def _register_agent_tools(database: NexusDatabase, agent_manager: AgentManager | None) -> None:
1728
+ """Register dynamic tools for each loaded agent.
1729
+
1730
+ Each agent becomes an MCP tool named `ask_<agent_name>`.
1731
+ """
1732
+ if agent_manager is None:
1733
+ return
1734
+
1735
+ for agent_config in agent_manager:
1736
+
1737
+ def create_agent_tool(cfg: AgentConfig) -> Any:
1738
+ """Create a closure to capture the agent config."""
1739
+
1740
+ async def agent_tool(task: str) -> str:
1741
+ """Execute a task using the configured agent.
1742
+
1743
+ Args:
1744
+ task: The task description to execute.
1745
+
1746
+ Returns:
1747
+ Agent's response.
1748
+ """
1749
+ logger.info("Agent tool called: ask_%s for task: %s", cfg.name, task[:100])
1750
+ executor = AgentExecutor(cfg, database, mcp)
1751
+ config = _get_config()
1752
+ project_id = config.project_id if config else None
1753
+ return await executor.execute(task, project_id)
1754
+
1755
+ # Set the docstring dynamically
1756
+ agent_tool.__doc__ = cfg.description
1757
+ return agent_tool
1758
+
1759
+ tool_name = f"ask_{agent_config.name}"
1760
+ tool_func = create_agent_tool(agent_config)
1761
+
1762
+ # We use mcp.add_tool directly to allow dynamic registration at runtime
1763
+ # FastMCP.tool is a decorator, add_tool is the underlying method
1764
+ mcp.add_tool(fn=tool_func, name=tool_name, description=agent_config.description)
1765
+ logger.info("Registered agent tool: %s", tool_name)
1766
+
1767
+
1768
+ def main() -> None:
1769
+ """Run the MCP server."""
1770
+ import argparse
1771
+ import signal
1772
+ import sys
1773
+ from types import FrameType
1774
+
1775
+ # Parse command-line arguments
1776
+ parser = argparse.ArgumentParser(description="Nexus-Dev MCP Server")
1777
+ parser.add_argument(
1778
+ "--transport",
1779
+ choices=["stdio", "sse"],
1780
+ default="stdio",
1781
+ help="Transport mode: stdio (default) or sse for Docker/network deployment",
1782
+ )
1783
+ parser.add_argument(
1784
+ "--port",
1785
+ type=int,
1786
+ default=8080,
1787
+ help="Port for SSE transport (default: 8080)",
1788
+ )
1789
+ parser.add_argument(
1790
+ "--host",
1791
+ default="0.0.0.0",
1792
+ help="Host for SSE transport (default: 0.0.0.0)",
1793
+ )
1794
+ args = parser.parse_args()
1795
+
1796
+ # Configure logging to always use stderr and a debug file
1797
+ root_logger = logging.getLogger()
1798
+ for handler in root_logger.handlers[:]:
1799
+ root_logger.removeHandler(handler)
1800
+
1801
+ # Stderr handler
1802
+ log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
1803
+ stderr_handler = logging.StreamHandler(sys.stderr)
1804
+ stderr_handler.setFormatter(logging.Formatter(log_format))
1805
+ root_logger.addHandler(stderr_handler)
1806
+
1807
+ # File handler for persistent debugging
1808
+ try:
1809
+ file_handler = logging.FileHandler("/tmp/nexus-dev-debug.log")
1810
+ file_handler.setFormatter(logging.Formatter(log_format))
1811
+ file_handler.setLevel(logging.DEBUG)
1812
+ root_logger.addHandler(file_handler)
1813
+ except Exception:
1814
+ pass # Fallback if /tmp is not writable
1815
+
1816
+ root_logger.setLevel(logging.DEBUG)
1817
+
1818
+ # Also ensure the module-specific logger is at INFO
1819
+ logger.setLevel(logging.DEBUG)
1820
+
1821
+ def handle_signal(sig: int, frame: FrameType | None) -> None:
1822
+ logger.info("Received signal %s, shutting down...", sig)
1823
+ sys.exit(0)
1824
+
1825
+ signal.signal(signal.SIGINT, handle_signal)
1826
+ signal.signal(signal.SIGTERM, handle_signal)
1827
+
1828
+ # Initialize on startup
1829
+ try:
1830
+ logger.info("Starting Nexus-Dev MCP server...")
1831
+ _get_config()
1832
+ database = _get_database()
1833
+ _get_mcp_config()
1834
+
1835
+ # Load and register custom agents
1836
+ # Find project root and look for agents directory
1837
+ logger.debug("Current working directory: %s", Path.cwd())
1838
+ project_root = _find_project_root()
1839
+ agents_dir = project_root / "agents" if project_root else None
1840
+ logger.debug("Project root: %s", project_root)
1841
+ logger.debug("Agents directory: %s", agents_dir)
1842
+
1843
+ global _agent_manager
1844
+ _agent_manager = AgentManager(agents_dir=agents_dir)
1845
+ _register_agent_tools(database, _agent_manager)
1846
+
1847
+ # Run server with selected transport
1848
+ if args.transport == "sse":
1849
+ logger.info(
1850
+ "Server initialization complete, running SSE transport on %s:%d",
1851
+ args.host,
1852
+ args.port,
1853
+ )
1854
+ mcp.run(transport="sse", host=args.host, port=args.port) # type: ignore
1855
+ else:
1856
+ logger.info("Server initialization complete, running stdio transport")
1857
+ mcp.run(transport="stdio")
1858
+ except Exception as e:
1859
+ logger.critical("Fatal error in MCP server: %s", e, exc_info=True)
1860
+ sys.exit(1)
1861
+ finally:
1862
+ logger.info("MCP server shutdown complete")
1863
+
1864
+
1865
+ if __name__ == "__main__":
1866
+ main()