Skip to content

Commit 0403dcf

Browse files
tae2089claude
andcommitted
feat: add search_docs MCP tool for keyword search in RAG tree
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d196752 commit 0403dcf

6 files changed

Lines changed: 248 additions & 2 deletions

File tree

internal/mcp/handlers.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,6 +1463,41 @@ func (h *handlers) getDocContent(ctx context.Context, request mcp.CallToolReques
14631463
return mcp.NewToolResultText(result), nil
14641464
}
14651465

1466+
func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1467+
query, err := request.RequireString("query")
1468+
if err != nil {
1469+
return mcp.NewToolResultError(fmt.Sprintf("missing parameter: %v", err)), nil
1470+
}
1471+
limit := request.GetInt("limit", 10)
1472+
if limit <= 0 {
1473+
limit = 10
1474+
}
1475+
1476+
key := "search_docs:" + mustJSON(map[string]any{"query": query, "limit": limit})
1477+
if h.cache != nil {
1478+
if cached, ok := h.cache.Get(key); ok {
1479+
return mcp.NewToolResultText(cached), nil
1480+
}
1481+
}
1482+
1483+
idx, err := ragindex.LoadIndex(h.ragIndexPath())
1484+
if err != nil {
1485+
return mcp.NewToolResultError(fmt.Sprintf("load doc-index: %v", err)), nil
1486+
}
1487+
1488+
results := ragindex.Search(idx.Root, query, limit)
1489+
1490+
b, err := json.Marshal(results)
1491+
if err != nil {
1492+
return mcp.NewToolResultError(fmt.Sprintf("marshal results: %v", err)), nil
1493+
}
1494+
resultStr := string(b)
1495+
if h.cache != nil {
1496+
h.cache.Set(key, resultStr)
1497+
}
1498+
return mcp.NewToolResultText(resultStr), nil
1499+
}
1500+
14661501
func getBool(request mcp.CallToolRequest, name string, defaultVal bool) bool {
14671502
if args, ok := request.Params.Arguments.(map[string]any); ok {
14681503
if v, ok := args[name]; ok {

internal/mcp/handlers_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2177,3 +2177,67 @@ func TestGetRagTree_DepthLimitsChildren(t *testing.T) {
21772177
t.Fatalf("expected 0 file children at depth=1, got %d", len(communityNode.Children))
21782178
}
21792179
}
2180+
2181+
// TestSearchDocs_ReturnsMatches: query 매칭 결과를 JSON으로 반환한다.
2182+
func TestSearchDocs_ReturnsMatches(t *testing.T) {
2183+
deps := setupTestDeps(t)
2184+
tmpDir := t.TempDir()
2185+
deps.RagIndexDir = tmpDir
2186+
2187+
comm := model.Community{Key: "auth", Label: "Auth Service", Description: "인증 레이어"}
2188+
if err := deps.DB.Create(&comm).Error; err != nil {
2189+
t.Fatalf("create community: %v", err)
2190+
}
2191+
node := model.Node{QualifiedName: "auth/handler.go/Login", Kind: model.NodeKindFunction,
2192+
Name: "Login", FilePath: "auth/handler.go", StartLine: 1, EndLine: 20, Language: "go"}
2193+
if err := deps.DB.Create(&node).Error; err != nil {
2194+
t.Fatalf("create node: %v", err)
2195+
}
2196+
if err := deps.DB.Create(&model.CommunityMembership{CommunityID: comm.ID, NodeID: node.ID}).Error; err != nil {
2197+
t.Fatalf("create membership: %v", err)
2198+
}
2199+
ann := model.Annotation{NodeID: node.ID}
2200+
if err := deps.DB.Create(&ann).Error; err != nil {
2201+
t.Fatalf("create annotation: %v", err)
2202+
}
2203+
if err := deps.DB.Create(&model.DocTag{AnnotationID: ann.ID, Kind: model.TagIndex, Value: "Auth 서비스 핸들러", Ordinal: 0}).Error; err != nil {
2204+
t.Fatalf("create doc tag: %v", err)
2205+
}
2206+
2207+
b := &ragindex.Builder{DB: deps.DB, IndexDir: tmpDir, OutDir: filepath.Join(tmpDir, "docs")}
2208+
if _, _, err := b.Build(); err != nil {
2209+
t.Fatalf("Build: %v", err)
2210+
}
2211+
2212+
result := callTool(t, deps, "search_docs", map[string]any{"query": "auth", "limit": float64(10)})
2213+
if result.IsError {
2214+
t.Fatalf("search_docs error: %v", getTextContent(result))
2215+
}
2216+
2217+
var results []ragindex.SearchResult
2218+
if err := json.Unmarshal([]byte(getTextContent(result)), &results); err != nil {
2219+
t.Fatalf("unmarshal: %v", err)
2220+
}
2221+
if len(results) == 0 {
2222+
t.Fatal("expected at least 1 search result")
2223+
}
2224+
}
2225+
2226+
// TestSearchDocs_MissingQuery: query 파라미터 없으면 에러 반환.
2227+
func TestSearchDocs_MissingQuery(t *testing.T) {
2228+
deps := setupTestDeps(t)
2229+
result := callTool(t, deps, "search_docs", map[string]any{})
2230+
if !result.IsError {
2231+
t.Fatal("expected error for missing query")
2232+
}
2233+
}
2234+
2235+
// TestSearchDocs_NoIndex: doc-index.json 없으면 에러 반환.
2236+
func TestSearchDocs_NoIndex(t *testing.T) {
2237+
deps := setupTestDeps(t)
2238+
deps.RagIndexDir = t.TempDir()
2239+
result := callTool(t, deps, "search_docs", map[string]any{"query": "something"})
2240+
if !result.IsError {
2241+
t.Fatal("expected error when index file missing")
2242+
}
2243+
}

internal/mcp/server.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,14 @@ func NewServer(deps *Deps) *server.MCPServer {
277277
),
278278
Handler: h.getDocContent,
279279
},
280+
server.ServerTool{
281+
Tool: mcp.NewTool("search_docs",
282+
mcp.WithDescription("Search the RAG document tree by keyword. Matches against node labels and summaries. Returns breadcrumb paths to matching nodes."),
283+
mcp.WithString("query", mcp.Description("Search keyword (case-insensitive)"), mcp.Required()),
284+
mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 10)")),
285+
),
286+
Handler: h.searchDocs,
287+
},
280288
)
281289

