-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtools_intelligence.go
More file actions
316 lines (298 loc) · 10.9 KB
/
tools_intelligence.go
File metadata and controls
316 lines (298 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
// Tools wiring the four intelligence-facing MCP tools per spec §9.
//
// find_node — fuzzy name lookup routed via the QueryPlanner.
// get_evidence_pack — assembles an EvidencePack via the Assembler.
// get_artifact_metadata — returns the most recent provenance snapshot.
// get_capabilities — returns the per-language capability matrix.
package mcp
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/randomcodespace/codeiq/internal/intelligence/evidence"
iqquery "github.com/randomcodespace/codeiq/internal/intelligence/query"
"github.com/randomcodespace/codeiq/internal/model"
)
// intelligenceTools returns the slice of intelligence-facing Tool
// definitions for d.
func intelligenceTools(d *Deps) []Tool {
return []Tool{
toolFindNode(d),
toolGetEvidencePack(d),
toolGetArtifactMetadata(d),
toolGetCapabilities(d),
}
}
// RegisterIntelligence appends every intelligence-facing tool to srv.
// Symmetric with RegisterGraph / RegisterTopology / RegisterFlow.
func RegisterIntelligence(srv *Server, d *Deps) error {
for _, t := range intelligenceTools(d) {
if err := srv.Register(t); err != nil {
return fmt.Errorf("mcp: register intelligence tool %q: %w", t.Name, err)
}
}
return nil
}
// ---------- tool builders ----------
// toolFindNode performs fuzzy name lookup. Routing rules:
//
// - Exact match (label == query, case-insensitive) takes priority.
// - Otherwise, the QueryPlanner picks GRAPH_FIRST (label/fqn search)
// vs LEXICAL_FIRST (doc-comment / config-key search) vs MERGED (both,
// concatenated) vs DEGRADED (empty matches + note).
// - Without a wired QueryPlanner the handler falls back to GRAPH_FIRST.
//
// Mirrors Java McpTools.findNode + TopologyService.findNode shape:
// returns `{ matches: [...], count: N }` with each match in the compact
// node-map form (id, kind, label, file_path, layer).
func toolFindNode(d *Deps) Tool {
return Tool{
Name: "find_node",
Description: "Find a node by name with fuzzy matching — exact " +
"match priority, then partial/contains match. Use as a " +
"quick lookup when you have a name but not the full node " +
"ID. Returns best-matching node with its properties and " +
"connections.",
Schema: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}`),
Handler: func(ctx context.Context, raw json.RawMessage) (any, error) {
var p struct {
Query string `json:"query"`
}
_ = json.Unmarshal(raw, &p)
if strings.TrimSpace(p.Query) == "" {
return NewErrorEnvelope(CodeInvalidInput, fmt.Errorf("query is required"), RequestID(ctx)), nil
}
if d.Store == nil {
return NewErrorEnvelope(CodeInternalError, fmt.Errorf("graph store not wired"), RequestID(ctx)), nil
}
limit := CapResults(50, d.MaxResults)
route := iqquery.QueryRouteGraphFirst
degradationNote := ""
if d.QueryPlanner != nil {
plan := d.QueryPlanner.Plan(iqquery.QueryFindSymbol, inferLanguageFromQuery(p.Query))
route = plan.Route
degradationNote = plan.DegradationNote
}
// `find_node` is a name lookup — it always runs the structural
// search (label/fqn substring) because that is the only signal
// strong enough to anchor downstream impact-tracing. The
// planner's route is advisory and surfaces as
// `degradation_note` so MCP clients know what to expect.
//
// LEXICAL_FIRST and MERGED augment the structural results with
// a lexical pass (doc-comment / config-key match) since those
// languages don't have full structural coverage and the user
// may be searching for something that only appears in
// comments.
matches, err := d.Store.SearchByLabel(p.Query, limit)
if err != nil {
return NewErrorEnvelope(CodeInternalError, err, RequestID(ctx)), nil
}
if route == iqquery.QueryRouteLexicalFirst || route == iqquery.QueryRouteMerged {
more, err2 := d.Store.SearchLexical(p.Query, limit)
if err2 == nil {
matches = mergeUnique(matches, more)
}
}
// Sort exact-label hits (case-insensitive) to the front;
// partial matches keep relative order. Mirrors Java
// TopologyService.findNode priority rule.
sorted := sortExactFirst(matches, p.Query)
out := map[string]any{
"matches": nodesToCompact(sorted),
"count": len(sorted),
}
if degradationNote != "" {
out["degradation_note"] = degradationNote
}
return out, nil
},
}
}
// toolGetEvidencePack assembles an EvidencePack. Returns the legacy
// `{ "error": "Evidence pack service unavailable. Run 'enrich' first." }`
// shape when Evidence is not wired — matches Java McpTools.getEvidencePack
// exactly so existing clients reading `error` keep working.
func toolGetEvidencePack(d *Deps) Tool {
return Tool{
Name: "get_evidence_pack",
Description: "Assemble a comprehensive evidence pack for a " +
"symbol (class, method, function) or file: matched graph " +
"nodes, source code snippets, provenance metadata, analysis " +
"confidence level, and any degradation notes. Use when " +
"asked to explain or investigate a specific code element " +
"in depth.",
Schema: json.RawMessage(`{"type":"object","properties":{"symbol":{"type":"string"},"file_path":{"type":"string"},"max_snippet_lines":{"type":"integer"},"include_references":{"type":"boolean"}}}`),
Handler: func(ctx context.Context, raw json.RawMessage) (any, error) {
if d.Evidence == nil {
return map[string]string{
"error": "Evidence pack service unavailable. Run 'enrich' first.",
}, nil
}
var req evidence.Request
if err := json.Unmarshal(raw, &req); err != nil {
return NewErrorEnvelope(CodeInvalidInput, err, RequestID(ctx)), nil
}
pack, err := d.Evidence.Assemble(ctx, req, d.ArtifactMeta)
if err != nil {
return NewErrorEnvelope(CodeInternalError, err, RequestID(ctx)), nil
}
return pack, nil
},
}
}
// toolGetArtifactMetadata returns the provenance metadata snapshot. The
// `{ "error": "..." }` envelope when nil mirrors Java McpTools.
func toolGetArtifactMetadata(d *Deps) Tool {
return Tool{
Name: "get_artifact_metadata",
Description: "Return provenance metadata about the analyzed " +
"codebase: repository identity, commit SHA, build " +
"timestamp, analysis tool versions, capability matrix " +
"snapshot, and integrity hash. Use when asked about " +
"analysis freshness, data provenance, or 'when was this " +
"last scanned?'.",
Schema: json.RawMessage(`{"type":"object","properties":{}}`),
Handler: func(ctx context.Context, _ json.RawMessage) (any, error) {
if d.ArtifactMeta == nil {
return map[string]string{
"error": "Artifact metadata unavailable. Run 'enrich' first.",
}, nil
}
return d.ArtifactMeta, nil
},
}
}
// toolGetCapabilities returns the per-language capability matrix. With
// no params: every language's matrix under `matrix.<lang>`. With
// `language=<name>`: that one row under `language` + `capabilities`.
//
// Mirrors Java McpTools.getCapabilities — identical key names so client
// parsing logic transfers verbatim.
func toolGetCapabilities(d *Deps) Tool {
return Tool{
Name: "get_capabilities",
Description: "Show the analysis capability matrix: what " +
"codeiq can detect per language (Java, Python, " +
"TypeScript, Go, etc.) across dimensions like call graph, " +
"type hierarchy, framework detection. Levels: EXACT, " +
"PARTIAL, LEXICAL_ONLY, UNSUPPORTED. Use when asked 'what " +
"languages do you support?' or 'how accurate is the " +
"analysis?'.",
Schema: json.RawMessage(`{"type":"object","properties":{"language":{"type":"string"}}}`),
Handler: func(ctx context.Context, raw json.RawMessage) (any, error) {
var p struct {
Language string `json:"language"`
}
_ = json.Unmarshal(raw, &p)
lang := strings.ToLower(strings.TrimSpace(p.Language))
if lang != "" {
caps := iqquery.CapabilityMatrixFor(lang)
return map[string]any{
"language": lang,
"capabilities": caps,
}, nil
}
return map[string]any{"matrix": iqquery.AllCapabilities()}, nil
},
}
}
// ---------- helpers ----------
// inferLanguageFromQuery heuristically classifies a free-text query as
// either a java-flavoured FQN (>=2 dots and identifier-only) or
// "unknown". Mirrors the §9 task plan — keeps the routing decision
// fully deterministic without parsing the graph.
func inferLanguageFromQuery(q string) string {
dots := strings.Count(q, ".")
if dots >= 2 && isIdentifierish(q) {
return "java"
}
return "unknown"
}
// isIdentifierish reports whether every rune in q is an ASCII letter,
// digit, underscore, dot, or dollar — the union of valid characters in a
// Java FQN. Used to filter out free-text queries that just happen to
// contain dots (e.g. "log4j2.xml is missing").
func isIdentifierish(q string) bool {
for _, r := range q {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_' || r == '.' || r == '$':
default:
return false
}
}
return true
}
// mergeUnique appends nodes from `more` to `base`, dropping any node
// whose ID is already present. Preserves base order followed by
// first-seen new IDs from `more`.
func mergeUnique(base, more []*model.CodeNode) []*model.CodeNode {
seen := make(map[string]struct{}, len(base)+len(more))
for _, n := range base {
if n != nil {
seen[n.ID] = struct{}{}
}
}
out := make([]*model.CodeNode, 0, len(base)+len(more))
out = append(out, base...)
for _, n := range more {
if n == nil {
continue
}
if _, dup := seen[n.ID]; dup {
continue
}
seen[n.ID] = struct{}{}
out = append(out, n)
}
return out
}
// sortExactFirst returns nodes ordered with exact label matches (case-
// insensitive) first, then partial matches in their input order.
// Mirrors Java TopologyService.findNode where the exact bucket is built
// first and the partial bucket appended afterward.
func sortExactFirst(nodes []*model.CodeNode, query string) []*model.CodeNode {
lower := strings.ToLower(query)
out := append([]*model.CodeNode(nil), nodes...)
sort.SliceStable(out, func(i, j int) bool {
ai := exactRank(out[i], lower)
aj := exactRank(out[j], lower)
return ai < aj
})
return out
}
// exactRank returns 0 for exact label match, 1 otherwise — used as the
// sort key by sortExactFirst.
func exactRank(n *model.CodeNode, lowerQuery string) int {
if n == nil {
return 2
}
if strings.EqualFold(n.Label, lowerQuery) {
return 0
}
return 1
}
// nodesToCompact projects a slice of nodes into the compact-map shape
// Java TopologyService.nodeToCompact emits. Used by find_node so the
// JSON envelope matches the Java side.
func nodesToCompact(nodes []*model.CodeNode) []map[string]any {
out := make([]map[string]any, 0, len(nodes))
for _, n := range nodes {
if n == nil {
continue
}
out = append(out, map[string]any{
"id": n.ID,
"kind": n.Kind.String(),
"label": n.Label,
"file_path": n.FilePath,
"layer": n.Layer.String(),
})
}
return out
}