docugraph 0.1.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.
Files changed (37) hide show
  1. docugraph/__init__.py +24 -0
  2. docugraph/_version.py +28 -0
  3. docugraph/agents/__init__.py +27 -0
  4. docugraph/agents/coding_agent.py +532 -0
  5. docugraph/agents/tools/__init__.py +62 -0
  6. docugraph/agents/tools/graph_query.py +203 -0
  7. docugraph/agents/tools/memory_ops.py +305 -0
  8. docugraph/agents/tools/search_code.py +305 -0
  9. docugraph/agents/tools/search_docs.py +148 -0
  10. docugraph/core/__init__.py +34 -0
  11. docugraph/core/config.py +183 -0
  12. docugraph/core/embeddings.py +394 -0
  13. docugraph/core/llm.py +892 -0
  14. docugraph/core/models.py +145 -0
  15. docugraph/ingestion/__init__.py +64 -0
  16. docugraph/ingestion/api_docs.py +646 -0
  17. docugraph/ingestion/chunker.py +382 -0
  18. docugraph/ingestion/crawler.py +305 -0
  19. docugraph/ingestion/git_indexer.py +556 -0
  20. docugraph/ingestion/local_files.py +516 -0
  21. docugraph/interfaces/__init__.py +1 -0
  22. docugraph/interfaces/cli.py +983 -0
  23. docugraph/interfaces/mcp_server.py +918 -0
  24. docugraph/retrieval/__init__.py +54 -0
  25. docugraph/retrieval/context_builder.py +522 -0
  26. docugraph/retrieval/hybrid_search.py +534 -0
  27. docugraph/retrieval/reranker.py +532 -0
  28. docugraph/storage/__init__.py +14 -0
  29. docugraph/storage/graph_store.py +602 -0
  30. docugraph/storage/memory_store.py +529 -0
  31. docugraph/storage/vector_store.py +367 -0
  32. docugraph-0.1.0.dist-info/METADATA +354 -0
  33. docugraph-0.1.0.dist-info/RECORD +37 -0
  34. docugraph-0.1.0.dist-info/WHEEL +4 -0
  35. docugraph-0.1.0.dist-info/entry_points.txt +3 -0
  36. docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