282290
log.Info("MCP server created", "name", "code-context-graph", "version", "1.0.0", "prompts", 5)

internal/mcp/server_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func TestMCPServer_ListTools(t *testing.T) {
3333
"build_rag_index",
3434
"get_rag_tree",
3535
"get_doc_content",
36+
"search_docs",
3637
}
3738

3839
if len(tools) != len(expected) {
@@ -62,8 +63,8 @@ func TestMCPServer_ListTools_18(t *testing.T) {
6263
srv := NewServer(deps)
6364
tools := srv.ListTools()
6465

65-
if len(tools) != 21 {
66-
t.Fatalf("expected 21 tools, got %d", len(tools))
66+
if len(tools) != 22 {
67+
t.Fatalf("expected 22 tools, got %d", len(tools))
6768
}
6869
}
6970

internal/ragindex/builder.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,52 @@ func LoadIndex(path string) (*Index, error) {
351351
return &idx, nil
352352
}
353353

354+
// SearchResult는 Search 함수가 반환하는 단일 매칭 결과이다.
355+
type SearchResult struct {
356+
ID string `json:"id"`
357+
Label string `json:"label"`
358+
Summary string `json:"summary"`
359+
DocPath string `json:"doc_path,omitempty"`
360+
Path []string `json:"path"` // root부터 해당 노드까지의 Label 경로
361+
}
362+
363+
// Search는 root 트리를 DFS로 순회하며 query를 Label과 Summary에서
364+
// case-insensitive 검색하여 최대 maxResults개의 결과를 반환한다.
365+
// root 노드 자체는 결과에 포함하지 않는다.
366+
func Search(root *TreeNode, query string, maxResults int) []SearchResult {
367+
if root == nil || query == "" {
368+
return nil
369+
}
370+
q := strings.ToLower(query)
371+
results := make([]SearchResult, 0)
372+
searchNode(root, q, []string{root.Label}, &results, maxResults)
373+
return results
374+
}
375+
376+
func searchNode(n *TreeNode, query string, path []string, results *[]SearchResult, maxResults int) {
377+
for _, child := range n.Children {
378+
if len(*results) >= maxResults {
379+
return
380+
}
381+
// 슬라이스 공유 방지를 위해 새 슬라이스로 복사
382+
childPath := make([]string, len(path)+1)
383+
copy(childPath, path)
384+
childPath[len(path)] = child.Label
385+
386+
if strings.Contains(strings.ToLower(child.Label), query) ||
387+
strings.Contains(strings.ToLower(child.Summary), query) {
388+
*results = append(*results, SearchResult{
389+
ID: child.ID,
390+
Label: child.Label,
391+
Summary: child.Summary,
392+
DocPath: child.DocPath,
393+
Path: childPath,
394+
})
395+
}
396+
searchNode(child, query, childPath, results, maxResults)
397+
}
398+
}
399+
354400
// FindNode는 root 트리에서 id와 일치하는 TreeNode를 재귀적으로 찾아 반환한다.
355401
// 없으면 nil을 반환한다.
356402
func FindNode(root *TreeNode, id string) *TreeNode {

internal/ragindex/builder_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,98 @@ func TestBuilder_NoSymbolsWithoutIntent(t *testing.T) {
516516
}
517517
}
518518

519+
// TestSearch_MatchesLabel: query가 label에 매칭되면 결과를 반환한다.
520+
func TestSearch_MatchesLabel(t *testing.T) {
521+
root := &ragindex.TreeNode{
522+
ID: "root",
523+
Label: "Root",
524+
Children: []*ragindex.TreeNode{
525+
{
526+
ID: "community:auth",
527+
Label: "MCP Server",
528+
Summary: "핸들러 레이어",
529+
Children: []*ragindex.TreeNode{
530+
{ID: "file:handlers.go", Label: "handlers.go", Summary: "MCP 핸들러"},
531+
},
532+
},
533+
{
534+
ID: "community:core",
535+
Label: "Core Logic",
536+
Summary: "비즈니스 로직",
537+
},
538+
},
539+
}
540+
541+
results := ragindex.Search(root, "mcp", 10)
542+
if len(results) != 2 {
543+
t.Fatalf("expected 2 results (community + file), got %d", len(results))
544+
}
545+
}
546+
547+
// TestSearch_CaseInsensitive: 검색은 대소문자를 구분하지 않는다.
548+
func TestSearch_CaseInsensitive(t *testing.T) {
549+
root := &ragindex.TreeNode{
550+
ID: "root",
551+
Label: "Root",
552+
Children: []*ragindex.TreeNode{
553+
{ID: "c1", Label: "Auth Service", Summary: "JWT 인증"},
554+
},
555+
}
556+
results := ragindex.Search(root, "AUTH", 10)
557+
if len(results) != 1 {
558+
t.Fatalf("expected 1 result, got %d", len(results))
559+
}
560+
}
561+
562+
// TestSearch_MaxResults: maxResults 파라미터가 결과 수를 제한한다.
563+
func TestSearch_MaxResults(t *testing.T) {
564+
children := make([]*ragindex.TreeNode, 10)
565+
for i := range children {
566+
children[i] = &ragindex.TreeNode{ID: fmt.Sprintf("c%d", i), Label: "test node", Summary: "test"}
567+
}
568+
root := &ragindex.TreeNode{ID: "root", Children: children}
569+
570+
results := ragindex.Search(root, "test", 3)
571+
if len(results) != 3 {
572+
t.Fatalf("expected 3 results (maxResults), got %d", len(results))
573+
}
574+
}
575+
576+
// TestSearch_IncludesBreadcrumb: 결과에 Path(breadcrumb)가 포함된다.
577+
func TestSearch_IncludesBreadcrumb(t *testing.T) {
578+
root := &ragindex.TreeNode{
579+
ID: "root",
580+
Label: "Root",
581+
Children: []*ragindex.TreeNode{
582+
{
583+
ID: "community:auth",
584+
Label: "Auth",
585+
Children: []*ragindex.TreeNode{
586+
{ID: "file:login.go", Label: "login.go", Summary: "로그인 처리"},
587+
},
588+
},
589+
},
590+
}
591+
results := ragindex.Search(root, "로그인", 10)
592+
if len(results) != 1 {
593+
t.Fatalf("expected 1 result, got %d", len(results))
594+
}
595+
if len(results[0].Path) != 3 {
596+
t.Fatalf("expected path length 3 (root→auth→file), got %d: %v", len(results[0].Path), results[0].Path)
597+
}
598+
}
599+
600+
// TestSearch_NoMatch: 매칭 없으면 빈 슬라이스 반환.
601+
func TestSearch_NoMatch(t *testing.T) {
602+
root := &ragindex.TreeNode{ID: "root", Label: "Root", Children: []*ragindex.TreeNode{
603+
{ID: "c1", Label: "Auth", Summary: "인증"},
604+
}}
605+
results := ragindex.Search(root, "zzznomatch", 10)
606+
if len(results) != 0 {
607+
t.Fatalf("expected 0 results, got %d", len(results))
608+
}
609+
}
610+
519611
// TestFindNode: FindNode가 재귀적으로 트리에서 노드를 찾는지 검증한다.
520612
func TestFindNode(t *testing.T) {
521613
root := &ragindex.TreeNode{

0 commit comments

Comments
 (0)