Skip to content

Commit f090e0e

Browse files
feat(mcp): fall back to DB for docs tools
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 72d3d88 commit f090e0e

2 files changed

Lines changed: 854 additions & 34 deletions

File tree

internal/mcp/handler_docs.go

Lines changed: 153 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111

1212
"github.com/mark3labs/mcp-go/mcp"
1313

14+
"github.com/tae2089/code-context-graph/internal/ctxns"
1415
"github.com/tae2089/code-context-graph/internal/paging"
1516
"github.com/tae2089/code-context-graph/internal/ragindex"
17+
"github.com/tae2089/code-context-graph/internal/retrieval"
1618
)
1719

1820
// @intent resolve the base directory that stores generated doc-index artifacts for MCP documentation tools.
@@ -78,6 +80,9 @@ func safePathUnderRoot(root, relPath, field string, createRoot bool, allowMissin
7880

7981
// @intent derive the safe doc-index.json path for either the shared docs root or one namespace-specific subtree.
8082
func (h *handlers) resolvedRagIndexPath(namespace string) (string, error) {
83+
if ctxns.Normalize(namespace) == ctxns.DefaultNamespace {
84+
return safePathUnderRoot(h.ragIndexRoot(), "doc-index.json", "file_path", false, true)
85+
}
8186
if namespace != "" {
8287
if err := validateNamespacePath(namespace, ""); err != nil {
8388
return "", err
@@ -143,42 +148,67 @@ func (h *handlers) buildRagIndex(ctx context.Context, request mcp.CallToolReques
143148
}
144149

145150
// getRagTree returns the documentation tree or a pruned node subtree.
146-
// @intent Exposes the documentation RAG index in a tree format to enable exploratory lookups.
151+
// @intent Exposes the documentation RAG index in a tree format to enable exploratory lookups, falling back to DB synthesis when doc-index.json is absent.
147152
// @param request node_id is the starting point for a subtree, community_id is a deprecated alias, and depth limits the return depth.
148-
// @requires doc-index.json must have been generated.
149-
// @ensures Returns the TreeNode JSON for the requested range on success.
153+
// @ensures Returns the TreeNode JSON for the requested range on success and prefers doc-index.json when present.
150154
// @see mcp.handlers.buildRagIndex
151155
func (h *handlers) getRagTree(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
152156
nodeID := request.GetString("node_id", "")
153157
if nodeID == "" {
154158
nodeID = request.GetString("community_id", "")
155159
}
156160
depth := int(request.GetFloat("depth", 0))
157-
namespace := requestNamespace(request)
158-
indexPath, err := h.resolvedRagIndexPath(namespace)
161+
namespace := resolveNamespace(ctx, requestNamespace(request))
162+
ctx = ctxns.WithNamespace(ctx, namespace)
163+
indexNamespace := namespace
164+
if namespace == ctxns.DefaultNamespace {
165+
indexNamespace = ""
166+
}
167+
indexPath, err := h.resolvedRagIndexPath(indexNamespace)
159168
if err != nil {
160169
return mcp.NewToolResultError(err.Error()), nil
161170
}
162171

163-
var indexMtime int64
164-
if stat, statErr := os.Stat(indexPath); statErr == nil {
165-
indexMtime = stat.ModTime().UnixNano()
172+
stat, statErr := os.Stat(indexPath)
173+
if statErr != nil {
174+
if !os.IsNotExist(statErr) {
175+
return mcp.NewToolResultError(fmt.Sprintf("stat doc-index: %v", statErr)), nil
176+
}
177+
return finalizeToolResult(h.cachedExecute(ctx, "get_rag_tree:db:", map[string]any{"node_id": nodeID, "depth": depth, "namespace": namespace}, func() (string, error) {
178+
if h.deps.DB == nil {
179+
return "", newToolResultErr("DB not configured")
180+
}
181+
builder := &ragindex.Builder{
182+
DB: h.deps.DB,
183+
OutDir: "docs",
184+
ProjectDesc: h.deps.RagProjectDesc,
185+
}
186+
root, _, _, err := builder.BuildTree(ctx)
187+
if err != nil {
188+
return "", newToolResultErr(fmt.Sprintf("build rag tree from DB: %v", err))
189+
}
190+
node, err := selectRagTreeNode(root, nodeID)
191+
if err != nil {
192+
return "", err
193+
}
194+
node = ragindex.PruneTree(node, depth)
195+
196+
// TreeNode fields are JSON-safe DTO fields; json.Marshal cannot fail for this payload.
197+
encoded, _ := json.Marshal(node)
198+
return string(encoded), nil
199+
}))
166200
}
167201

202+
indexMtime := stat.ModTime().UnixNano()
168203
return finalizeToolResult(h.cachedExecute(ctx, "get_rag_tree:", map[string]any{"node_id": nodeID, "depth": depth, "namespace": namespace, "mtime": indexMtime}, func() (string, error) {
169204
idx, err := ragindex.LoadIndex(indexPath)
170205
if err != nil {
171206
return "", newToolResultErr(fmt.Sprintf("load doc-index: %v", err))
172207
}
173208

174-
var node *ragindex.TreeNode
175-
if nodeID == "" {
176-
node = idx.Root
177-
} else {
178-
node = ragindex.FindNode(idx.Root, nodeID)
179-
if node == nil {
180-
return "", newToolResultErr(fmt.Sprintf("node_id %q not found", nodeID))
181-
}
209+
node, err := selectRagTreeNode(idx.Root, nodeID)
210+
if err != nil {
211+
return "", err
182212
}
183213

184214
node = ragindex.PruneTree(node, depth)
@@ -189,6 +219,18 @@ func (h *handlers) getRagTree(ctx context.Context, request mcp.CallToolRequest)
189219
}))
190220
}
191221

222+
// @intent select the requested RAG tree node with shared not-found semantics for JSON indexes and DB fallback trees.
223+
func selectRagTreeNode(root *ragindex.TreeNode, nodeID string) (*ragindex.TreeNode, error) {
224+
if nodeID == "" {
225+
return root, nil
226+
}
227+
node := ragindex.FindNode(root, nodeID)
228+
if node == nil {
229+
return nil, newToolResultErr(fmt.Sprintf("node_id %q not found", nodeID))
230+
}
231+
return node, nil
232+
}
233+
192234
// getDocContent reads a generated documentation file by relative path.
193235
// @intent Returns the content of a documentation file directly so agents can read detailed descriptions.
194236
// @param request file_path is the relative documentation path based on the working directory.
@@ -262,15 +304,27 @@ func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest)
262304
return finalizeToolResult("", newToolResultErr(err.Error()))
263305
}
264306
namespace := requestNamespace(request)
307+
ctx = h.applyNamespace(ctx, request)
265308
indexPath, err := h.resolvedRagIndexPath(namespace)
266309
if err != nil {
267310
return mcp.NewToolResultError(err.Error()), nil
268311
}
269312

270-
var indexMtime int64
271-
if stat, statErr := os.Stat(indexPath); statErr == nil {
272-
indexMtime = stat.ModTime().UnixNano()
313+
stat, statErr := os.Stat(indexPath)
314+
if statErr != nil && !os.IsNotExist(statErr) {
315+
return mcp.NewToolResultError(fmt.Sprintf("stat doc-index: %v", statErr)), nil
273316
}
317+
if statErr != nil && os.IsNotExist(statErr) {
318+
return finalizeToolResult(h.cachedExecute(ctx, "search_docs:db:", map[string]any{"query": query, "limit": pageReq.Limit, "namespace": namespace}, func() (string, error) {
319+
results, err := h.searchDocsFromDB(ctx, namespace, query, pageReq.Limit)
320+
if err != nil {
321+
return "", newToolResultErr(err.Error())
322+
}
323+
b, _ := json.Marshal(results)
324+
return string(b), nil
325+
}))
326+
}
327+
indexMtime := stat.ModTime().UnixNano()
274328

275329
return finalizeToolResult(h.cachedExecute(ctx, "search_docs:", map[string]any{"query": query, "limit": pageReq.Limit, "namespace": namespace, "mtime": indexMtime}, func() (string, error) {
276330
idx, err := ragindex.LoadIndex(indexPath)
@@ -289,18 +343,63 @@ func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest)
289343
}))
290344
}
291345

292-
// retrieveDocsResponse is the JSON envelope returned by retrieve_docs.
293-
// @intent keep retrieve_docs responses extensible while preserving a stable top-level results array.
294-
type retrieveDocsResponse struct {
295-
Results []retrieveDocsResult `json:"results"`
346+
// @intent search persisted graph nodes when search_docs has no generated doc-index.json to load.
347+
// @requires ctx must carry the selected namespace for SearchBackend.Query.
348+
// @ensures returns SearchResult-compatible JSON items without requiring generated index files.
349+
// @sideEffect queries the search backend and may scan graph annotations as a fallback.
350+
func (h *handlers) searchDocsFromDB(ctx context.Context, namespace, query string, limit int) ([]ragindex.SearchResult, error) {
351+
if h.deps.DB == nil {
352+
return nil, fmt.Errorf("DB not configured")
353+
}
354+
if limit <= 0 {
355+
return []ragindex.SearchResult{}, nil
356+
}
357+
service := retrieval.Service{DB: h.deps.DB, SearchBackend: h.deps.SearchBackend}
358+
retrieved, err := service.FromDB(ctx, namespace, query, limit, 0, nil)
359+
if err != nil {
360+
return nil, err
361+
}
362+
results := make([]ragindex.SearchResult, 0, min(limit, len(retrieved.Results)))
363+
for _, result := range retrieved.Results {
364+
if len(results) >= limit {
365+
break
366+
}
367+
results = append(results, ragindex.SearchResult{
368+
ID: result.ID,
369+
Label: result.Label,
370+
Kind: result.Kind,
371+
Summary: result.Summary,
372+
DocPath: result.DocPath,
373+
Path: result.Path,
374+
})
375+
}
376+
if results == nil {
377+
results = []ragindex.SearchResult{}
378+
}
379+
return results, nil
296380
}
297381

298-
// retrieveDocsResult combines tree retrieval metadata with optional Markdown content.
299-
// @intent return both why a document matched and the bounded document body needed for agent reasoning.
300-
type retrieveDocsResult struct {
301-
ragindex.RetrieveResult
302-
Content string `json:"content,omitempty"`
303-
ContentTruncated bool `json:"content_truncated,omitempty"`
382+
// @intent keep MCP retrieve_docs response decoding compatible while sharing the canonical retrieval DTO.
383+
type retrieveDocsResponse = retrieval.Response
384+
385+
// @intent keep MCP retrieve_docs result decoding compatible while sharing the canonical retrieval DTO.
386+
type retrieveDocsResult = retrieval.Result
387+
388+
var _ = (*handlers).retrieveDocsFromDB
389+
390+
// @intent build a retrieve_docs response from persisted graph nodes and annotation tags when doc-index.json is unavailable.
391+
// @requires SearchBackend and DB must be configured, and ctx must carry the desired namespace.
392+
// @ensures returned results are grouped one-per-file in first-seen FTS order and keep retrieve_docs' stable JSON shape.
393+
// @sideEffect queries the search backend and may read generated Markdown files for bounded content.
394+
func (h *handlers) retrieveDocsFromDB(ctx context.Context, namespace, query string, limit, contentLimit int) (retrieveDocsResponse, error) {
395+
service := retrieval.Service{DB: h.deps.DB, SearchBackend: h.deps.SearchBackend}
396+
retrieved, err := service.FromDB(ctx, namespace, query, limit, contentLimit, func(_ context.Context, namespace, docPath string, limit int) (string, bool, error) {
397+
return h.readIndexedDocContent(namespace, docPath, limit)
398+
})
399+
if err != nil {
400+
return retrieveDocsResponse{Results: []retrieveDocsResult{}}, err
401+
}
402+
return retrieved, nil
304403
}
305404

306405
// retrieveDocs retrieves generated Markdown docs using tree-aware multi-term matching.
@@ -334,16 +433,38 @@ func (h *handlers) retrieveDocs(ctx context.Context, request mcp.CallToolRequest
334433
explain := request.GetBool("explain", false)
335434

336435
namespace := requestNamespace(request)
436+
ctx = h.applyNamespace(ctx, request)
337437
indexPath, err := h.resolvedRagIndexPath(namespace)
338438
if err != nil {
339439
return mcp.NewToolResultError(err.Error()), nil
340440
}
341441

342-
var indexMtime int64
343-
if stat, statErr := os.Stat(indexPath); statErr == nil {
344-
indexMtime = stat.ModTime().UnixNano()
442+
stat, statErr := os.Stat(indexPath)
443+
if statErr != nil && !os.IsNotExist(statErr) {
444+
return mcp.NewToolResultError(fmt.Sprintf("stat doc-index: %v", statErr)), nil
345445
}
346446

447+
if statErr != nil && os.IsNotExist(statErr) {
448+
cacheKey := map[string]any{
449+
"query": query,
450+
"limit": pageReq.Limit,
451+
"content_limit": contentLimit,
452+
"namespace": namespace,
453+
"explain": explain,
454+
}
455+
return finalizeToolResult(h.cachedExecute(ctx, "retrieve_docs:db:", cacheKey, func() (string, error) {
456+
response, err := h.retrieveDocsFromDB(ctx, namespace, query, pageReq.Limit, contentLimit)
457+
if err != nil {
458+
return "", newToolResultErr(err.Error())
459+
}
460+
461+
b, _ := json.Marshal(response)
462+
return string(b), nil
463+
}))
464+
}
465+
466+
indexMtime := stat.ModTime().UnixNano()
467+
347468
cacheKey := map[string]any{
348469
"query": query,
349470
"limit": pageReq.Limit,

0 commit comments

Comments
 (0)