@@ -0,0 +1,918 @@
1
+ """MCP (Model Context Protocol) server for DocuGraph AI.
2
+
3
+ Provides tools for Claude Code and other MCP-compatible clients:
4
+ - search_docs: Search indexed documentation
5
+ - hybrid_search: Combine vector, keyword, and graph search
6
+ - crawl_url: Crawl and index a URL
7
+ - index_git: Clone and index a git repository
8
+ - memory_store: Store values in agent memory
9
+ - memory_recall: Retrieve values from agent memory
10
+ - graph_query: Query the knowledge graph
11
+ - graph_add: Add content to the knowledge graph
12
+ - get_stats: Get indexing statistics
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+ from typing import Any
18
+
19
+ from mcp.server import Server
20
+ from mcp.server.stdio import stdio_server
21
+ from mcp.types import (
22
+ CallToolResult,
23
+ TextContent,
24
+ Tool,
25
+ ToolAnnotations,
26
+ )
27
+
28
+ from docugraph._version import __version__
29
+ from docugraph.core.llm import LLMUnavailableError
30
+ from docugraph.storage.vector_store import VectorStore
31
+
32
+ # Initialize server. `version` is passed explicitly: without it the MCP SDK
33
+ # falls back to the *mcp* package's version (server.py: `server_version =
34
+ # self.version if self.version else pkg_version("mcp")`), so clients would see
35
+ # the SDK's version advertised as ours.
36
+ server = Server("docugraph", version=__version__)
37
+
38
+ # Global vector store instance
39
+ _vector_store: VectorStore | None = None
40
+
41
+
42
+ def get_vector_store() -> VectorStore:
43
+ """Get or create the vector store instance."""
44
+ global _vector_store
45
+ if _vector_store is None:
46
+ _vector_store = VectorStore()
47
+ return _vector_store
48
+
49
+
50
+ # MCP annotations, one per tool. An MCP client uses these hints to decide
51
+ # whether a call needs human approval, so they must describe what the tool
52
+ # actually does. Re-check this table whenever a tool's behaviour changes.
53
+ #
54
+ # destructiveHint=False is a claim that the call *only adds* -- that nothing
55
+ # already stored is replaced or lost. Three of the four writers keep that claim.
56
+ # `crawl_url` and `index_git` append chunks: `VectorStore.add_chunks` calls
57
+ # LanceDB `add`, which appends, and no delete or merge path runs -- re-indexing
58
+ # a source therefore duplicates its chunks rather than replacing them, which is a
59
+ # separate idempotency question and not a destructive one. `graph_add` appends a
60
+ # graph episode.
61
+ #
62
+ # `memory_store` does not keep it. `MemoryStore.set` looks up the row for the
63
+ # (repository, branch, key) it is given and UPDATEs it, so the value that was
64
+ # under that key is gone. Overwriting a record a caller names by key is still an
65
+ # overwrite, and the hint means "only adds", so this one is destructive.
66
+ TOOL_ANNOTATIONS: dict[str, ToolAnnotations] = {
67
+ # Performs no writes.
68
+ "search_docs": ToolAnnotations(readOnlyHint=True, destructiveHint=False),
69
+ "hybrid_search": ToolAnnotations(readOnlyHint=True, destructiveHint=False),
70
+ "memory_recall": ToolAnnotations(readOnlyHint=True, destructiveHint=False),
71
+ "graph_query": ToolAnnotations(readOnlyHint=True, destructiveHint=False),
72
+ "get_stats": ToolAnnotations(readOnlyHint=True, destructiveHint=False),
73
+ # Writes, additively.
74
+ "crawl_url": ToolAnnotations(readOnlyHint=False, destructiveHint=False),
75
+ "index_git": ToolAnnotations(readOnlyHint=False, destructiveHint=False),
76
+ "graph_add": ToolAnnotations(readOnlyHint=False, destructiveHint=False),
77
+ # Writes, replacing the value already held under `key`.
78
+ "memory_store": ToolAnnotations(readOnlyHint=False, destructiveHint=True),
79
+ }
80
+
81
+
82
+ def _annotate(tools: list[Tool]) -> list[Tool]:
83
+ """Attach the declared annotation to every tool.
84
+
85
+ Raises KeyError (rather than defaulting) if a tool was added without a
86
+ classification: an unclassified tool must not reach a client.
87
+ """
88
+ for tool in tools:
89
+ tool.annotations = TOOL_ANNOTATIONS[tool.name]
90
+ return tools
91
+
92
+
93
+ @server.list_tools()
94
+ async def list_tools() -> list[Tool]:
95
+ """List available tools."""
96
+ return _annotate(
97
+ [
98
+ # Search tools
99
+ Tool(
100
+ name="search_docs",
101
+ description=(
102
+ "Search indexed documentation for relevant content. "
103
+ "Returns chunks of documentation that match the query, "
104
+ "with relevance scores and source information."
105
+ ),
106
+ inputSchema={
107
+ "type": "object",
108
+ "properties": {
109
+ "query": {
110
+ "type": "string",
111
+ "description": "The search query to find relevant documentation",
112
+ },
113
+ "top_k": {
114
+ "type": "integer",
115
+ "description": "Number of results to return (default: 5)",
116
+ "default": 5,
117
+ },
118
+ },
119
+ "required": ["query"],
120
+ },
121
+ ),
122
+ Tool(
123
+ name="hybrid_search",
124
+ description=(
125
+ "Perform hybrid search combining vector similarity, keyword matching, "
126
+ "and optionally knowledge graph traversal for more comprehensive results."
127
+ ),
128
+ inputSchema={
129
+ "type": "object",
130
+ "properties": {
131
+ "query": {
132
+ "type": "string",
133
+ "description": "The search query",
134
+ },
135
+ "top_k": {
136
+ "type": "integer",
137
+ "description": "Number of results to return (default: 10)",
138
+ "default": 10,
139
+ },
140
+ "mode": {
141
+ "type": "string",
142
+ "description": "Search mode: vector, keyword, hybrid, or all (default: hybrid)",
143
+ "enum": ["vector", "keyword", "hybrid", "all"],
144
+ "default": "hybrid",
145
+ },
146
+ "include_graph": {
147
+ "type": "boolean",
148
+ "description": "Include knowledge graph search (default: false)",
149
+ "default": False,
150
+ },
151
+ "rerank": {
152
+ "type": "boolean",
153
+ "description": (
154
+ "Rerank fused results with the local cross-encoder "
155
+ "(BAAI/bge-reranker-v2-m3, multilingual). Downloads "
156
+ "~1.8 GB on first use. Opt-in; off by default."
157
+ ),
158
+ "default": False,
159
+ },
160
+ },
161
+ "required": ["query"],
162
+ },
163
+ ),
164
+ # Indexing tools
165
+ Tool(
166
+ name="crawl_url",
167
+ description=(
168
+ "Crawl a URL and index its content for future searches. "
169
+ "Use this to add new documentation to the knowledge base."
170
+ ),
171
+ inputSchema={
172
+ "type": "object",
173
+ "properties": {
174
+ "url": {
175
+ "type": "string",
176
+ "description": "The URL to crawl and index",
177
+ },
178
+ "max_pages": {
179
+ "type": "integer",
180
+ "description": "Maximum pages to crawl (default: 1)",
181
+ "default": 1,
182
+ },
183
+ },
184
+ "required": ["url"],
185
+ },
186
+ ),
187
+ Tool(
188
+ name="index_git",
189
+ description=(
190
+ "Clone a git repository and index its documentation. "
191
+ "Useful for adding project documentation to the knowledge base."
192
+ ),
193
+ inputSchema={
194
+ "type": "object",
195
+ "properties": {
196
+ "url": {
197
+ "type": "string",
198
+ "description": "Git repository URL (HTTPS or SSH)",
199
+ },
200
+ "name": {
201
+ "type": "string",
202
+ "description": "Custom name for the repository (optional)",
203
+ },
204
+ "ref": {
205
+ "type": "string",
206
+ "description": "Branch, tag, or commit to checkout (optional)",
207
+ },
208
+ "include_code": {
209
+ "type": "boolean",
210
+ "description": "Include code files, not just docs (default: false)",
211
+ "default": False,
212
+ },
213
+ },
214
+ "required": ["url"],
215
+ },
216
+ ),
217
+ # Memory tools
218
+ Tool(
219
+ name="memory_store",
220
+ description=(
221
+ "Store information in agent memory for later recall. "
222
+ "Memory is scoped by repository and branch for context isolation."
223
+ ),
224
+ inputSchema={
225
+ "type": "object",
226
+ "properties": {
227
+ "key": {
228
+ "type": "string",
229
+ "description": "Key to store the value under",
230
+ },
231
+ "value": {
232
+ "type": "string",
233
+ "description": "Value to store (can be JSON for complex data)",
234
+ },
235
+ "repository": {
236
+ "type": "string",
237
+ "description": "Repository scope (default: 'default')",
238
+ "default": "default",
239
+ },
240
+ "branch": {
241
+ "type": "string",
242
+ "description": "Branch scope (default: 'main')",
243
+ "default": "main",
244
+ },
245
+ },
246
+ "required": ["key", "value"],
247
+ },
248
+ ),
249
+ Tool(
250
+ name="memory_recall",
251
+ description=(
252
+ "Retrieve information from agent memory. "
253
+ "Use this to recall previous decisions, context, or stored data."
254
+ ),
255
+ inputSchema={
256
+ "type": "object",
257
+ "properties": {
258
+ "key": {
259
+ "type": "string",
260
+ "description": "Key to retrieve (or pattern with % wildcard)",
261
+ },
262
+ "repository": {
263
+ "type": "string",
264
+ "description": "Repository scope (default: 'default')",
265
+ "default": "default",
266
+ },
267
+ "branch": {
268
+ "type": "string",
269
+ "description": "Branch scope (default: 'main')",
270
+ "default": "main",
271
+ },
272
+ "list_all": {
273
+ "type": "boolean",
274
+ "description": "List all keys instead of getting a specific value",
275
+ "default": False,
276
+ },
277
+ },
278
+ "required": ["key"],
279
+ },
280
+ ),
281
+ # Graph tools
282
+ Tool(
283
+ name="graph_query",
284
+ description=(
285
+ "Query the knowledge graph to find entities and relationships. "
286
+ "Returns facts extracted from indexed documentation."
287
+ ),
288
+ inputSchema={
289
+ "type": "object",
290
+ "properties": {
291
+ "query": {
292
+ "type": "string",
293
+ "description": "Search query for the knowledge graph",
294
+ },
295
+ "num_results": {
296
+ "type": "integer",
297
+ "description": "Maximum results to return (default: 10)",
298
+ "default": 10,
299
+ },
300
+ "group_id": {
301
+ "type": "string",
302
+ "description": "Group ID to filter by (optional)",
303
+ },
304
+ },
305
+ "required": ["query"],
306
+ },
307
+ ),
308
+ Tool(
309
+ name="graph_add",
310
+ description=(
311
+ "Add content to the knowledge graph. Extracts entities and relationships using LLM."
312
+ ),
313
+ inputSchema={
314
+ "type": "object",
315
+ "properties": {
316
+ "content": {
317
+ "type": "string",
318
+ "description": "Content to extract entities from",
319
+ },
320
+ "name": {
321
+ "type": "string",
322
+ "description": "Episode name (optional)",
323
+ },
324
+ "group_id": {
325
+ "type": "string",
326
+ "description": "Group ID for scoping (default: 'default')",
327
+ "default": "default",
328
+ },
329
+ },
330
+ "required": ["content"],
331
+ },
332
+ ),
333
+ # Utility tools
334
+ Tool(
335
+ name="get_stats",
336
+ description="Get statistics about the indexed documentation.",
337
+ inputSchema={
338
+ "type": "object",
339
+ "properties": {},
340
+ },
341
+ ),
342
+ ]
343
+ )
344
+
345
+
346
+ @server.call_tool()
347
+ async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
348
+ """Handle tool calls."""
349
+ handlers = {
350
+ "search_docs": _search_docs,
351
+ "hybrid_search": _hybrid_search,
352
+ "crawl_url": _crawl_url,
353
+ "index_git": _index_git,
354
+ "memory_store": _memory_store,
355
+ "memory_recall": _memory_recall,
356
+ "graph_query": _graph_query,
357
+ "graph_add": _graph_add,
358
+ "get_stats": _get_stats,
359
+ }
360
+
361
+ handler = handlers.get(name)
362
+ if handler:
363
+ return await handler(arguments)
364
+
365
+ return CallToolResult(
366
+ content=[TextContent(type="text", text=f"Unknown tool: {name}")],
367
+ isError=True,
368
+ )
369
+
370
+
371
+ async def _search_docs(arguments: dict[str, Any]) -> CallToolResult:
372
+ """Search indexed documentation."""
373
+ query = arguments.get("query", "")
374
+ top_k = arguments.get("top_k", 5)
375
+
376
+ if not query:
377
+ return CallToolResult(
378
+ content=[TextContent(type="text", text="Error: query is required")],
379
+ isError=True,
380
+ )
381
+
382
+ try:
383
+ vector_store = get_vector_store()
384
+ # The vector store is synchronous and can take a while on a cold index.
385
+ # This server is one process, so a blocking call here stalls every other
386
+ # request silently -- nothing in the logs would say why.
387
+ results = await asyncio.to_thread(vector_store.search, query, top_k=top_k)
388
+
389
+ if not results:
390
+ return CallToolResult(
391
+ content=[TextContent(type="text", text="No results found.")],
392
+ )
393
+
394
+ # Format results
395
+ output_parts = [f"Found {len(results)} results for: {query}\n"]
396
+
397
+ for i, result in enumerate(results, 1):
398
+ chunk = result.chunk
399
+ source = chunk.metadata.get("source_url") or chunk.metadata.get(
400
+ "source_path", "Unknown"
401
+ )
402
+ title = chunk.metadata.get("title", "Untitled")
403
+ section = chunk.metadata.get("section_title", "")
404
+
405
+ output_parts.append(f"\n--- Result {i} (score: {result.score:.4f}) ---")
406
+ output_parts.append(f"Title: {title}")
407
+ if section:
408
+ output_parts.append(f"Section: {section}")
409
+ output_parts.append(f"Source: {source}")
410
+ output_parts.append(f"\n{chunk.content}\n")
411
+
412
+ return CallToolResult(
413
+ content=[TextContent(type="text", text="\n".join(output_parts))],
414
+ )
415
+
416
+ except Exception as e:
417
+ return CallToolResult(
418
+ content=[TextContent(type="text", text=f"Error searching: {str(e)}")],
419
+ isError=True,
420
+ )
421
+
422
+
423
+ async def _hybrid_search(arguments: dict[str, Any]) -> CallToolResult:
424
+ """Perform hybrid search."""
425
+ query = arguments.get("query", "")
426
+ top_k = arguments.get("top_k", 10)
427
+ mode = arguments.get("mode", "hybrid")
428
+ include_graph = arguments.get("include_graph", False)
429
+ rerank = arguments.get("rerank", False)
430
+
431
+ if not query:
432
+ return CallToolResult(
433
+ content=[TextContent(type="text", text="Error: query is required")],
434
+ isError=True,
435
+ )
436
+
437
+ try:
438
+ from docugraph.retrieval.hybrid_search import (
439
+ FusionStrategy,
440
+ HybridRetriever,
441
+ HybridSearchConfig,
442
+ SearchMode,
443
+ )
444
+
445
+ mode_map = {
446
+ "vector": SearchMode.VECTOR,
447
+ "keyword": SearchMode.KEYWORD,
448
+ "hybrid": SearchMode.HYBRID,
449
+ "all": SearchMode.ALL,
450
+ }
451
+
452
+ config = HybridSearchConfig(include_graph=include_graph)
453
+ retriever = HybridRetriever(config=config)
454
+
455
+ results = await retriever.asearch(
456
+ query=query,
457
+ top_k=top_k,
458
+ mode=mode_map.get(mode, SearchMode.HYBRID),
459
+ fusion=FusionStrategy.RRF,
460
+ )
461
+
462
+ # Report the graph leg explicitly. Without this, a caller that asked
463
+ # for graph results and got none cannot tell that from a graph that
464
+ # simply had nothing to say.
465
+ status = retriever.graph_status
466
+ graph_note: str | None = None
467
+ if status.requested:
468
+ if status.available:
469
+ graph_note = "graph_available: true"
470
+ else:
471
+ graph_note = (
472
+ f"graph_available: false — {status.reason}. "
473
+ "These results are from vector/keyword search only."
474
+ )
475
+
476
+ if rerank and results:
477
+ # Opt-in cross-encoder rerank (downloads ~1.8 GB on first use).
478
+ from docugraph.retrieval.reranker import FastembedReranker
479
+
480
+ reranker = FastembedReranker()
481
+ reranked = reranker.rerank(
482
+ query=query,
483
+ results=[r.to_search_result() for r in results],
484
+ top_k=top_k,
485
+ )
486
+ # Reorder the HybridResult objects (which carry source attribution)
487
+ # and update each score to the blended rerank score so the reported
488
+ # score matches the reranked order.
489
+ by_id = {r.chunk.id: r for r in results}
490
+ reordered = []
491
+ for rr in reranked:
492
+ hit = by_id.get(rr.chunk.id)
493
+ if hit is not None:
494
+ hit.score = rr.final_score
495
+ reordered.append(hit)
496
+ results = reordered
497
+
498
+ if not results:
499
+ # Carry the graph note here too: "No results found." on its own
500
+ # would hide that the graph leg never ran.
501
+ empty_parts = ["No results found."]
502
+ if graph_note:
503
+ empty_parts.append(graph_note)
504
+ return CallToolResult(
505
+ content=[TextContent(type="text", text="\n\n".join(empty_parts))],
506
+ )
507
+
508
+ output_parts = [f"Found {len(results)} results for: {query}\n"]
509
+ if graph_note:
510
+ output_parts.append(f"{graph_note}\n")
511
+
512
+ for i, result in enumerate(results, 1):
513
+ chunk = result.chunk
514
+ source = chunk.metadata.get("source_url") or chunk.metadata.get(
515
+ "source_path", "Unknown"
516
+ )
517
+ title = chunk.metadata.get("title", "Untitled")
518
+
519
+ output_parts.append(f"\n--- Result {i} (score: {result.score:.4f}) ---")
520
+ output_parts.append(f"Title: {title}")
521
+ output_parts.append(f"Source: {source}")
522
+ output_parts.append(f"Found via: {', '.join(result.sources)}")
523
+ output_parts.append(f"\n{chunk.content}\n")
524
+
525
+ return CallToolResult(
526
+ content=[TextContent(type="text", text="\n".join(output_parts))],
527
+ )
528
+
529
+ except Exception as e:
530
+ return CallToolResult(
531
+ content=[TextContent(type="text", text=f"Error in hybrid search: {str(e)}")],
532
+ isError=True,
533
+ )
534
+
535
+
536
+ async def _crawl_url(arguments: dict[str, Any]) -> CallToolResult:
537
+ """Crawl a URL and index its content."""
538
+ url = arguments.get("url", "")
539
+ max_pages = arguments.get("max_pages", 1)
540
+
541
+ if not url:
542
+ return CallToolResult(
543
+ content=[TextContent(type="text", text="Error: url is required")],
544
+ isError=True,
545
+ )
546
+
547
+ try:
548
+ from docugraph.ingestion.chunker import Chunker
549
+ from docugraph.ingestion.crawler import DocCrawler
550
+
551
+ crawler = DocCrawler()
552
+ chunker = Chunker()
553
+ vector_store = get_vector_store()
554
+
555
+ # Crawl
556
+ if max_pages == 1:
557
+ documents = [await crawler.crawl_single(url)]
558
+ else:
559
+ documents = await crawler.crawl_site(url, max_pages=max_pages)
560
+
561
+ # Chunk and index
562
+ all_chunks = []
563
+ for doc in documents:
564
+ chunks = chunker.chunk_document(doc)
565
+ all_chunks.extend(chunks)
566
+
567
+ count = vector_store.add_chunks(all_chunks)
568
+
569
+ return CallToolResult(
570
+ content=[
571
+ TextContent(
572
+ type="text",
573
+ text=f"Successfully indexed {count} chunks from {len(documents)} pages.\nTotal chunks in store: {vector_store.count()}",
574
+ )
575
+ ],
576
+ )
577
+
578
+ except Exception as e:
579
+ return CallToolResult(
580
+ content=[TextContent(type="text", text=f"Error crawling: {str(e)}")],
581
+ isError=True,
582
+ )
583
+
584
+
585
+ async def _index_git(arguments: dict[str, Any]) -> CallToolResult:
586
+ """Clone and index a git repository."""
587
+ url = arguments.get("url", "")
588
+ name = arguments.get("name")
589
+ ref = arguments.get("ref")
590
+ include_code = arguments.get("include_code", False)
591
+
592
+ if not url:
593
+ return CallToolResult(
594
+ content=[TextContent(type="text", text="Error: url is required")],
595
+ isError=True,
596
+ )
597
+
598
+ try:
599
+ from docugraph.ingestion.chunker import Chunker
600
+ from docugraph.ingestion.git_indexer import GitIndexer, GitIndexerConfig
601
+
602
+ config = GitIndexerConfig(
603
+ clone_depth=1, # Shallow clone for speed
604
+ ref=ref,
605
+ include_code=include_code,
606
+ )
607
+
608
+ indexer = GitIndexer(config=config)
609
+ chunker = Chunker()
610
+ vector_store = get_vector_store()
611
+
612
+ # Clone and index
613
+ repo_info, documents = indexer.clone_and_index(url, name=name, ref=ref)
614
+
615
+ # Chunk and index
616
+ all_chunks = []
617
+ for doc in documents:
618
+ chunks = chunker.chunk_document(doc)
619
+ all_chunks.extend(chunks)
620
+
621
+ count = vector_store.add_chunks(all_chunks)
622
+
623
+ return CallToolResult(
624
+ content=[
625
+ TextContent(
626
+ type="text",
627
+ text=(
628
+ f"Successfully indexed {count} chunks from {len(documents)} files.\n"
629
+ f"Repository: {repo_info.name} @ {repo_info.branch or 'HEAD'}\n"
630
+ f"Commit: {(repo_info.commit_hash or 'N/A')[:12]}\n"
631
+ f"Total chunks in store: {vector_store.count()}"
632
+ ),
633
+ )
634
+ ],
635
+ )
636
+
637
+ except Exception as e:
638
+ return CallToolResult(
639
+ content=[TextContent(type="text", text=f"Error indexing git repo: {str(e)}")],
640
+ isError=True,
641
+ )
642
+
643
+
644
+ async def _memory_store(arguments: dict[str, Any]) -> CallToolResult:
645
+ """Store a value in agent memory."""
646
+ key = arguments.get("key", "")
647
+ value = arguments.get("value", "")
648
+ repository = arguments.get("repository", "default")
649
+ branch = arguments.get("branch", "main")
650
+
651
+ if not key:
652
+ return CallToolResult(
653
+ content=[TextContent(type="text", text="Error: key is required")],
654
+ isError=True,
655
+ )
656
+
657
+ try:
658
+ from docugraph.storage.memory_store import MemoryStore
659
+
660
+ store = MemoryStore()
661
+
662
+ # Try to parse as JSON
663
+ try:
664
+ parsed_value = json.loads(value)
665
+ except (json.JSONDecodeError, TypeError):
666
+ parsed_value = value
667
+
668
+ store.set(key, parsed_value, repository=repository, branch=branch)
669
+
670
+ return CallToolResult(
671
+ content=[
672
+ TextContent(
673
+ type="text",
674
+ text=f"Stored: {key} = {value}\nScope: {repository}/{branch}",
675
+ )
676
+ ],
677
+ )
678
+
679
+ except Exception as e:
680
+ return CallToolResult(
681
+ content=[TextContent(type="text", text=f"Error storing memory: {str(e)}")],
682
+ isError=True,
683
+ )
684
+
685
+
686
+ async def _memory_recall(arguments: dict[str, Any]) -> CallToolResult:
687
+ """Retrieve a value from agent memory."""
688
+ key = arguments.get("key", "")
689
+ repository = arguments.get("repository", "default")
690
+ branch = arguments.get("branch", "main")
691
+ list_all = arguments.get("list_all", False)
692
+
693
+ if not key and not list_all:
694
+ return CallToolResult(
695
+ content=[TextContent(type="text", text="Error: key is required")],
696
+ isError=True,
697
+ )
698
+
699
+ try:
700
+ from docugraph.storage.memory_store import MemoryStore
701
+
702
+ store = MemoryStore()
703
+
704
+ if list_all or key == "*":
705
+ keys = store.list_keys(repository=repository, branch=branch)
706
+ if not keys:
707
+ return CallToolResult(
708
+ content=[
709
+ TextContent(type="text", text=f"No memories found in {repository}/{branch}")
710
+ ],
711
+ )
712
+ return CallToolResult(
713
+ content=[
714
+ TextContent(
715
+ type="text",
716
+ text=f"Memory keys in {repository}/{branch}:\n"
717
+ + "\n".join(f" - {k}" for k in keys),
718
+ )
719
+ ],
720
+ )
721
+
722
+ value = store.get(key, repository=repository, branch=branch)
723
+
724
+ if value is None:
725
+ return CallToolResult(
726
+ content=[TextContent(type="text", text=f"No value found for key: {key}")],
727
+ )
728
+
729
+ # Format value
730
+ value_str = json.dumps(value, indent=2) if isinstance(value, (dict, list)) else str(value)
731
+
732
+ return CallToolResult(
733
+ content=[
734
+ TextContent(
735
+ type="text",
736
+ text=f"{key} = {value_str}\nScope: {repository}/{branch}",
737
+ )
738
+ ],
739
+ )
740
+
741
+ except Exception as e:
742
+ return CallToolResult(
743
+ content=[TextContent(type="text", text=f"Error recalling memory: {str(e)}")],
744
+ isError=True,
745
+ )
746
+
747
+
748
+ def _llm_error_result(exc: RuntimeError, action: str) -> CallToolResult:
749
+ """Turn an LLM/runtime failure into an actionable MCP error result.
750
+
751
+ An LLMUnavailableError already carries a full explanation of what is
752
+ missing and how to fix it, so it is passed through verbatim. Any other
753
+ RuntimeError gets the generic pointer, since it could be something else.
754
+ """
755
+ if isinstance(exc, LLMUnavailableError):
756
+ text = str(exc)
757
+ else:
758
+ text = (
759
+ f"{action} needs an LLM and could not use one. Error: {exc}\n\n"
760
+ "To use the knowledge graph:\n"
761
+ " Local: install Ollama (https://ollama.ai), run 'ollama serve', "
762
+ "and 'ollama pull <model>'\n"
763
+ " Cloud: set OPENAI_API_KEY or ANTHROPIC_API_KEY\n"
764
+ "Vector and keyword search need no LLM and are unaffected."
765
+ )
766
+ return CallToolResult(
767
+ content=[TextContent(type="text", text=text)],
768
+ isError=True,
769
+ )
770
+
771
+
772
+ async def _graph_query(arguments: dict[str, Any]) -> CallToolResult:
773
+ """Query the knowledge graph."""
774
+ query = arguments.get("query", "")
775
+ num_results = arguments.get("num_results", 10)
776
+ group_id = arguments.get("group_id")
777
+
778
+ if not query:
779
+ return CallToolResult(
780
+ content=[TextContent(type="text", text="Error: query is required")],
781
+ isError=True,
782
+ )
783
+
784
+ try:
785
+ from docugraph.storage.graph_store import GraphStore
786
+
787
+ store = GraphStore()
788
+ group_ids = [group_id] if group_id else None
789
+
790
+ try:
791
+ results = await store.search(query, group_ids=group_ids, num_results=num_results)
792
+ finally:
793
+ await store.close()
794
+
795
+ if not results:
796
+ return CallToolResult(
797
+ content=[TextContent(type="text", text="No results found in knowledge graph.")],
798
+ )
799
+
800
+ output_parts = [f"Found {len(results)} facts for: {query}\n"]
801
+
802
+ for i, r in enumerate(results, 1):
803
+ output_parts.append(f"{i}. {r['fact']}")
804
+ if r.get("valid_at"):
805
+ output_parts.append(f" Valid: {r['valid_at']}")
806
+ output_parts.append("")
807
+
808
+ return CallToolResult(
809
+ content=[TextContent(type="text", text="\n".join(output_parts))],
810
+ )
811
+
812
+ except RuntimeError as e:
813
+ return _llm_error_result(e, "Graph search")
814
+ except Exception as e:
815
+ return CallToolResult(
816
+ content=[TextContent(type="text", text=f"Error querying graph: {str(e)}")],
817
+ isError=True,
818
+ )
819
+
820
+
821
+ async def _graph_add(arguments: dict[str, Any]) -> CallToolResult:
822
+ """Add content to the knowledge graph."""
823
+ content = arguments.get("content", "")
824
+ name = arguments.get("name")
825
+ group_id = arguments.get("group_id", "default")
826
+
827
+ if not content:
828
+ return CallToolResult(
829
+ content=[TextContent(type="text", text="Error: content is required")],
830
+ isError=True,
831
+ )
832
+
833
+ try:
834
+ from docugraph.storage.graph_store import GraphStore
835
+
836
+ store = GraphStore()
837
+
838
+ try:
839
+ episode_name = name or f"episode_{hash(content) % 10000}"
840
+ result = await store.add_episode(
841
+ name=episode_name,
842
+ content=content,
843
+ source_type="text",
844
+ group_id=group_id,
845
+ )
846
+ finally:
847
+ await store.close()
848
+
849
+ output_parts = [
850
+ f"Added to knowledge graph: {result['episode_uuid']}",
851
+ f"Extracted {result['entities_count']} entities",
852
+ f"Extracted {result['relationships_count']} relationships",
853
+ ]
854
+
855
+ if result["entities"]:
856
+ output_parts.append("\nEntities:")
857
+ for e in result["entities"][:5]:
858
+ output_parts.append(f" - {e['name']}")
859
+ if len(result["entities"]) > 5:
860
+ output_parts.append(f" ... and {len(result['entities']) - 5} more")
861
+
862
+ return CallToolResult(
863
+ content=[TextContent(type="text", text="\n".join(output_parts))],
864
+ )
865
+
866
+ except RuntimeError as e:
867
+ return _llm_error_result(e, "Graph add")
868
+ except Exception as e:
869
+ return CallToolResult(
870
+ content=[TextContent(type="text", text=f"Error adding to graph: {str(e)}")],
871
+ isError=True,
872
+ )
873
+
874
+
875
+ async def _get_stats(_arguments: dict[str, Any]) -> CallToolResult:
876
+ """Get indexing statistics."""
877
+ try:
878
+ from docugraph.core.config import get_config
879
+
880
+ config = get_config()
881
+ vector_store = get_vector_store()
882
+
883
+ stats = {
884
+ "total_chunks": vector_store.count(),
885
+ "data_directory": str(config.storage.data_dir),
886
+ "embedding_model": vector_store.embedding_model,
887
+ "embedding_provider": config.embeddings.provider.value,
888
+ }
889
+
890
+ return CallToolResult(
891
+ content=[TextContent(type="text", text=json.dumps(stats, indent=2))],
892
+ )
893
+
894
+ except Exception as e:
895
+ return CallToolResult(
896
+ content=[TextContent(type="text", text=f"Error getting stats: {str(e)}")],
897
+ isError=True,
898
+ )
899
+
900
+
901
+ def run_server() -> None:
902
+ """Run the MCP server."""
903
+ asyncio.run(_run_server())
904
+
905
+
906
+ async def _run_server() -> None:
907
+ """Run the MCP server with stdio transport."""
908
+ async with stdio_server() as (read_stream, write_stream):
909
+ await server.run(read_stream, write_stream, server.create_initialization_options())
910
+
911
+
912
+ def main() -> None:
913
+ """Entry point for the MCP server."""
914
+ run_server()
915
+
916
+
917
+ if __name__ == "__main__":
918
+ main()