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.
- docugraph/__init__.py +24 -0
- docugraph/_version.py +28 -0
- docugraph/agents/__init__.py +27 -0
- docugraph/agents/coding_agent.py +532 -0
- docugraph/agents/tools/__init__.py +62 -0
- docugraph/agents/tools/graph_query.py +203 -0
- docugraph/agents/tools/memory_ops.py +305 -0
- docugraph/agents/tools/search_code.py +305 -0
- docugraph/agents/tools/search_docs.py +148 -0
- docugraph/core/__init__.py +34 -0
- docugraph/core/config.py +183 -0
- docugraph/core/embeddings.py +394 -0
- docugraph/core/llm.py +892 -0
- docugraph/core/models.py +145 -0
- docugraph/ingestion/__init__.py +64 -0
- docugraph/ingestion/api_docs.py +646 -0
- docugraph/ingestion/chunker.py +382 -0
- docugraph/ingestion/crawler.py +305 -0
- docugraph/ingestion/git_indexer.py +556 -0
- docugraph/ingestion/local_files.py +516 -0
- docugraph/interfaces/__init__.py +1 -0
- docugraph/interfaces/cli.py +983 -0
- docugraph/interfaces/mcp_server.py +918 -0
- docugraph/retrieval/__init__.py +54 -0
- docugraph/retrieval/context_builder.py +522 -0
- docugraph/retrieval/hybrid_search.py +534 -0
- docugraph/retrieval/reranker.py +532 -0
- docugraph/storage/__init__.py +14 -0
- docugraph/storage/graph_store.py +602 -0
- docugraph/storage/memory_store.py +529 -0
- docugraph/storage/vector_store.py +367 -0
- docugraph-0.1.0.dist-info/METADATA +354 -0
- docugraph-0.1.0.dist-info/RECORD +37 -0
- docugraph-0.1.0.dist-info/WHEEL +4 -0
- docugraph-0.1.0.dist-info/entry_points.txt +3 -0
- docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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()
|