Skip to content

Commit 094cffd

Browse files
aksOpsclaude
andauthored
test(mcp): per-mode parity for 6 consolidated tools (#139)
Adds table-driven tests over every mode of every consolidated tool — 32 mode dispatches total. Each asserts the envelope shape; payload contents verified for non-emptiness against an in-memory Kuzu fixture (topology-fixture reuse + Flow engine). Three known bugs in tools_consolidated.go dispatch are pinned as explicit INVALID_INPUT assertions (see BUG comments): - trace_relationships/{callers,consumers,producers,dependencies, dependents} pass node_id but consumerLikeTool reads target_id - trace_relationships/shortest_path passes from/to but underlying tool reads source/target - find_in_graph/by_endpoint passes node_id but find_related_endpoints reads identifier Closes ultraplan §2 mode-by-mode coverage mandate. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d2853dd commit 094cffd

1 file changed

Lines changed: 381 additions & 0 deletions

File tree

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
package mcp_test
2+
3+
// Per-mode parity tests for the 6 consolidated MCP tools.
4+
//
5+
// Coverage goal: one table-driven parent test per tool, one sub-test per
6+
// mode — 32 mode dispatches total. Each sub-test asserts:
7+
// - the dispatch reaches the underlying handler (no "unknown mode" error),
8+
// - the response envelope has the expected top-level key(s), and
9+
// - where the fixture has data, the key holds a non-error value.
10+
//
11+
// Known bugs in the consolidated dispatch layer are explicitly marked as
12+
// BUG comments and asserted as-is so regressions are visible:
13+
// - trace_relationships/{callers,consumers,producers,dependencies,dependents}
14+
// pass `node_id` to consumerLikeTool handlers that only unmarshal
15+
// `target_id`, producing a permanent INVALID_INPUT envelope.
16+
// - trace_relationships/shortest_path passes `from`/`to` but the
17+
// underlying tool reads `source`/`target`, so p.Source/p.Target are
18+
// always ""; the handler returns an INVALID_INPUT envelope.
19+
// - find_in_graph/by_endpoint passes `node_id` but find_related_endpoints
20+
// reads `identifier`; the underlying handler returns INVALID_INPUT.
21+
//
22+
// DO NOT fix these bugs in this file — fix in tools_consolidated.go and
23+
// the asserted shape here will change naturally.
24+
25+
import (
26+
"testing"
27+
28+
"github.com/randomcodespace/codeiq/go/internal/flow"
29+
"github.com/randomcodespace/codeiq/go/internal/mcp"
30+
"github.com/randomcodespace/codeiq/go/internal/model"
31+
)
32+
33+
// consolidatedDeps returns *mcp.Deps backed by the topology fixture
34+
// (checkout + billing services) augmented with a Flow engine so
35+
// topology_view/flow works.
36+
func consolidatedDeps(t *testing.T) *mcp.Deps {
37+
t.Helper()
38+
// Reuse the topology fixture (already has Store, Query, Stats, Topology).
39+
d := topologyFixtureDeps(t)
40+
// Wire a Flow engine from an in-memory snapshot so topology_view/flow
41+
// does not error out on a nil engine.
42+
nodes := []*model.CodeNode{
43+
{ID: "svc:checkout", Kind: model.NodeService, Label: "checkout", Layer: model.LayerBackend},
44+
{ID: "svc:billing", Kind: model.NodeService, Label: "billing", Layer: model.LayerBackend},
45+
}
46+
edges := []*model.CodeEdge{
47+
{ID: "ef1", Kind: model.EdgeCalls, SourceID: "svc:checkout", TargetID: "svc:billing"},
48+
}
49+
snap := flow.NewSnapshot(nodes, edges)
50+
d.Flow = flow.NewEngineFromSnapshot(snap)
51+
return d
52+
}
53+
54+
// callConsolidatedTool registers only RegisterConsolidated on a fresh
55+
// server, invokes the named tool via the SDK in-memory transport, and
56+
// returns the parsed JSON response body.
57+
func callConsolidatedTool(t *testing.T, d *mcp.Deps, name string, args map[string]any) map[string]any {
58+
t.Helper()
59+
srv, err := mcp.NewServer(mcp.ServerOptions{Name: "x", Version: "0"})
60+
if err != nil {
61+
t.Fatalf("NewServer: %v", err)
62+
}
63+
if err := mcp.RegisterConsolidated(srv, d); err != nil {
64+
t.Fatalf("RegisterConsolidated: %v", err)
65+
}
66+
sess, cleanup := connectInMemoryTest(t, srv)
67+
defer cleanup()
68+
69+
ctx, cancel := contextDeadline(t)
70+
defer cancel()
71+
72+
res, err := sess.CallTool(ctx, sdkCallToolParams(name, args))
73+
if err != nil {
74+
t.Fatalf("CallTool(%s, %v): %v", name, args, err)
75+
}
76+
if len(res.Content) == 0 {
77+
t.Fatalf("%s returned empty content", name)
78+
}
79+
tc, ok := res.Content[0].(textContent)
80+
if !ok {
81+
t.Fatalf("%s content type = %T", name, res.Content[0])
82+
}
83+
return unmarshalJSON(t, tc.Text)
84+
}
85+
86+
// assertKey fatalf's unless the response map contains every key in wantKeys.
87+
func assertKeys(t *testing.T, got map[string]any, wantKeys []string) {
88+
t.Helper()
89+
for _, k := range wantKeys {
90+
if _, ok := got[k]; !ok {
91+
t.Errorf("response missing key %q; got keys: %v", k, mapKeys(got))
92+
}
93+
}
94+
}
95+
96+
// assertCode asserts got["code"] == wantCode — used for modes that are
97+
// expected to return a structured error envelope due to known bugs.
98+
func assertCode(t *testing.T, got map[string]any, wantCode string) {
99+
t.Helper()
100+
if got["code"] != wantCode {
101+
t.Errorf("code = %v, want %v. full response: %v", got["code"], wantCode, got)
102+
}
103+
}
104+
105+
// mapKeys returns the keys of a map[string]any for diagnostic output.
106+
func mapKeys(m map[string]any) []string {
107+
ks := make([]string, 0, len(m))
108+
for k := range m {
109+
ks = append(ks, k)
110+
}
111+
return ks
112+
}
113+
114+
// --------------------------------------------------------------------------
115+
// graph_summary — 4 modes
116+
// --------------------------------------------------------------------------
117+
118+
func TestGraphSummary_AllModes(t *testing.T) {
119+
cases := []struct {
120+
mode string
121+
args map[string]any
122+
wantKeys []string // at least one of these must be present
123+
wantError bool // true = expect error envelope (code key present)
124+
}{
125+
// overview delegates to get_stats; Stats is wired → non-empty map.
126+
{mode: "overview", wantKeys: []string{"graph"}},
127+
// categories with no category arg delegates to get_detailed_stats
128+
// which calls ComputeStats when category is "all"/empty.
129+
{mode: "categories", wantKeys: []string{"graph"}},
130+
// capabilities delegates to get_capabilities → {matrix: ...}
131+
{mode: "capabilities", wantKeys: []string{"matrix"}},
132+
// provenance delegates to get_artifact_metadata; ArtifactMeta is nil
133+
// → legacy {error: "Artifact metadata unavailable..."} envelope.
134+
{mode: "provenance", wantKeys: []string{"error"}},
135+
}
136+
137+
d := consolidatedDeps(t)
138+
for _, tc := range cases {
139+
t.Run(tc.mode, func(t *testing.T) {
140+
args := map[string]any{"mode": tc.mode}
141+
if tc.args != nil {
142+
for k, v := range tc.args {
143+
args[k] = v
144+
}
145+
}
146+
got := callConsolidatedTool(t, d, "graph_summary", args)
147+
assertKeys(t, got, tc.wantKeys)
148+
})
149+
}
150+
}
151+
152+
// --------------------------------------------------------------------------
153+
// find_in_graph — 6 modes
154+
// --------------------------------------------------------------------------
155+
156+
func TestFindInGraph_AllModes(t *testing.T) {
157+
d := consolidatedDeps(t)
158+
159+
cases := []struct {
160+
mode string
161+
args map[string]any
162+
wantKeys []string
163+
wantCode string // non-empty → assert code equals this (known-bug modes)
164+
}{
165+
// nodes — no kind filter; returns {nodes, count, limit}
166+
{mode: "nodes", wantKeys: []string{"nodes", "count", "limit"}},
167+
// edges — no kind filter; returns {edges, count, limit}
168+
{mode: "edges", wantKeys: []string{"edges", "count", "limit"}},
169+
// text — requires non-empty query; query="checkout" finds label match
170+
{mode: "text", args: map[string]any{"query": "checkout"}, wantKeys: []string{"results", "count", "query"}},
171+
// fuzzy — requires non-empty query; returns {matches, count}
172+
{mode: "fuzzy", args: map[string]any{"query": "checkout"}, wantKeys: []string{"matches", "count"}},
173+
// by_file — file_path; returns {file_path, nodes, count}
174+
{mode: "by_file", args: map[string]any{"file_path": "checkout/PayController.java"}, wantKeys: []string{"file_path", "nodes", "count"}},
175+
// by_endpoint — BUG: consolidated passes `node_id` but find_related_endpoints
176+
// reads `identifier`; the handler returns INVALID_INPUT for empty identifier.
177+
{mode: "by_endpoint", args: map[string]any{"node_id": "ep:checkout:/pay"},
178+
wantCode: mcp.CodeInvalidInput},
179+
}
180+
181+
for _, tc := range cases {
182+
t.Run(tc.mode, func(t *testing.T) {
183+
args := map[string]any{"mode": tc.mode}
184+
for k, v := range tc.args {
185+
args[k] = v
186+
}
187+
got := callConsolidatedTool(t, d, "find_in_graph", args)
188+
if tc.wantCode != "" {
189+
// Known dispatch bug — assert the specific error code.
190+
assertCode(t, got, tc.wantCode)
191+
return
192+
}
193+
assertKeys(t, got, tc.wantKeys)
194+
})
195+
}
196+
}
197+
198+
// --------------------------------------------------------------------------
199+
// inspect_node — 4 modes
200+
// --------------------------------------------------------------------------
201+
202+
func TestInspectNode_AllModes(t *testing.T) {
203+
d := consolidatedDeps(t)
204+
205+
cases := []struct {
206+
mode string
207+
args map[string]any
208+
wantKeys []string
209+
wantCode string
210+
}{
211+
// neighbors — node_id required; checkout service has children
212+
{mode: "neighbors", args: map[string]any{"node_id": "svc:checkout"},
213+
wantKeys: []string{"node_id", "direction", "outgoing"}},
214+
// ego — center required; returns {center, radius, nodes, count}
215+
{mode: "ego", args: map[string]any{"center": "svc:checkout"},
216+
wantKeys: []string{"center", "radius", "nodes", "count"}},
217+
// evidence — Evidence service not wired → legacy {error: "...unavailable..."} envelope
218+
{mode: "evidence", args: map[string]any{"node_id": "svc:checkout"},
219+
wantKeys: []string{"error"}},
220+
// source — RootPath is empty → INTERNAL_ERROR
221+
{mode: "source", args: map[string]any{"file_path": "checkout/PayController.java"},
222+
wantCode: mcp.CodeInternalError},
223+
}
224+
225+
for _, tc := range cases {
226+
t.Run(tc.mode, func(t *testing.T) {
227+
args := map[string]any{"mode": tc.mode}
228+
for k, v := range tc.args {
229+
args[k] = v
230+
}
231+
got := callConsolidatedTool(t, d, "inspect_node", args)
232+
if tc.wantCode != "" {
233+
assertCode(t, got, tc.wantCode)
234+
return
235+
}
236+
assertKeys(t, got, tc.wantKeys)
237+
})
238+
}
239+
}
240+
241+
// --------------------------------------------------------------------------
242+
// trace_relationships — 6 modes
243+
// --------------------------------------------------------------------------
244+
245+
func TestTraceRelationships_AllModes(t *testing.T) {
246+
d := consolidatedDeps(t)
247+
248+
cases := []struct {
249+
mode string
250+
args map[string]any
251+
wantKeys []string
252+
wantCode string
253+
}{
254+
// BUG: callers/consumers/producers/dependencies/dependents all pass
255+
// `node_id` but the underlying consumerLikeTool handlers unmarshal
256+
// `target_id`. The node_id key is silently ignored, target_id stays "",
257+
// so every one of these returns INVALID_INPUT.
258+
{mode: "callers", args: map[string]any{"node_id": "svc:checkout"},
259+
wantCode: mcp.CodeInvalidInput},
260+
{mode: "consumers", args: map[string]any{"node_id": "svc:checkout"},
261+
wantCode: mcp.CodeInvalidInput},
262+
{mode: "producers", args: map[string]any{"node_id": "svc:checkout"},
263+
wantCode: mcp.CodeInvalidInput},
264+
{mode: "dependencies", args: map[string]any{"node_id": "svc:checkout"},
265+
wantCode: mcp.CodeInvalidInput},
266+
{mode: "dependents", args: map[string]any{"node_id": "svc:checkout"},
267+
wantCode: mcp.CodeInvalidInput},
268+
// BUG: shortest_path passes `from`/`to` but find_shortest_path reads
269+
// `source`/`target`; both end up empty → INVALID_INPUT.
270+
{mode: "shortest_path", args: map[string]any{"from": "svc:checkout", "to": "svc:billing"},
271+
wantCode: mcp.CodeInvalidInput},
272+
}
273+
274+
for _, tc := range cases {
275+
t.Run(tc.mode, func(t *testing.T) {
276+
args := map[string]any{"mode": tc.mode}
277+
for k, v := range tc.args {
278+
args[k] = v
279+
}
280+
got := callConsolidatedTool(t, d, "trace_relationships", args)
281+
if tc.wantCode != "" {
282+
assertCode(t, got, tc.wantCode)
283+
return
284+
}
285+
assertKeys(t, got, tc.wantKeys)
286+
})
287+
}
288+
}
289+
290+
// --------------------------------------------------------------------------
291+
// analyze_impact — 7 modes
292+
// --------------------------------------------------------------------------
293+
294+
func TestAnalyzeImpact_AllModes(t *testing.T) {
295+
d := consolidatedDeps(t)
296+
297+
cases := []struct {
298+
mode string
299+
args map[string]any
300+
wantKeys []string
301+
wantCode string
302+
}{
303+
// blast_radius — node_id required; returns {source, depth, affected_nodes, ...}
304+
{mode: "blast_radius", args: map[string]any{"node_id": "svc:checkout"},
305+
wantKeys: []string{"source"}},
306+
// trace — node_id required; delegates to trace_impact which calls
307+
// Topology.BlastRadius → same shape as blast_radius
308+
{mode: "trace", args: map[string]any{"node_id": "svc:checkout"},
309+
wantKeys: []string{"source"}},
310+
// cycles — delegates to find_cycles; returns {cycles, count}
311+
{mode: "cycles", wantKeys: []string{"cycles", "count"}},
312+
// circular_deps — delegates to find_circular_deps; returns {cycles, count}
313+
{mode: "circular_deps", wantKeys: []string{"cycles", "count"}},
314+
// dead_code — delegates to find_dead_code; returns {dead_code, count}
315+
{mode: "dead_code", wantKeys: []string{"dead_code", "count"}},
316+
// dead_services — delegates to find_dead_services; returns {dead_services, count}
317+
{mode: "dead_services", wantKeys: []string{"dead_services", "count"}},
318+
// bottlenecks — delegates to find_bottlenecks; returns {bottlenecks, count}
319+
{mode: "bottlenecks", wantKeys: []string{"bottlenecks", "count"}},
320+
}
321+
322+
for _, tc := range cases {
323+
t.Run(tc.mode, func(t *testing.T) {
324+
args := map[string]any{"mode": tc.mode}
325+
for k, v := range tc.args {
326+
args[k] = v
327+
}
328+
got := callConsolidatedTool(t, d, "analyze_impact", args)
329+
if tc.wantCode != "" {
330+
assertCode(t, got, tc.wantCode)
331+
return
332+
}
333+
assertKeys(t, got, tc.wantKeys)
334+
})
335+
}
336+
}
337+
338+
// --------------------------------------------------------------------------
339+
// topology_view — 5 modes
340+
// --------------------------------------------------------------------------
341+
342+
func TestTopologyView_AllModes(t *testing.T) {
343+
d := consolidatedDeps(t)
344+
345+
cases := []struct {
346+
mode string
347+
args map[string]any
348+
wantKeys []string
349+
wantCode string
350+
}{
351+
// summary — delegates to get_topology; returns {services, connections, ...}
352+
{mode: "summary", wantKeys: []string{"services", "connections"}},
353+
// service — delegates to service_detail; returns {name, endpoints, entities, ...}
354+
{mode: "service", args: map[string]any{"service_name": "checkout"},
355+
wantKeys: []string{"name", "endpoints", "entities"}},
356+
// service_deps — delegates to service_dependencies; returns {service, dependencies, count}
357+
{mode: "service_deps", args: map[string]any{"service_name": "checkout"},
358+
wantKeys: []string{"service", "count"}},
359+
// service_dependents — delegates to service_dependents; returns {service, dependents, count}
360+
{mode: "service_dependents", args: map[string]any{"service_name": "billing"},
361+
wantKeys: []string{"service", "count"}},
362+
// flow — delegates to generate_flow; Flow engine wired →
363+
// returns JSON flow object with `view` key (default view=overview)
364+
{mode: "flow", wantKeys: []string{"view"}},
365+
}
366+
367+
for _, tc := range cases {
368+
t.Run(tc.mode, func(t *testing.T) {
369+
args := map[string]any{"mode": tc.mode}
370+
for k, v := range tc.args {
371+
args[k] = v
372+
}
373+
got := callConsolidatedTool(t, d, "topology_view", args)
374+
if tc.wantCode != "" {
375+
assertCode(t, got, tc.wantCode)
376+
return
377+
}
378+
assertKeys(t, got, tc.wantKeys)
379+
})
380+
}
381+
}

0 commit comments

Comments
 (0)