@@ -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.
8082func (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
151155func (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