Forge
csharp9332cf7c
1using System.Collections.Frozen;
2using System.Reflection;
3using System.Text.Json;
4using System.Text.Json.Serialization;
5using HybridCodebaseIndex.Core;
6
7namespace HybridCodebaseIndex.Mcp;
8
9internal static class ToolHandlers
10{
11 private static readonly CodebaseIndexService Service = new();
12 private static readonly IndexWatchManager Watchers = new(Service);
13
14 private static readonly JsonSerializerOptions JsonOut = new()
15 {
16 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
17 WriteIndented = false,
18 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
19 };
20
21 private static readonly JsonSerializerOptions JsonOutWithNulls = new()
22 {
23 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
24 WriteIndented = false,
25 DefaultIgnoreCondition = JsonIgnoreCondition.Never,
26 };
27
28 internal static string Handle(string name, IReadOnlyDictionary<string, JsonElement> args)
29 {
30 args ??= FrozenDictionary<string, JsonElement>.Empty;
31 return name switch
32 {
33 "codebase_index_version" => HandleVersion(),
34 "codebase_index_search" => HandleSearch(args),
35 "codebase_index_explain" => HandleExplain(args),
36 "codebase_index_status" => HandleStatus(args),
37 "codebase_index_reindex" => HandleReindex(args),
38 "codebase_index_watch" => HandleWatch(args),
39 "codebase_index_verify" => HandleVerify(args),
40 "codebase_index_draft_doc" => HandleDraftDoc(args),
41 "codebase_index_vec_reindex" => HandleVecReindex(args),
42 _ => throw new ArgumentException($"Unknown tool: {name}", nameof(name)),
43 };
44 }
45
46 private static string HandleVersion()
47 {
48 var asm = typeof(ToolHandlers).Assembly;
49 var name = asm.GetName();
50 var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
51
52 var dto = new VersionResultDto(
53 AssemblyName: name.Name ?? "unknown",
54 AssemblyVersion: name.Version?.ToString(),
55 InformationalVersion: info,
56 FrameworkDescription: System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
57 RuntimeIdentifier: System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier,
58 OsDescription: System.Runtime.InteropServices.RuntimeInformation.OSDescription,
59 ProcessArchitecture: System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString());
60
61 return JsonSerializer.Serialize(dto, JsonOut);
62 }
63
64 private static string HandleSearch(IReadOnlyDictionary<string, JsonElement> args)
65 {
66 var ws = RequireString(args, "workspace_path");
67 var sln = OptionalString(args, "solution_path");
68 var q = RequireString(args, "query");
69 var topN = OptionalInt(args, "top_n") ?? 15;
70 if (topN < 1)
71 topN = 1;
72 if (topN > 128)
73 topN = 128;
74
75 var pathPrefix = OptionalString(args, "path_prefix");
76 var excludePrefixes = OptionalStringArray(args, "exclude_path_prefixes");
77 var extensions = OptionalStringArray(args, "extensions");
78
79 var semantic = OptionalBool(args, "semantic") ?? false;
80 var alpha = OptionalDouble(args, "alpha") ?? 0.65;
81 var beta = OptionalDouble(args, "beta") ?? 0.35;
82 var vecTopK = OptionalInt(args, "vec_top_k") ?? 30;
83
84 var (response, err) = Service.SearchHybridAsync(ws, sln, q, topN, pathPrefix, excludePrefixes, extensions, semantic, alpha, beta, vecTopK).GetAwaiter().GetResult();
85 var dto = new SearchResultDto(
86 Err: err,
87 IndexFormatVersion: response.IndexFormatVersion,
88 Query: response.Query,
89 DatabasePath: response.DatabasePath,
90 Hits: response.Hits.Select(static h => new HitDto(h.HitId, h.Path, h.Extension, h.HitKind, h.RankScore, h.FtsScore, h.VecScore, h.Snippet, h.LineStart, h.LineEnd, h.ChunkCharCount, h.LastWriteUtcIso)).ToList());
91
92 return JsonSerializer.Serialize(dto, JsonOut);
93 }
94
95 private static string HandleExplain(IReadOnlyDictionary<string, JsonElement> args)
96 {
97 var ws = RequireString(args, "workspace_path");
98 var sln = OptionalString(args, "solution_path");
99 var hitId = RequireLong(args, "hit_id");
100
101 var resp = Service.ExplainHitAsync(ws, sln, hitId).GetAwaiter().GetResult();
102 var dto = new ExplainResultDto(
103 Err: resp.Err,
104 IndexFormatVersion: resp.IndexFormatVersion,
105 DatabasePath: resp.DatabasePath,
106 Hit: resp.Hit is null
107 ? null
108 : new HitDto(resp.Hit.HitId, resp.Hit.Path, resp.Hit.Extension, resp.Hit.HitKind, resp.Hit.RankScore, resp.Hit.FtsScore, resp.Hit.VecScore, resp.Hit.Snippet, resp.Hit.LineStart, resp.Hit.LineEnd, resp.Hit.ChunkCharCount, resp.Hit.LastWriteUtcIso));
109
110 return JsonSerializer.Serialize(dto, JsonOut);
111 }
112
113 private static string HandleStatus(IReadOnlyDictionary<string, JsonElement> args)
114 {
115 var ws = RequireString(args, "workspace_path");
116 var sln = OptionalString(args, "solution_path");
117 var st = Service.GetStatusAsync(ws, sln).GetAwaiter().GetResult();
118 var dto = new StatusResultDto(
119 IndexFormatVersion: st.IndexFormatVersion,
120 DatabasePath: st.DatabasePath,
121 DatabaseExists: st.DatabaseExists,
122 DocumentCount: st.DocumentCount,
123 DocumentCountMayBeStale: st.DocumentCountMayBeStale,
124 IndexedAtIso: st.IndexedAtIso,
125 WorkspaceRoot: st.WorkspaceRootNormalized,
126 LastReindexError: st.LastReindexError,
127 LastReindexErrorAtIso: st.LastReindexErrorAtIso,
128 SettingsSource: st.SettingsSource,
129 SettingsParseError: st.SettingsParseError,
130 EffectiveSettings: st.EffectiveSettings is null
131 ? null
132 : new EffectiveSettingsDto(
133 st.EffectiveSettings.IncludeCsInFts,
134 st.EffectiveSettings.ExtraIncludeRoots.ToList(),
135 st.EffectiveSettings.ExcludeRoots.ToList(),
136 st.EffectiveSettings.EffectiveExtensions.ToList(),
137 st.EffectiveSettings.ExcludePathSegments.ToList(),
138 st.EffectiveSettings.IgnoreFiles.ToList(),
139 st.EffectiveSettings.MaxIndexedFileBytes,
140 st.EffectiveSettings.ChunkLines,
141 st.EffectiveSettings.ChunkOverlapLines,
142 st.EffectiveSettings.BinaryProbeBytes),
143 ReindexState: st.ReindexState,
144 ReindexStartedAtIso: st.ReindexStartedAtIso);
145
146 // Keep the contract stable: include nullable fields explicitly.
147 return JsonSerializer.Serialize(dto, JsonOutWithNulls);
148 }
149
150 private static string HandleReindex(IReadOnlyDictionary<string, JsonElement> args)
151 {
152 var ws = RequireString(args, "workspace_path");
153 var sln = OptionalString(args, "solution_path");
154 var full = OptionalBool(args, "full_rebuild") ?? false;
155 var summary = full
156 ? Service.FullRebuildAsync(ws, sln).GetAwaiter().GetResult()
157 : Service.FullReindexAsync(ws, sln).GetAwaiter().GetResult();
158 var dto = new ReindexResultDto(
159 IndexFormatVersion: summary.IndexFormatVersion,
160 DatabasePath: summary.DatabasePath,
161 FilesIndexed: summary.FilesIndexed,
162 FilesSkippedTooLarge: summary.FilesSkippedTooLarge,
163 FilesSkippedBinary: summary.FilesSkippedBinary,
164 FilesSkippedExcluded: summary.FilesSkippedExcluded,
165 SkippedReasonCounts: summary.SkippedReasonCounts,
166 SkippedTopPathPrefixes: summary.SkippedTopPathPrefixes.Select(static p => new TopPrefixDto(p.PathPrefix, p.Count)).ToList(),
167 SkippedSample: summary.SkippedSample.Select(static s => new SkippedDto(s.Path, s.Reason)).ToList(),
168 DurationMs: (long)summary.Duration.TotalMilliseconds);
169
170 return JsonSerializer.Serialize(dto, JsonOut);
171 }
172
173 private static string HandleWatch(IReadOnlyDictionary<string, JsonElement> args)
174 {
175 var ws = RequireString(args, "workspace_path");
176 var sln = OptionalString(args, "solution_path");
177 var enabled = OptionalBool(args, "enabled") ?? false;
178 var debounceMs = OptionalInt(args, "debounce_ms") ?? 750;
179 if (debounceMs < 50)
180 debounceMs = 50;
181 if (debounceMs > 60_000)
182 debounceMs = 60_000;
183
184 Watchers.SetEnabled(ws, sln, enabled, debounceMs);
185
186 return JsonSerializer.Serialize(new
187 {
188 workspacePath = ws,
189 solutionPath = sln,
190 enabled,
191 debounceMs,
192 }, JsonOut);
193 }
194
195 private static string HandleVerify(IReadOnlyDictionary<string, JsonElement> args)
196 {
197 var ws = RequireString(args, "workspace_path");
198 var sln = OptionalString(args, "solution_path");
199 var ids = RequireStringArray(args, "identifiers");
200
201 var topN = OptionalInt(args, "top_n") ?? 5;
202 if (topN < 1) topN = 1;
203 if (topN > 25) topN = 25;
204
205 var suggestions = OptionalInt(args, "suggestions") ?? 8;
206 if (suggestions < 0) suggestions = 0;
207 if (suggestions > 25) suggestions = 25;
208
209 var pathPrefix = OptionalString(args, "path_prefix");
210 var excludePrefixes = OptionalStringArray(args, "exclude_path_prefixes");
211 var extensions = OptionalStringArray(args, "extensions") ?? [".cs"];
212
213 static string NormalizeId(string s)
214 {
215 // Best-effort token for FTS: strip obvious punctuation so prefix search works better.
216 // Keep dots/underscores because they're meaningful in namespaces/member access for humans.
217 return s.Trim();
218 }
219
220 static string PrefixToken(string id)
221 {
222 // Take the last segment (Foo.Bar.Baz -> Baz) for suggestions.
223 var lastDot = id.LastIndexOf('.');
224 var token = lastDot >= 0 ? id[(lastDot + 1)..] : id;
225 token = token.Trim();
226 if (token.Length > 24)
227 token = token[..24];
228 return token;
229 }
230
231 var results = new List<VerifyItemDto>(capacity: ids.Count);
232
233 foreach (var raw in ids)
234 {
235 var id = NormalizeId(raw);
236 if (string.IsNullOrWhiteSpace(id))
237 continue;
238
239 var (resp, err) = Service.SearchAsync(ws, sln, id, topN, pathPrefix, excludePrefixes, extensions).GetAwaiter().GetResult();
240 if (!string.IsNullOrEmpty(err))
241 {
242 results.Add(new VerifyItemDto(id, Exists: false, MatchKind: "strict", Segment: null, Err: err, Hits: [], Suggestions: []));
243 continue;
244 }
245
246 var hits = resp.Hits
247 .Take(topN)
248 .Select(static h => new HitDto(h.HitId, h.Path, h.Extension, h.HitKind, h.RankScore, h.FtsScore, h.VecScore, h.Snippet, h.LineStart, h.LineEnd, h.ChunkCharCount, h.LastWriteUtcIso))
249 .ToList();
250
251 if (hits.Count > 0)
252 {
253 results.Add(new VerifyItemDto(id, Exists: true, MatchKind: "strict", Segment: null, Err: null, Hits: hits, Suggestions: []));
254 continue;
255 }
256
257 var seg = PrefixToken(id);
258 if (seg.Length > 0 && !string.Equals(seg, id, StringComparison.Ordinal))
259 {
260 var (segResp, segErr) = Service.SearchAsync(ws, sln, seg, topN, pathPrefix, excludePrefixes, extensions).GetAwaiter().GetResult();
261 if (string.IsNullOrEmpty(segErr) && segResp.Hits.Count > 0)
262 {
263 var segHits = segResp.Hits
264 .Take(topN)
265 .Select(static h => new HitDto(h.HitId, h.Path, h.Extension, h.HitKind, h.RankScore, h.FtsScore, h.VecScore, h.Snippet, h.LineStart, h.LineEnd, h.ChunkCharCount, h.LastWriteUtcIso))
266 .ToList();
267 results.Add(new VerifyItemDto(id, Exists: true, MatchKind: "segment", Segment: seg, Err: null, Hits: segHits, Suggestions: []));
268 continue;
269 }
270 }
271
272 if (suggestions == 0)
273 {
274 results.Add(new VerifyItemDto(id, Exists: false, MatchKind: "strict", Segment: null, Err: null, Hits: [], Suggestions: []));
275 continue;
276 }
277
278 var token = PrefixToken(id);
279 var q = token.Length == 0 ? id : $"{token}*";
280 var (sugResp, sugErr) = Service.SearchAsync(ws, sln, q, Math.Max(suggestions, 5), pathPrefix, excludePrefixes, extensions).GetAwaiter().GetResult();
281 if (!string.IsNullOrEmpty(sugErr))
282 {
283 results.Add(new VerifyItemDto(id, Exists: false, MatchKind: "strict", Segment: null, Err: null, Hits: [], Suggestions: []));
284 continue;
285 }
286
287 var sug = sugResp.Hits
288 .Take(suggestions)
289 .Select(static h => new HitDto(h.HitId, h.Path, h.Extension, h.HitKind, h.RankScore, h.FtsScore, h.VecScore, h.Snippet, h.LineStart, h.LineEnd, h.ChunkCharCount, h.LastWriteUtcIso))
290 .ToList();
291
292 results.Add(new VerifyItemDto(id, Exists: false, MatchKind: "strict", Segment: null, Err: null, Hits: [], Suggestions: sug));
293 }
294
295 return JsonSerializer.Serialize(new VerifyResultDto(
296 WorkspacePath: ws,
297 SolutionPath: sln,
298 Extensions: extensions,
299 PathPrefix: pathPrefix,
300 ExcludePathPrefixes: excludePrefixes,
301 TopN: topN,
302 Suggestions: suggestions,
303 Items: results), JsonOut);
304 }
305
306 private static string HandleDraftDoc(IReadOnlyDictionary<string, JsonElement> args)
307 {
308 var ws = RequireString(args, "workspace_path");
309 var sln = OptionalString(args, "solution_path");
310 var title = RequireString(args, "title");
311 var paths = RequireStringArray(args, "changed_paths");
312
313 var resp = Service.DraftDocAsync(ws, sln, title, paths).GetAwaiter().GetResult();
314 return JsonSerializer.Serialize(new DraftDocDto(
315 Err: resp.Err,
316 IndexFormatVersion: resp.IndexFormatVersion,
317 DatabasePath: resp.DatabasePath,
318 Title: resp.Title,
319 Markdown: resp.Markdown), JsonOut);
320 }
321
322 private static string RequireString(IReadOnlyDictionary<string, JsonElement> args, string key)
323 {
324 if (!args.TryGetValue(key, out var el) || el.ValueKind != JsonValueKind.String)
325 throw new ArgumentException($"Missing or invalid '{key}' (string required).");
326
327 var s = el.GetString();
328 if (string.IsNullOrWhiteSpace(s))
329 throw new ArgumentException($"Missing or invalid '{key}' (string required).");
330
331 return s.Trim();
332 }
333
334 private static string? OptionalString(IReadOnlyDictionary<string, JsonElement> args, string key)
335 {
336 if (!args.TryGetValue(key, out var el) || el.ValueKind != JsonValueKind.String)
337 return null;
338 var s = el.GetString();
339 return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
340 }
341
342 private static int? OptionalInt(IReadOnlyDictionary<string, JsonElement> args, string key)
343 {
344 if (!args.TryGetValue(key, out var el))
345 return null;
346 return el.TryGetInt32(out var i) ? i : null;
347 }
348
349 private static List<string>? OptionalStringArray(IReadOnlyDictionary<string, JsonElement> args, string key)
350 {
351 if (!args.TryGetValue(key, out var el) || el.ValueKind != JsonValueKind.Array)
352 return null;
353
354 var list = new List<string>();
355 foreach (var it in el.EnumerateArray())
356 {
357 if (it.ValueKind != JsonValueKind.String)
358 continue;
359 var s = it.GetString();
360 if (!string.IsNullOrWhiteSpace(s))
361 list.Add(s.Trim());
362 }
363 return list.Count == 0 ? null : list;
364 }
365
366 private static List<string> RequireStringArray(IReadOnlyDictionary<string, JsonElement> args, string key)
367 {
368 if (!args.TryGetValue(key, out var el) || el.ValueKind != JsonValueKind.Array)
369 throw new ArgumentException($"Missing or invalid '{key}' (string[] required).");
370
371 var list = new List<string>();
372 foreach (var it in el.EnumerateArray())
373 {
374 if (it.ValueKind != JsonValueKind.String)
375 continue;
376 var s = it.GetString();
377 if (!string.IsNullOrWhiteSpace(s))
378 list.Add(s.Trim());
379 }
380
381 if (list.Count == 0)
382 throw new ArgumentException($"Missing or invalid '{key}' (string[] required).");
383
384 return list;
385 }
386
387 private static bool? OptionalBool(IReadOnlyDictionary<string, JsonElement> args, string key)
388 {
389 if (!args.TryGetValue(key, out var el))
390 return null;
391 return el.ValueKind == JsonValueKind.True ? true
392 : el.ValueKind == JsonValueKind.False ? false
393 : null;
394 }
395
396 private static long RequireLong(IReadOnlyDictionary<string, JsonElement> args, string key)
397 {
398 if (!args.TryGetValue(key, out var el))
399 throw new ArgumentException($"Missing or invalid '{key}' (integer required).");
400
401 if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v))
402 return v;
403
404 throw new ArgumentException($"Missing or invalid '{key}' (integer required).");
405 }
406
407 private sealed record SearchResultDto(
408 string? Err,
409 int IndexFormatVersion,
410 string Query,
411 string DatabasePath,
412 List<HitDto> Hits);
413
414 private sealed record HitDto(
415 long HitId,
416 string Path,
417 string Extension,
418 string HitKind,
419 double RankScore,
420 double? FtsScore,
421 double? VecScore,
422 string? Snippet,
423 int LineStart,
424 int LineEnd,
425 int ChunkCharCount,
426 string? LastWriteUtcIso);
427
428 private sealed record ExplainResultDto(
429 string? Err,
430 int IndexFormatVersion,
431 string DatabasePath,
432 HitDto? Hit);
433
434 private sealed record StatusResultDto(
435 int IndexFormatVersion,
436 string DatabasePath,
437 bool DatabaseExists,
438 int DocumentCount,
439 bool DocumentCountMayBeStale,
440 string? IndexedAtIso,
441 string? WorkspaceRoot,
442 string? LastReindexError,
443 string? LastReindexErrorAtIso,
444 string SettingsSource,
445 string? SettingsParseError,
446 EffectiveSettingsDto? EffectiveSettings,
447 string? ReindexState,
448 string? ReindexStartedAtIso);
449
450 private sealed record EffectiveSettingsDto(
451 bool IncludeCsInFts,
452 List<string> ExtraIncludeRoots,
453 List<string> ExcludeRoots,
454 List<string> EffectiveExtensions,
455 List<string> ExcludePathSegments,
456 List<string> IgnoreFiles,
457 long MaxIndexedFileBytes,
458 int ChunkLines,
459 int ChunkOverlapLines,
460 int BinaryProbeBytes);
461
462 private sealed record ReindexResultDto(
463 int IndexFormatVersion,
464 string DatabasePath,
465 int FilesIndexed,
466 int FilesSkippedTooLarge,
467 int FilesSkippedBinary,
468 int FilesSkippedExcluded,
469 IReadOnlyDictionary<string, int> SkippedReasonCounts,
470 List<TopPrefixDto> SkippedTopPathPrefixes,
471 List<SkippedDto> SkippedSample,
472 long DurationMs);
473
474 private sealed record TopPrefixDto(
475 string PathPrefix,
476 int Count);
477
478 private sealed record SkippedDto(
479 string Path,
480 string Reason);
481
482 private sealed record VersionResultDto(
483 string AssemblyName,
484 string? AssemblyVersion,
485 string? InformationalVersion,
486 string FrameworkDescription,
487 string RuntimeIdentifier,
488 string OsDescription,
489 string ProcessArchitecture);
490
491 private sealed record VerifyResultDto(
492 string WorkspacePath,
493 string? SolutionPath,
494 List<string> Extensions,
495 string? PathPrefix,
496 List<string>? ExcludePathPrefixes,
497 int TopN,
498 int Suggestions,
499 List<VerifyItemDto> Items);
500
501 private sealed record VerifyItemDto(
502 string Identifier,
503 bool Exists,
504 string MatchKind,
505 string? Segment,
506 string? Err,
507 List<HitDto> Hits,
508 List<HitDto> Suggestions);
509
510 private sealed record DraftDocDto(
511 string? Err,
512 int IndexFormatVersion,
513 string DatabasePath,
514 string Title,
515 string Markdown);
516
517 private static string HandleVecReindex(IReadOnlyDictionary<string, JsonElement> args)
518 {
519 var ws = RequireString(args, "workspace_path");
520 var sln = OptionalString(args, "solution_path");
521 var (count, err) = Service.ReindexVectorsAsync(ws, sln).GetAwaiter().GetResult();
522 return JsonSerializer.Serialize(new
523 {
524 workspacePath = ws,
525 solutionPath = sln,
526 vectorsUpserted = count,
527 err,
528 }, JsonOut);
529 }
530
531 private static double? OptionalDouble(IReadOnlyDictionary<string, JsonElement> args, string key)
532 {
533 if (!args.TryGetValue(key, out var el))
534 return null;
535 if (el.ValueKind == JsonValueKind.Number && el.TryGetDouble(out var d))
536 return d;
537 return null;
538 }
539}
540
View only · write via MCP/CIDE