|
| 1 | +// Consolidated MCP tools — Plan Phase 2. |
| 2 | +// |
| 3 | +// 34 narrow tools collapsed into 6 mode-driven tools + 1 escape hatch |
| 4 | +// (run_cypher, already in tools_graph.go) + 1 review tool (review_changes, |
| 5 | +// added in Phase 3). Each consolidated tool takes a `mode` string parameter |
| 6 | +// and DELEGATES TO THE EXISTING TOOL HANDLERS — this is a surface change, |
| 7 | +// not a query-layer rewrite. The deprecated 34 stay wired for one release |
| 8 | +// for back-compat with agents pinned to old names. |
| 9 | +// |
| 10 | +// graph_summary → get_stats, get_detailed_stats, get_capabilities, get_artifact_metadata |
| 11 | +// find_in_graph → query_nodes, query_edges, search_graph, find_node, find_component_by_file, find_related_endpoints |
| 12 | +// inspect_node → get_node_neighbors, get_ego_graph, get_evidence_pack, read_file |
| 13 | +// trace_relationships → find_callers, find_consumers, find_producers, find_dependencies, find_dependents, find_shortest_path |
| 14 | +// analyze_impact → trace_impact, blast_radius, find_cycles, find_circular_deps, find_dead_code, find_dead_services, find_bottlenecks |
| 15 | +// topology_view → get_topology, service_detail, service_dependencies, service_dependents, generate_flow |
| 16 | +// run_cypher → (escape hatch, unchanged) |
| 17 | +package mcp |
| 18 | + |
| 19 | +import ( |
| 20 | + "context" |
| 21 | + "encoding/json" |
| 22 | + "fmt" |
| 23 | +) |
| 24 | + |
| 25 | +// RegisterConsolidated appends the 6 new tools to srv. Wired alongside |
| 26 | +// the deprecated 34 by the CLI's mcp boot path. |
| 27 | +func RegisterConsolidated(srv *Server, d *Deps) error { |
| 28 | + for _, t := range []Tool{ |
| 29 | + toolGraphSummary(d), |
| 30 | + toolFindInGraph(d), |
| 31 | + toolInspectNode(d), |
| 32 | + toolTraceRelationships(d), |
| 33 | + toolAnalyzeImpact(d), |
| 34 | + toolTopologyView(d), |
| 35 | + } { |
| 36 | + if err := srv.Register(t); err != nil { |
| 37 | + return fmt.Errorf("mcp: register consolidated tool %q: %w", t.Name, err) |
| 38 | + } |
| 39 | + } |
| 40 | + return nil |
| 41 | +} |
| 42 | + |
| 43 | +// delegate invokes another tool's handler with a synthesized params object. |
| 44 | +// Each consolidated tool's mode dispatches through this so the consolidated |
| 45 | +// surface stays in lockstep with the deprecated tools — no logic forks. |
| 46 | +func delegate(ctx context.Context, t Tool, params map[string]any) (any, error) { |
| 47 | + raw, err := json.Marshal(params) |
| 48 | + if err != nil { |
| 49 | + return NewErrorEnvelope(CodeInternalError, fmt.Errorf("marshal params: %w", err), RequestID(ctx)), nil |
| 50 | + } |
| 51 | + return t.Handler(ctx, raw) |
| 52 | +} |
| 53 | + |
| 54 | +func toolGraphSummary(d *Deps) Tool { |
| 55 | + return Tool{ |
| 56 | + Name: "graph_summary", |
| 57 | + Description: "Graph overview, statistics, capabilities, and provenance " + |
| 58 | + "in one tool. `mode`: `overview` (totals + breakdowns), " + |
| 59 | + "`categories` (specific category via `category` param), " + |
| 60 | + "`capabilities` (intelligence capability matrix), `provenance` " + |
| 61 | + "(artifact + index timestamps). Default mode: overview.", |
| 62 | + Schema: json.RawMessage(`{"type":"object","properties":{"mode":{"type":"string","enum":["overview","categories","capabilities","provenance"]},"category":{"type":"string"}}}`), |
| 63 | + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { |
| 64 | + var p struct { |
| 65 | + Mode string `json:"mode"` |
| 66 | + Category string `json:"category"` |
| 67 | + } |
| 68 | + _ = json.Unmarshal(raw, &p) |
| 69 | + if p.Mode == "" { |
| 70 | + p.Mode = "overview" |
| 71 | + } |
| 72 | + switch p.Mode { |
| 73 | + case "overview": |
| 74 | + return delegate(ctx, toolGetStats(d), nil) |
| 75 | + case "categories": |
| 76 | + return delegate(ctx, toolGetDetailedStats(d), map[string]any{"category": p.Category}) |
| 77 | + case "capabilities": |
| 78 | + return delegate(ctx, toolGetCapabilities(d), nil) |
| 79 | + case "provenance": |
| 80 | + return delegate(ctx, toolGetArtifactMetadata(d), nil) |
| 81 | + default: |
| 82 | + return NewErrorEnvelope(CodeInvalidInput, fmt.Errorf("unknown mode %q: expected overview|categories|capabilities|provenance", p.Mode), RequestID(ctx)), nil |
| 83 | + } |
| 84 | + }, |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +func toolFindInGraph(d *Deps) Tool { |
| 89 | + return Tool{ |
| 90 | + Name: "find_in_graph", |
| 91 | + Description: "Find nodes, edges, or matches. `mode`: " + |
| 92 | + "`nodes` (filter by `kind`), `edges` (filter by `kind`), " + |
| 93 | + "`text` (label search via `query`), `fuzzy` (Planner-routed match via `query`), " + |
| 94 | + "`by_file` (`file_path`), `by_endpoint` (`node_id`).", |
| 95 | + Schema: json.RawMessage(`{"type":"object","properties":{"mode":{"type":"string","enum":["nodes","edges","text","fuzzy","by_file","by_endpoint"]},"kind":{"type":"string"},"query":{"type":"string"},"file_path":{"type":"string"},"node_id":{"type":"string"},"limit":{"type":"integer"}},"required":["mode"]}`), |
| 96 | + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { |
| 97 | + var p struct { |
| 98 | + Mode string `json:"mode"` |
| 99 | + Kind string `json:"kind"` |
| 100 | + Query string `json:"query"` |
| 101 | + FilePath string `json:"file_path"` |
| 102 | + NodeID string `json:"node_id"` |
| 103 | + Limit int `json:"limit"` |
| 104 | + } |
| 105 | + if err := json.Unmarshal(raw, &p); err != nil { |
| 106 | + return NewErrorEnvelope(CodeInvalidInput, err, RequestID(ctx)), nil |
| 107 | + } |
| 108 | + switch p.Mode { |
| 109 | + case "nodes": |
| 110 | + return delegate(ctx, toolQueryNodes(d), map[string]any{"kind": p.Kind, "limit": p.Limit}) |
| 111 | + case "edges": |
| 112 | + return delegate(ctx, toolQueryEdges(d), map[string]any{"kind": p.Kind, "limit": p.Limit}) |
| 113 | + case "text": |
| 114 | + return delegate(ctx, toolSearchGraph(d), map[string]any{"query": p.Query, "limit": p.Limit}) |
| 115 | + case "fuzzy": |
| 116 | + return delegate(ctx, toolFindNode(d), map[string]any{"query": p.Query}) |
| 117 | + case "by_file": |
| 118 | + return delegate(ctx, toolFindComponentByFile(d), map[string]any{"file_path": p.FilePath}) |
| 119 | + case "by_endpoint": |
| 120 | + return delegate(ctx, toolFindRelatedEndpoints(d), map[string]any{"node_id": p.NodeID}) |
| 121 | + default: |
| 122 | + return NewErrorEnvelope(CodeInvalidInput, fmt.Errorf("unknown mode %q", p.Mode), RequestID(ctx)), nil |
| 123 | + } |
| 124 | + }, |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +func toolInspectNode(d *Deps) Tool { |
| 129 | + return Tool{ |
| 130 | + Name: "inspect_node", |
| 131 | + Description: "Inspect a single node. `mode`: " + |
| 132 | + "`neighbors` (1-hop via `node_id` + `direction`), " + |
| 133 | + "`ego` (`center` + `radius`), " + |
| 134 | + "`evidence` (snippets + provenance via `node_id` or `file_path`), " + |
| 135 | + "`source` (file contents via `file_path`).", |
| 136 | + Schema: json.RawMessage(`{"type":"object","properties":{"mode":{"type":"string","enum":["neighbors","ego","evidence","source"]},"node_id":{"type":"string"},"center":{"type":"string"},"radius":{"type":"integer"},"direction":{"type":"string"},"file_path":{"type":"string"}},"required":["mode"]}`), |
| 137 | + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { |
| 138 | + var p struct { |
| 139 | + Mode string `json:"mode"` |
| 140 | + NodeID string `json:"node_id"` |
| 141 | + Center string `json:"center"` |
| 142 | + Radius int `json:"radius"` |
| 143 | + Direction string `json:"direction"` |
| 144 | + FilePath string `json:"file_path"` |
| 145 | + } |
| 146 | + if err := json.Unmarshal(raw, &p); err != nil { |
| 147 | + return NewErrorEnvelope(CodeInvalidInput, err, RequestID(ctx)), nil |
| 148 | + } |
| 149 | + switch p.Mode { |
| 150 | + case "neighbors": |
| 151 | + return delegate(ctx, toolGetNodeNeighbors(d), map[string]any{"node_id": p.NodeID, "direction": p.Direction}) |
| 152 | + case "ego": |
| 153 | + return delegate(ctx, toolGetEgoGraph(d), map[string]any{"center": p.Center, "radius": p.Radius}) |
| 154 | + case "evidence": |
| 155 | + return delegate(ctx, toolGetEvidencePack(d), map[string]any{"node_id": p.NodeID, "file_path": p.FilePath}) |
| 156 | + case "source": |
| 157 | + return delegate(ctx, toolReadFile(d), map[string]any{"file_path": p.FilePath}) |
| 158 | + default: |
| 159 | + return NewErrorEnvelope(CodeInvalidInput, fmt.Errorf("unknown mode %q", p.Mode), RequestID(ctx)), nil |
| 160 | + } |
| 161 | + }, |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | +func toolTraceRelationships(d *Deps) Tool { |
| 166 | + return Tool{ |
| 167 | + Name: "trace_relationships", |
| 168 | + Description: "Walk relationships from a node. `mode`: " + |
| 169 | + "`callers` | `consumers` | `producers` | `dependencies` | `dependents` " + |
| 170 | + "(all use `node_id`); `shortest_path` (uses `from`+`to`).", |
| 171 | + Schema: json.RawMessage(`{"type":"object","properties":{"mode":{"type":"string","enum":["callers","consumers","producers","dependencies","dependents","shortest_path"]},"node_id":{"type":"string"},"from":{"type":"string"},"to":{"type":"string"}},"required":["mode"]}`), |
| 172 | + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { |
| 173 | + var p struct { |
| 174 | + Mode string `json:"mode"` |
| 175 | + NodeID string `json:"node_id"` |
| 176 | + From string `json:"from"` |
| 177 | + To string `json:"to"` |
| 178 | + } |
| 179 | + if err := json.Unmarshal(raw, &p); err != nil { |
| 180 | + return NewErrorEnvelope(CodeInvalidInput, err, RequestID(ctx)), nil |
| 181 | + } |
| 182 | + switch p.Mode { |
| 183 | + case "callers": |
| 184 | + return delegate(ctx, toolFindCallers(d), map[string]any{"node_id": p.NodeID}) |
| 185 | + case "consumers": |
| 186 | + return delegate(ctx, toolFindConsumers(d), map[string]any{"node_id": p.NodeID}) |
| 187 | + case "producers": |
| 188 | + return delegate(ctx, toolFindProducers(d), map[string]any{"node_id": p.NodeID}) |
| 189 | + case "dependencies": |
| 190 | + return delegate(ctx, toolFindDependencies(d), map[string]any{"node_id": p.NodeID}) |
| 191 | + case "dependents": |
| 192 | + return delegate(ctx, toolFindDependents(d), map[string]any{"node_id": p.NodeID}) |
| 193 | + case "shortest_path": |
| 194 | + return delegate(ctx, toolFindShortestPath(d), map[string]any{"from": p.From, "to": p.To}) |
| 195 | + default: |
| 196 | + return NewErrorEnvelope(CodeInvalidInput, fmt.Errorf("unknown mode %q", p.Mode), RequestID(ctx)), nil |
| 197 | + } |
| 198 | + }, |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +func toolAnalyzeImpact(d *Deps) Tool { |
| 203 | + return Tool{ |
| 204 | + Name: "analyze_impact", |
| 205 | + Description: "Architectural-impact queries. `mode`: " + |
| 206 | + "`blast_radius` (`node_id`+`depth`), `trace` (`node_id`+`depth`), " + |
| 207 | + "`cycles` (`limit`), `circular_deps`, `dead_code` (`kind`+`limit`), " + |
| 208 | + "`dead_services`, `bottlenecks`.", |
| 209 | + Schema: json.RawMessage(`{"type":"object","properties":{"mode":{"type":"string","enum":["blast_radius","trace","cycles","circular_deps","dead_code","dead_services","bottlenecks"]},"node_id":{"type":"string"},"depth":{"type":"integer"},"limit":{"type":"integer"},"kind":{"type":"string"}},"required":["mode"]}`), |
| 210 | + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { |
| 211 | + var p struct { |
| 212 | + Mode string `json:"mode"` |
| 213 | + NodeID string `json:"node_id"` |
| 214 | + Depth int `json:"depth"` |
| 215 | + Limit int `json:"limit"` |
| 216 | + Kind string `json:"kind"` |
| 217 | + } |
| 218 | + if err := json.Unmarshal(raw, &p); err != nil { |
| 219 | + return NewErrorEnvelope(CodeInvalidInput, err, RequestID(ctx)), nil |
| 220 | + } |
| 221 | + switch p.Mode { |
| 222 | + case "blast_radius": |
| 223 | + return delegate(ctx, toolBlastRadius(d), map[string]any{"node_id": p.NodeID, "depth": p.Depth}) |
| 224 | + case "trace": |
| 225 | + return delegate(ctx, toolTraceImpact(d), map[string]any{"node_id": p.NodeID, "depth": p.Depth}) |
| 226 | + case "cycles": |
| 227 | + return delegate(ctx, toolFindCycles(d), map[string]any{"limit": p.Limit}) |
| 228 | + case "circular_deps": |
| 229 | + return delegate(ctx, toolFindCircularDeps(d), nil) |
| 230 | + case "dead_code": |
| 231 | + return delegate(ctx, toolFindDeadCode(d), map[string]any{"kind": p.Kind, "limit": p.Limit}) |
| 232 | + case "dead_services": |
| 233 | + return delegate(ctx, toolFindDeadServices(d), nil) |
| 234 | + case "bottlenecks": |
| 235 | + return delegate(ctx, toolFindBottlenecks(d), nil) |
| 236 | + default: |
| 237 | + return NewErrorEnvelope(CodeInvalidInput, fmt.Errorf("unknown mode %q", p.Mode), RequestID(ctx)), nil |
| 238 | + } |
| 239 | + }, |
| 240 | + } |
| 241 | +} |
| 242 | + |
| 243 | +func toolTopologyView(d *Deps) Tool { |
| 244 | + return Tool{ |
| 245 | + Name: "topology_view", |
| 246 | + Description: "Service topology + architecture flow diagrams. `mode`: " + |
| 247 | + "`summary`, `service` (`service_name`), `service_deps` (`service_name`), " + |
| 248 | + "`service_dependents` (`service_name`), `flow` (`view`+`format`).", |
| 249 | + Schema: json.RawMessage(`{"type":"object","properties":{"mode":{"type":"string","enum":["summary","service","service_deps","service_dependents","flow"]},"service_name":{"type":"string"},"view":{"type":"string"},"format":{"type":"string","enum":["mermaid","dot","yaml"]}},"required":["mode"]}`), |
| 250 | + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { |
| 251 | + var p struct { |
| 252 | + Mode string `json:"mode"` |
| 253 | + ServiceName string `json:"service_name"` |
| 254 | + View string `json:"view"` |
| 255 | + Format string `json:"format"` |
| 256 | + } |
| 257 | + if err := json.Unmarshal(raw, &p); err != nil { |
| 258 | + return NewErrorEnvelope(CodeInvalidInput, err, RequestID(ctx)), nil |
| 259 | + } |
| 260 | + switch p.Mode { |
| 261 | + case "summary": |
| 262 | + return delegate(ctx, toolGetTopology(d), nil) |
| 263 | + case "service": |
| 264 | + return delegate(ctx, toolServiceDetail(d), map[string]any{"service_name": p.ServiceName}) |
| 265 | + case "service_deps": |
| 266 | + return delegate(ctx, toolServiceDependencies(d), map[string]any{"service_name": p.ServiceName}) |
| 267 | + case "service_dependents": |
| 268 | + return delegate(ctx, toolServiceDependents(d), map[string]any{"service_name": p.ServiceName}) |
| 269 | + case "flow": |
| 270 | + return delegate(ctx, toolGenerateFlow(d), map[string]any{"view": p.View, "format": p.Format}) |
| 271 | + default: |
| 272 | + return NewErrorEnvelope(CodeInvalidInput, fmt.Errorf("unknown mode %q", p.Mode), RequestID(ctx)), nil |
| 273 | + } |
| 274 | + }, |
| 275 | + } |
| 276 | +} |
0 commit comments