Skip to content

Commit bc23992

Browse files
tae2089claude
andcommitted
feat: federate search, query_graph, list_graph_stats, and search_docs across namespaces
Add an optional 'namespaces' array parameter to the four read tools. Federation fans out per namespace at the handler level so every store query path keeps its single-namespace invariant: - search merges per-namespace candidate pools, reranks once, and labels each item with its namespace (single-namespace responses are byte-identical to before). - query_graph runs the extracted per-namespace body for each namespace and groups outcomes; a missing target in one namespace becomes a per-namespace error instead of failing the call. - list_graph_stats and search_docs return per-namespace groups without merging counts across unrelated graphs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c09fcd1 commit bc23992

6 files changed

Lines changed: 555 additions & 153 deletions

File tree

internal/adapters/inbound/mcp/handler_docs.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"strings"
1111

1212
"github.com/mark3labs/mcp-go/mcp"
13+
"github.com/tae2089/trace"
14+
1315
"github.com/tae2089/code-context-graph/internal/app/wiki"
1416
requestctx "github.com/tae2089/code-context-graph/internal/ctx"
1517
)
@@ -104,6 +106,11 @@ func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest)
104106
if h.deps.Docs.Retrieval == nil {
105107
return mcp.NewToolResultError("DB is not configured"), nil
106108
}
109+
110+
if namespaces := requestNamespaces(request); len(namespaces) > 0 {
111+
return h.searchDocsFederated(ctx, query, limit, namespaces)
112+
}
113+
107114
return finalizeToolResult(h.cachedExecute(ctx, "search_docs:db:", map[string]any{"query": query, "limit": limit, "namespace": namespace}, func() (string, error) {
108115
results, err := h.searchDocsFromDB(ctx, namespace, query, limit)
109116
if err != nil {
@@ -114,6 +121,34 @@ func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest)
114121
}))
115122
}
116123

124+
// federatedDocsEntry labels one namespace's documentation candidates in a federated response.
125+
// @intent keep doc candidates attributable to their source repository.
126+
type federatedDocsEntry struct {
127+
Namespace string `json:"namespace"`
128+
Results []wiki.SearchResult `json:"results"`
129+
}
130+
131+
// searchDocsFederated searches documentation candidates across several namespaces.
132+
// @intent let one docs query cover multiple repositories with per-namespace grouping.
133+
func (h *handlers) searchDocsFederated(ctx context.Context, query string, limit int, namespaces []string) (*mcp.CallToolResult, error) {
134+
return finalizeToolResult(h.cachedExecute(ctx, "search_docs:db:", map[string]any{"query": query, "limit": limit, "namespaces": namespaces}, func() (string, error) {
135+
entries := make([]federatedDocsEntry, 0, len(namespaces))
136+
for _, ns := range namespaces {
137+
nsCtx := requestctx.WithNamespace(ctx, ns)
138+
results, err := h.searchDocsFromDB(nsCtx, ns, query, limit)
139+
if err != nil {
140+
return "", newToolResultErr(err.Error())
141+
}
142+
entries = append(entries, federatedDocsEntry{Namespace: ns, Results: results})
143+
}
144+
b, err := json.Marshal(map[string]any{"namespaces": entries})
145+
if err != nil {
146+
return "", trace.Wrap(err, "marshal result")
147+
}
148+
return string(b), nil
149+
}))
150+
}
151+
117152
// @intent search persisted graph nodes directly from DB and search backend.
118153
// @requires ctx must carry the selected namespace for SearchBackend.Query.
119154
// @ensures returns SearchResult-compatible JSON items without requiring generated index files.
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/mark3labs/mcp-go/mcp"
10+
)
11+
12+
func seedFederatedNamespaces(t *testing.T, deps *Deps) {
13+
t.Helper()
14+
write := func(dir, rel, content string) string {
15+
t.Helper()
16+
full := filepath.Join(dir, rel)
17+
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
18+
t.Fatalf("mkdir: %v", err)
19+
}
20+
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
21+
t.Fatalf("write: %v", err)
22+
}
23+
return full
24+
}
25+
alphaDir := t.TempDir()
26+
betaDir := t.TempDir()
27+
deps.Runtime.RepoRoot = filepath.Dir(alphaDir)
28+
write(alphaDir, "pay.go", "package alpha\n\nfunc PaymentProcess() string {\n\treturn \"payment\"\n}\n")
29+
write(betaDir, "refund.go", "package beta\n\nfunc PaymentRefund() string {\n\treturn \"payment\"\n}\n")
30+
31+
deps.Runtime.RepoRoot = alphaDir
32+
if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": alphaDir, "namespace": "alpha"}); res.IsError {
33+
t.Fatalf("parse alpha failed: %+v", res)
34+
}
35+
deps.Runtime.RepoRoot = betaDir
36+
if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": betaDir, "namespace": "beta"}); res.IsError {
37+
t.Fatalf("parse beta failed: %+v", res)
38+
}
39+
}
40+
41+
func resultTextOf(t *testing.T, result *mcp.CallToolResult) string {
42+
t.Helper()
43+
if result.IsError {
44+
t.Fatalf("tool returned error: %+v", result.Content)
45+
}
46+
for _, content := range result.Content {
47+
if text, ok := content.(mcp.TextContent); ok {
48+
return text.Text
49+
}
50+
if text, ok := content.(*mcp.TextContent); ok {
51+
return text.Text
52+
}
53+
}
54+
t.Fatal("no text content in result")
55+
return ""
56+
}
57+
58+
func TestSearch_FederatesAcrossNamespaces(t *testing.T) {
59+
deps := setupTestDeps(t)
60+
seedFederatedNamespaces(t, deps)
61+
62+
result := callTool(t, deps, "search", map[string]any{
63+
"query": "Payment",
64+
"namespaces": []string{"alpha", "beta"},
65+
})
66+
var items []struct {
67+
QualifiedName string `json:"qualified_name"`
68+
Namespace string `json:"namespace"`
69+
}
70+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &items); err != nil {
71+
t.Fatalf("unmarshal: %v", err)
72+
}
73+
seen := map[string]string{}
74+
for _, item := range items {
75+
seen[item.QualifiedName] = item.Namespace
76+
}
77+
if seen["alpha.PaymentProcess"] != "alpha" || seen["beta.PaymentRefund"] != "beta" {
78+
t.Fatalf("federated search items = %v, want hits from both namespaces with labels", seen)
79+
}
80+
}
81+
82+
func TestSearch_SingleNamespaceResponseUnchanged(t *testing.T) {
83+
deps := setupTestDeps(t)
84+
seedFederatedNamespaces(t, deps)
85+
86+
result := callTool(t, deps, "search", map[string]any{"query": "Payment", "namespace": "alpha"})
87+
text := resultTextOf(t, result)
88+
var raw []map[string]any
89+
if err := json.Unmarshal([]byte(text), &raw); err != nil {
90+
t.Fatalf("unmarshal: %v", err)
91+
}
92+
if len(raw) == 0 {
93+
t.Fatal("single-namespace search returned no results")
94+
}
95+
for _, item := range raw {
96+
if _, exists := item["namespace"]; exists {
97+
t.Fatalf("single-namespace response gained a namespace field: %v", item)
98+
}
99+
}
100+
}
101+
102+
func TestListGraphStats_FederatesAcrossNamespaces(t *testing.T) {
103+
deps := setupTestDeps(t)
104+
seedFederatedNamespaces(t, deps)
105+
106+
result := callTool(t, deps, "list_graph_stats", map[string]any{"namespaces": []string{"alpha", "beta"}})
107+
var payload struct {
108+
Namespaces []struct {
109+
Namespace string `json:"namespace"`
110+
TotalNodes int64 `json:"total_nodes"`
111+
} `json:"namespaces"`
112+
}
113+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil {
114+
t.Fatalf("unmarshal: %v", err)
115+
}
116+
if len(payload.Namespaces) != 2 {
117+
t.Fatalf("stats groups = %d, want 2", len(payload.Namespaces))
118+
}
119+
for _, group := range payload.Namespaces {
120+
if group.TotalNodes == 0 {
121+
t.Fatalf("namespace %q has zero nodes in federated stats", group.Namespace)
122+
}
123+
}
124+
}
125+
126+
func TestQueryGraph_FederatesAcrossNamespaces(t *testing.T) {
127+
deps := setupTestDeps(t)
128+
seedFederatedNamespaces(t, deps)
129+
130+
result := callTool(t, deps, "query_graph", map[string]any{
131+
"pattern": "callers_of",
132+
"target": "alpha.PaymentProcess",
133+
"namespaces": []string{"alpha", "beta"},
134+
})
135+
var payload struct {
136+
Pattern string `json:"pattern"`
137+
Namespaces []struct {
138+
Namespace string `json:"namespace"`
139+
Response json.RawMessage `json:"response,omitempty"`
140+
Error string `json:"error,omitempty"`
141+
} `json:"namespaces"`
142+
}
143+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil {
144+
t.Fatalf("unmarshal: %v", err)
145+
}
146+
if len(payload.Namespaces) != 2 {
147+
t.Fatalf("query groups = %d, want 2", len(payload.Namespaces))
148+
}
149+
byNS := map[string]json.RawMessage{}
150+
errsByNS := map[string]string{}
151+
for _, group := range payload.Namespaces {
152+
byNS[group.Namespace] = group.Response
153+
errsByNS[group.Namespace] = group.Error
154+
}
155+
if len(byNS["alpha"]) == 0 {
156+
t.Fatalf("alpha response missing: %+v", payload)
157+
}
158+
if errsByNS["beta"] == "" {
159+
t.Fatalf("beta should report a per-namespace error for missing file, got %+v", payload)
160+
}
161+
}
162+
163+
func TestSearchDocs_FederatesAcrossNamespaces(t *testing.T) {
164+
deps := setupTestDeps(t)
165+
seedFederatedNamespaces(t, deps)
166+
167+
result := callTool(t, deps, "search_docs", map[string]any{
168+
"query": "Payment",
169+
"namespaces": []string{"alpha", "beta"},
170+
})
171+
var payload struct {
172+
Namespaces []struct {
173+
Namespace string `json:"namespace"`
174+
Results []struct {
175+
Label string `json:"label"`
176+
} `json:"results"`
177+
} `json:"namespaces"`
178+
}
179+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil {
180+
t.Fatalf("unmarshal: %v", err)
181+
}
182+
if len(payload.Namespaces) != 2 {
183+
t.Fatalf("docs groups = %d, want 2", len(payload.Namespaces))
184+
}
185+
for _, group := range payload.Namespaces {
186+
if len(group.Results) == 0 {
187+
t.Fatalf("namespace %q returned no doc candidates", group.Namespace)
188+
}
189+
}
190+
}

0 commit comments

Comments
 (0)