From 58c86ef50dae6c9eff7febae9ca6233acc6fedf6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 11 Jun 2026 23:30:54 +0900 Subject: [PATCH 1/8] Add MCP language filters (#3540) --- changelog.d/unreleased/3540.added.md | 18 +++ src/CodeIndex/Mcp/McpServer.cs | 2 +- src/CodeIndex/Mcp/McpToolDefinitions.cs | 10 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 139 ++++++++++++++++++++---- tests/CodeIndex.Tests/McpServerTests.cs | 31 ++++++ 5 files changed, 175 insertions(+), 25 deletions(-) create mode 100644 changelog.d/unreleased/3540.added.md diff --git a/changelog.d/unreleased/3540.added.md b/changelog.d/unreleased/3540.added.md new file mode 100644 index 0000000000..e4b670c649 --- /dev/null +++ b/changelog.d/unreleased/3540.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 3540 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP `languages` now supports CLI-compatible filters (#3540)** — `languages` accepts `indexedOnly`, `capability`, `extension`, and `alias` arguments so agents can discover supported and indexed languages the same way CLI users do. + +## 日本語 + +- **MCP `languages` が CLI 互換のフィルタに対応しました (#3540)** — `languages` は `indexedOnly`、`capability`、`extension`、`alias` 引数を受け付け、エージェントが CLI と同じ基準で対応言語とインデックス済み言語を確認できるようになりました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 3f3629b697..2645544832 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -2692,7 +2692,7 @@ JsonObject CreateUnknownToolResponseForMetrics() "batch_query" => ExecuteBatchQuery(id, args), "deps" => ExecuteDeps(id, args), "impact_analysis" => ExecuteImpactAnalysis(id, args), - "languages" => ExecuteLanguages(id), + "languages" => ExecuteLanguages(id, args), "validate" => ExecuteValidate(id, args), "unused_symbols" => ExecuteUnusedSymbols(id, args), "symbol_hotspots" => ExecuteSymbolHotspots(id, args), diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 31958f131e..97d8219f7c 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -356,11 +356,17 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "languages", - "List all supported languages with their file extensions and capabilities (symbol extraction, call-graph queries). No database required. / 対応言語一覧を拡張子・機能(シンボル抽出、コールグラフ対応)付きで返す。DB不要。", + "List supported languages with extensions, aliases, and capabilities. Use `indexedOnly`, `capability`, `extension`, or `alias` to match CLI language filters and extension lookup. / 対応言語一覧を拡張子・別名・機能付きで返す。`indexedOnly` / `capability` / `extension` / `alias` で CLI の言語フィルタと拡張子 lookup に合わせて絞り込める。", new JsonObject { ["type"] = "object", - ["properties"] = new JsonObject() + ["properties"] = new JsonObject + { + ["indexedOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only languages currently present in the index. Requires the configured database.", ["default"] = false }, + ["capability"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "symbols", "graph", "references" } }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "symbols", "graph", "references" } } } }, ["description"] = "Filter by language capability. `graph` and `references` both require call-graph/reference extraction support. Accepts a single value or an array; all requested capabilities must match." }, + ["extension"] = new JsonObject { ["type"] = "string", ["description"] = "Look up languages by file extension. Accepts `cs` or `.cs` style values." }, + ["alias"] = new JsonObject { ["type"] = "string", ["description"] = "Look up languages by canonical language name or CLI language alias, e.g. `cs` for `csharp`." } + } }, ReadOnlyAnnotations()), CreateToolDefinition( diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 409970d778..bd0d057c74 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -30,6 +30,9 @@ public partial class McpServer "category", "format", "groupBy", + "alias", + "capability", + "extension", "kind", "lang", "language", @@ -420,6 +423,22 @@ private static List ReadStringList(JsonNode? args, string propertyName) : []; } + private static List ReadStringOrArrayList(JsonNode? args, string propertyName) + { + var node = args?[propertyName]; + if (node is JsonArray array) + { + return array.Select(item => item is JsonValue value && value.TryGetValue(out var text) ? text : null) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .ToList(); + } + + return node is JsonValue scalar && scalar.TryGetValue(out var scalarText) && !string.IsNullOrWhiteSpace(scalarText) + ? [scalarText] + : []; + } + private JsonNode? TryReadSearchGuardFilters(JsonNode? id, JsonNode? args, out List filters) { filters = []; @@ -478,7 +497,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) private static JsonObject? ValidateCommonListArguments(JsonNode? args) { - foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections" }) + foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability" }) { if (ValidateStringListArgument(args, propertyName) is JsonObject error) return error; @@ -641,14 +660,14 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "focusLine" or "focusColumn" or "focusLength" or "startLine" or "endLine" or "maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" or "guardWindow" or "maxOutputBytes" => "integer", - "excludeTests" or "includeGenerated" or "rawQuery" or "noDedup" or "exactSubstring" or + "excludeTests" or "includeGenerated" or "indexedOnly" or "rawQuery" or "noDedup" or "exactSubstring" or "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or "optimize" or "reverse" or "cycles" => "boolean", - "project" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", + "project" or "capability" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "solution" or "symbol" or "groupBy" or "category" or "language" or - "bucket" or "minConfidence" or "description" or "context" or "toolInvocationContext" or "db" => "string", + "bucket" or "minConfidence" or "extension" or "alias" or "description" or "context" or "toolInvocationContext" or "db" => "string", "queries" or "evidencePaths" or "evidence_paths" => "array", _ => string.Empty, }; @@ -719,6 +738,7 @@ private static string DescribeJsonType(JsonNode? node) "batch_query" => new HashSet(StringComparer.Ordinal) { "queries" }, "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, + "languages" => new HashSet(StringComparer.Ordinal) { "indexedOnly", "capability", "extension", "alias" }, "validate" => new HashSet(StringComparer.Ordinal) { "kind", "path", "excludePaths", "excludeTests", "project", "solution" }, "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "project", "solution" }, "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, @@ -2946,7 +2966,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg "outline" => ExecuteOutline(null, toolArgs), "deps" => ExecuteDeps(null, toolArgs), "impact_analysis" => ExecuteImpactAnalysis(null, toolArgs), - "languages" => ExecuteLanguages(null), + "languages" => ExecuteLanguages(null, toolArgs), "validate" => ExecuteValidate(null, toolArgs), "unused_symbols" => ExecuteUnusedSymbols(null, toolArgs), "symbol_hotspots" => ExecuteSymbolHotspots(null, toolArgs), @@ -3677,11 +3697,29 @@ private JsonNode ExecutePing(JsonNode? id) return CreateToolResult(id, $"cdidx v{_version} is ready.", payload); } - private JsonNode ExecuteLanguages(JsonNode? id) + private JsonNode ExecuteLanguages(JsonNode? id, JsonNode? args) { var langExtensions = FileIndexer.GetLanguageExtensions(); var symbolLangs = SymbolExtractor.GetSupportedLanguages(); var graphLangs = ReferenceExtractor.GetSupportedLanguages(); + var indexedOnly = args?["indexedOnly"]?.GetValue() ?? false; + var capabilities = ReadStringOrArrayList(args, "capability") + .Select(value => value.Trim().ToLowerInvariant()) + .ToList(); + var extensionFilter = args?["extension"]?.GetValue()?.Trim(); + var normalizedExtension = string.IsNullOrWhiteSpace(extensionFilter) + ? null + : extensionFilter.StartsWith(".", StringComparison.Ordinal) ? extensionFilter : "." + extensionFilter; + var aliasFilter = QueryCommandRunner.NormalizeLangFilterValue(args?["alias"]?.GetValue()); + + if (args?["capability"] is JsonArray capabilityArray && capabilities.Count != capabilityArray.Count) + return CreateToolErrorResponse(id, "capability entries must be non-empty strings."); + + foreach (var capability in capabilities) + { + if (!IsKnownLanguageCapability(capability)) + return CreateToolErrorResponse(id, $"Invalid language capability '{capability}'. Use one of: symbols, graph, references."); + } // Build consolidated language info / 統合言語情報を構築 var allLangs = new Dictionary Extensions, List Aliases, bool Symbols, bool Graph)>(StringComparer.Ordinal); @@ -3695,29 +3733,86 @@ private JsonNode ExecuteLanguages(JsonNode? id) info.Extensions.Add(ext); } - var sorted = allLangs.OrderBy(kv => kv.Key).ToList(); - var languagesArray = new JsonArray(); - foreach (var (lang, info) in sorted) + JsonNode BuildResponse(HashSet? indexedLanguages) { - var extArray = new JsonArray(); - foreach (var ext in info.Extensions.OrderBy(e => e)) - extArray.Add(ext); + var sorted = allLangs + .Where(kv => !indexedOnly || indexedLanguages?.Contains(kv.Key) == true) + .Where(kv => capabilities.All(capability => LanguageMatchesCapability(kv.Value.Symbols, kv.Value.Graph, capability))) + .Where(kv => normalizedExtension is null || kv.Value.Extensions.Contains(normalizedExtension, StringComparer.OrdinalIgnoreCase)) + .Where(kv => aliasFilter is null + || string.Equals(kv.Key, aliasFilter, StringComparison.OrdinalIgnoreCase) + || kv.Value.Aliases.Contains(aliasFilter, StringComparer.OrdinalIgnoreCase)) + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .ToList(); - languagesArray.Add(new JsonObject + var languagesArray = new JsonArray(); + foreach (var (lang, info) in sorted) { - ["lang"] = lang, - ["extensions"] = extArray, - ["aliases"] = new JsonArray(info.Aliases.OrderBy(alias => alias).Select(alias => JsonValue.Create(alias)).ToArray()), - ["symbol_extraction"] = info.Symbols, - ["graph_queries"] = info.Graph, - }); + var extArray = new JsonArray(); + foreach (var ext in info.Extensions.OrderBy(e => e, StringComparer.Ordinal)) + extArray.Add(ext); + + languagesArray.Add(new JsonObject + { + ["lang"] = lang, + ["extensions"] = extArray, + ["aliases"] = new JsonArray(info.Aliases.OrderBy(alias => alias, StringComparer.Ordinal).Select(alias => JsonValue.Create(alias)).ToArray()), + ["symbol_extraction"] = info.Symbols, + ["graph_queries"] = info.Graph, + }); + } + + var payload = new JsonObject + { + ["languages"] = languagesArray, + ["filters"] = new JsonObject + { + ["indexedOnly"] = indexedOnly, + ["capability"] = new JsonArray(capabilities.Select(capability => JsonValue.Create(capability)).ToArray()), + ["extension"] = normalizedExtension, + ["alias"] = aliasFilter, + }, + }; + if (normalizedExtension is not null) + { + payload["extension_lookup"] = new JsonObject + { + ["extension"] = normalizedExtension, + ["matched"] = sorted.Count, + ["languages"] = new JsonArray(sorted.Select(kv => JsonValue.Create(kv.Key)).ToArray()), + }; + } + if (aliasFilter is not null) + { + payload["alias_lookup"] = new JsonObject + { + ["alias"] = aliasFilter, + ["matched"] = sorted.Count, + ["languages"] = new JsonArray(sorted.Select(kv => JsonValue.Create(kv.Key)).ToArray()), + }; + } + + var summary = $"{sorted.Count} languages supported. {symbolLangs.Count} with symbol extraction, {graphLangs.Count} with call-graph queries."; + return CreateToolResult(id, summary, payload); } - var payload = new JsonObject { ["languages"] = languagesArray }; - var summary = $"{sorted.Count} languages supported. {symbolLangs.Count} with symbol extraction, {graphLangs.Count} with call-graph queries."; - return CreateToolResult(id, summary, payload); + if (!indexedOnly) + return BuildResponse(null); + + return WithDbReader(id, args, reader => BuildResponse(new HashSet(reader.GetStatus().Languages.Keys, StringComparer.Ordinal))); } + private static bool IsKnownLanguageCapability(string capability) => + capability is "symbols" or "graph" or "references"; + + private static bool LanguageMatchesCapability(bool symbols, bool graph, string capability) => + capability switch + { + "symbols" => symbols, + "graph" or "references" => graph, + _ => false, + }; + private JsonNode ExecuteIndex(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) => ExecuteIndexAsync(id, args, progressToken).GetAwaiter().GetResult(); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 798a93e3f6..0831e77cca 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -8814,6 +8814,37 @@ public void ToolsCall_Languages_ReturnsCapabilities() Assert.Contains(".shtml", htmlExtensions); } + [Fact] + public void ToolsCall_Languages_FiltersByCliCompatibleMetadata_Issue3540() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"languages","arguments":{"capability":["graph","references"],"extension":"cs","alias":"cs"}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + var languages = structured["languages"]!.AsArray(); + var language = Assert.Single(languages)!; + Assert.Equal("csharp", language["lang"]!.GetValue()); + Assert.True(language["graph_queries"]!.GetValue()); + Assert.Contains(".cs", language["extensions"]!.AsArray().Select(e => e!.GetValue())); + Assert.Equal(".cs", structured["filters"]!["extension"]!.GetValue()); + Assert.Equal(2, structured["filters"]!["capability"]!.AsArray().Count); + Assert.Equal(1, structured["extension_lookup"]!["matched"]!.GetValue()); + Assert.Equal("csharp", Assert.Single(structured["extension_lookup"]!["languages"]!.AsArray())!.GetValue()); + Assert.Equal(1, structured["alias_lookup"]!["matched"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Languages_IndexedOnlyUsesDatabaseLanguages_Issue3540() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"languages","arguments":{"indexedOnly":true}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.True(structured["filters"]!["indexedOnly"]!.GetValue()); + var language = Assert.Single(structured["languages"]!.AsArray())!; + Assert.Equal("csharp", language["lang"]!.GetValue()); + } + [Fact] public void ToolsCall_Outline_ReturnsSymbols() { From 10d69832f9b2b651ec435a22b40803fb35ef3525 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 11 Jun 2026 23:53:46 +0900 Subject: [PATCH 2/8] Expose MCP status and validate health views (#3541) --- changelog.d/unreleased/3541.added.md | 18 ++ src/CodeIndex/Mcp/McpServer.cs | 2 +- src/CodeIndex/Mcp/McpToolDefinitions.cs | 20 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 319 +++++++++++++++++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 78 ++++++ 5 files changed, 422 insertions(+), 15 deletions(-) create mode 100644 changelog.d/unreleased/3541.added.md diff --git a/changelog.d/unreleased/3541.added.md b/changelog.d/unreleased/3541.added.md new file mode 100644 index 0000000000..86492f7abf --- /dev/null +++ b/changelog.d/unreleased/3541.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 3541 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP `status` and `validate` now expose CLI-style health-check views (#3541)** — `status` can run scoped freshness/readiness checks with compact diagnostics, and `validate` supports severity filters plus count and compact output modes. + +## 日本語 + +- **MCP `status` と `validate` が CLI 風の health-check view に対応しました (#3541)** — `status` は scope 指定の freshness/readiness check と compact diagnostics を返せるようになり、`validate` は severity filter と count / compact 出力に対応しました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 2645544832..6085cd2c65 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -2687,7 +2687,7 @@ JsonObject CreateUnknownToolResponseForMetrics() "excerpt" => ExecuteExcerpt(id, args), "map" => ExecuteMap(id, args), "analyze_symbol" => ExecuteAnalyzeSymbol(id, args), - "status" => ExecuteStatus(id), + "status" => ExecuteStatus(id, args), "outline" => ExecuteOutline(id, args), "batch_query" => ExecuteBatchQuery(id, args), "deps" => ExecuteDeps(id, args), diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 97d8219f7c..cf4b464428 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -315,11 +315,21 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "status", - "Get database statistics: file count, chunk count, symbol count, reference count, and language breakdown. / DB統計情報を取得:ファイル数、チャンク数、シンボル数、参照数、言語別内訳。", + "Get database statistics, readiness state, and optional CLI-style freshness checks. Use `check`, `scopes`, `staleAfterSeconds`, `explain`, `config`, `logPath`, or `format` for bounded health-check views. / DB統計、readiness 状態、必要に応じて CLI 風の freshness check を取得。`check` / `scopes` / `staleAfterSeconds` / `explain` / `config` / `logPath` / `format` で health-check 用の出力に絞り込める。", new JsonObject { ["type"] = "object", - ["properties"] = new JsonObject() + ["properties"] = new JsonObject + { + ["check"] = new JsonObject { ["type"] = "boolean", ["description"] = "Run a workspace freshness check and populate `workspace_check`, `index_matches_workspace`, and `failed_checks`.", ["default"] = false }, + ["scopes"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "workspace", "graph", "issues", "sql", "hotspot", "csharp", "fold", "newer" } } } }, ["description"] = "Readiness scopes to evaluate for `failed_checks`. Omit to evaluate all scopes." }, + ["staleAfterSeconds"] = new JsonObject { ["type"] = "integer", ["description"] = "Effective stale-after threshold, in seconds, echoed with `index_age_seconds` when `check` is true.", ["default"] = 86400, ["minimum"] = 1 }, + ["explain"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "freshness", "readiness", "all" }, ["description"] = "Include a focused `explain` object for freshness/readiness diagnostics. `all` includes both.", ["default"] = "all" }, + ["config"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include effective MCP/CLI status configuration such as DB path, version, log dir, stale threshold, and update-check request state.", ["default"] = false }, + ["logPath"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include the resolved global tool log directory as `log_path`.", ["default"] = false }, + ["updateCheck"] = new JsonObject { ["type"] = "boolean", ["description"] = "Run the same update check as CLI status. Defaults to false because it may perform network I/O.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "compact" }, ["description"] = "Response shape. `compact` returns counts, freshness, readiness, and requested diagnostics without full language/kind tables.", ["default"] = "full" } + } }, ReadOnlyAnnotations()), CreateToolDefinition( @@ -378,9 +388,13 @@ private JsonNode HandleToolsList(JsonNode? id) ["properties"] = new JsonObject { ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by issue kind (replacement_char, bom, null_byte, mixed_line_endings, mixed_line_endings_three_way, cr_only_line_endings, utf16_bom, non_utf8_likely, line_too_long)" }, + ["severity"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "error", "warning", "info" }, ["description"] = "Filter by issue severity." }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max issues to return (default: 20).", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false } + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a top-file histogram; omit issue rows.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full issue rows, count-only metadata, or compact file/line/kind/severity rows.", ["default"] = "full" } } }, ReadOnlyAnnotations()), diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index bd0d057c74..500c929372 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -37,6 +37,7 @@ public partial class McpServer "lang", "language", "rankBy", + "severity", }; internal const int MaxMcpIndexFailureMessageLength = 512; @@ -497,7 +498,7 @@ private static List ReadStringOrArrayList(JsonNode? args, string propert private static JsonObject? ValidateCommonListArguments(JsonNode? args) { - foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability" }) + foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability", "scopes" }) { if (ValidateStringListArgument(args, propertyName) is JsonObject error) return error; @@ -659,14 +660,15 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "limit" or "offset" or "snippetLines" or "maxLineWidth" or "before" or "after" or "focusLine" or "focusColumn" or "focusLength" or "startLine" or "endLine" or "maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" or + "staleAfterSeconds" or "guardWindow" or "maxOutputBytes" => "integer", - "excludeTests" or "includeGenerated" or "indexedOnly" or "rawQuery" or "noDedup" or "exactSubstring" or + "check" or "excludeTests" or "includeGenerated" or "indexedOnly" or "rawQuery" or "noDedup" or "exactSubstring" or "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or - "optimize" or "reverse" or "cycles" => "boolean", - "project" or "capability" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", + "optimize" or "reverse" or "cycles" or "config" or "logPath" or "updateCheck" => "boolean", + "project" or "capability" or "scopes" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or - "solution" or "symbol" or "groupBy" or "category" or "language" or + "solution" or "symbol" or "groupBy" or "category" or "language" or "severity" or "explain" or "bucket" or "minConfidence" or "extension" or "alias" or "description" or "context" or "toolInvocationContext" or "db" => "string", "queries" or "evidencePaths" or "evidence_paths" => "array", _ => string.Empty, @@ -734,12 +736,13 @@ private static string DescribeJsonType(JsonNode? node) "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" }, "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "project", "solution" }, "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" }, + "status" => new HashSet(StringComparer.Ordinal) { "check", "scopes", "staleAfterSeconds", "explain", "config", "logPath", "updateCheck", "format" }, "outline" => new HashSet(StringComparer.Ordinal) { "path" }, "batch_query" => new HashSet(StringComparer.Ordinal) { "queries" }, "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, "languages" => new HashSet(StringComparer.Ordinal) { "indexedOnly", "capability", "extension", "alias" }, - "validate" => new HashSet(StringComparer.Ordinal) { "kind", "path", "excludePaths", "excludeTests", "project", "solution" }, + "validate" => new HashSet(StringComparer.Ordinal) { "kind", "severity", "limit", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution" }, "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "project", "solution" }, "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, "index" => new HashSet(StringComparer.Ordinal) { "path", "rebuild", "maxFileBytes" }, @@ -2236,13 +2239,39 @@ private static void AddHotspotFamilySignal(JsonObject payload, HotspotFamilySign } } - private JsonNode ExecuteStatus(JsonNode? id) + private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) { - return WithDbReader(id, args: null, reader => + var checkWorkspace = args?["check"]?.GetValue() ?? false; + var staleAfterSeconds = args?["staleAfterSeconds"]?.GetValue() ?? (int)TimeSpan.FromDays(1).TotalSeconds; + if (staleAfterSeconds <= 0) + return CreateToolErrorResponse(id, "staleAfterSeconds must be greater than or equal to 1"); + var explain = args?["explain"]?.GetValue()?.Trim().ToLowerInvariant(); + if (explain is not (null or "freshness" or "readiness" or "all")) + return CreateToolErrorResponse(id, "explain must be one of freshness, readiness, all"); + var format = ReadResponseFormat(args); + if (format is not ("full" or "compact")) + return CreateToolErrorResponse(id, "format must be one of full, compact"); + if (!TryReadStatusScopes(args, out var statusScopes, out var scopeError)) + return CreateToolErrorResponse(id, scopeError!); + var includeConfig = args?["config"]?.GetValue() ?? false; + var includeLogPath = args?["logPath"]?.GetValue() ?? false; + var runUpdateCheck = args?["updateCheck"]?.GetValue() ?? false; + + return WithDbReader(id, args, reader => { var status = reader.GetStatus(); WorkspaceMetadataEnricher.Enrich(status, _dbPath, _dbPathExplicit); status.MacProfile = MacProfileDetector.DetectCurrent(); + if (checkWorkspace) + { + status.WorkspaceCheck = IndexFreshnessChecker.Check(reader, status.ProjectRoot); + status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked + ? status.WorkspaceCheck.MatchesWorkspace + : null; + status.StaleAfterSeconds = staleAfterSeconds; + if (status.IndexedAt.HasValue) + status.IndexAgeSeconds = Math.Max(0, (long)Math.Round((GetUtcNow() - status.IndexedAt.Value).TotalSeconds, MidpointRounding.AwayFromZero)); + } ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(status.ProjectRoot); status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages().OrderBy(l => l).ToList(); status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(); @@ -2261,12 +2290,21 @@ private JsonNode ExecuteStatus(JsonNode? id) .ToList(); } status.Version = _version; + status.UpdateCheck = runUpdateCheck + ? UpdateChecker.Check(_version, CancellationToken.None) + : null; if (!status.FoldReady) { status.DegradedReason = DegradationReasonCodes.BuildFoldNotReadyExplanation(status.FoldReadyReason); status.RecommendedAction = BuildFoldBackfillCommand(_dbPath, _dbPathExplicit); status.AlternativeAction = BuildFoldRebuildRepairCommand(status.ProjectRoot, _dbPath, _dbPathExplicit); } + var checkFailures = checkWorkspace + ? BuildMcpStatusCheckFailures(status, statusScopes) + : []; + if (checkWorkspace) + status.FailedChecks = checkFailures.Select(failure => failure.Name).ToList(); + var structured = JsonSerializer.SerializeToNode(status, _jsonOptions)!.AsObject(); structured["project_root"] = status.ProjectRoot; structured["git_head"] = status.GitHead; @@ -2302,10 +2340,215 @@ private JsonNode ExecuteStatus(JsonNode? id) ["burst"] = RateLimiter.Options.BurstCapacity, }, }; + var effectiveConfig = includeConfig + ? BuildMcpStatusEffectiveConfig(status, staleAfterSeconds, checkWorkspace, runUpdateCheck) + : null; + var logPath = includeLogPath ? GlobalToolLog.ResolveLogDirectoryForStatus() : null; + var explainPayload = explain is null + ? null + : BuildMcpStatusExplain(status, checkFailures, explain); + if (effectiveConfig is not null) + structured["effective_config"] = effectiveConfig.DeepClone(); + if (logPath is not null) + structured["log_path"] = logPath; + if (explainPayload is not null) + structured["explain"] = explainPayload.DeepClone(); + if (format == "compact") + { + structured = BuildMcpCompactStatusPayload(status, checkFailures); + if (effectiveConfig is not null) + structured["effective_config"] = effectiveConfig; + if (logPath is not null) + structured["log_path"] = logPath; + if (explainPayload is not null) + structured["explain"] = explainPayload; + } return CreateToolResult(id, "Database stats returned.", structured); }); } + private sealed record McpStatusCheckFailure(string Name, bool IsStale, string Diagnostic); + + private static bool TryReadStatusScopes(JsonNode? args, out HashSet? scopes, out string? error) + { + scopes = null; + error = null; + if (args?["scopes"] is null) + return true; + + var values = ReadStringOrArrayList(args, "scopes") + .Select(scope => scope.Trim().ToLowerInvariant()) + .ToList(); + if (args["scopes"] is JsonArray array && values.Count != array.Count) + { + error = "scopes entries must be non-empty strings."; + return false; + } + if (values.Count == 0) + { + error = "scopes cannot be empty or whitespace-only."; + return false; + } + + scopes = new HashSet(StringComparer.Ordinal); + foreach (var value in values) + { + if (!IsKnownMcpStatusScope(value)) + { + error = $"Invalid status scope '{value}'. Use one of: workspace, graph, issues, sql, hotspot, csharp, fold, newer."; + return false; + } + scopes.Add(value); + } + return true; + } + + private static bool IsKnownMcpStatusScope(string scope) => + scope is "workspace" or "graph" or "issues" or "sql" or "hotspot" or "csharp" or "fold" or "newer"; + + private static IReadOnlyList BuildMcpStatusCheckFailures(StatusResult status, IReadOnlySet? scopes) + { + var failures = new List(); + var checkAll = scopes is not { Count: > 0 }; + bool Includes(string scope) => checkAll || scopes!.Contains(scope); + + if (Includes("workspace")) + { + if (status.WorkspaceCheck?.Checked != true) + { + failures.Add(new McpStatusCheckFailure("workspace_unavailable", true, "[stale] workspace_check unavailable")); + } + else if (!status.WorkspaceCheck.MatchesWorkspace) + { + var check = status.WorkspaceCheck; + failures.Add(new McpStatusCheckFailure( + "workspace_stale", + true, + $"[stale] workspace_check reason={check.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + } + } + + if (Includes("graph") && !status.GraphTableAvailable) + failures.Add(new McpStatusCheckFailure("graph_table_available", false, "[degraded] graph_table_available=false")); + if (Includes("issues") && !status.IssuesTableAvailable) + failures.Add(new McpStatusCheckFailure("issues_table_available", false, "[degraded] issues_table_available=false")); + if (Includes("issues") && status.IssuesTableAvailable && !status.FileIssuesDataCurrent) + failures.Add(new McpStatusCheckFailure("file_issues_data_current", false, "[degraded] file_issues_data_current=false")); + if (Includes("workspace") && status.MigrationInProgress) + failures.Add(new McpStatusCheckFailure("migration_in_progress", false, "[degraded] migration_in_progress=true")); + if (Includes("sql") && !status.SqlGraphContractReady) + failures.Add(new McpStatusCheckFailure("sql_graph_contract_ready", false, $"[degraded] sql_graph_contract_ready=false reason={status.SqlGraphContractDegradedReason ?? "unknown"}")); + if (Includes("hotspot") && !status.HotspotFamilyReady) + failures.Add(new McpStatusCheckFailure("hotspot_family_ready", false, $"[degraded] hotspot_family_ready=false reason={status.HotspotFamilyDegradedReason ?? "unknown"}")); + if (Includes("csharp") && !status.CSharpSymbolNameReady) + failures.Add(new McpStatusCheckFailure("csharp_symbol_name_ready", false, "[degraded] csharp_symbol_name_ready=false")); + if (Includes("csharp") && !status.CSharpMetadataTargetReady) + failures.Add(new McpStatusCheckFailure("csharp_metadata_target_ready", false, $"[degraded] csharp_metadata_target_ready=false reason={status.CSharpMetadataTargetDegradedReason ?? "unknown"}")); + if (Includes("fold") && !status.FoldReady) + failures.Add(new McpStatusCheckFailure("fold_ready", false, $"[degraded] fold_ready=false reason={status.FoldReadyReason ?? "unknown"}")); + if (Includes("newer") && status.IndexNewerThanReader) + failures.Add(new McpStatusCheckFailure("index_newer_than_reader", false, $"[degraded] index_newer_than_reader=true reason={status.IndexNewerThanReaderReason ?? "unknown"}")); + + return failures; + } + + private JsonObject BuildMcpStatusEffectiveConfig(StatusResult status, int staleAfterSeconds, bool checkWorkspace, bool runUpdateCheck) => new() + { + ["db_path"] = _dbPath, + ["db_explicit"] = _dbPathExplicit, + ["project_root"] = status.ProjectRoot, + ["data_dir"] = status.DataDir, + ["data_dir_source"] = status.DataDirSource, + ["global_tool_log_dir"] = GlobalToolLog.ResolveLogDirectoryForStatus(), + ["stale_after_seconds"] = staleAfterSeconds, + ["check"] = checkWorkspace, + ["update_check_requested"] = runUpdateCheck, + ["version"] = status.Version, + }; + + private JsonObject BuildMcpStatusExplain(StatusResult status, IReadOnlyList failures, string explain) + { + var payload = new JsonObject(); + if (explain is "freshness" or "all") + { + payload["freshness"] = new JsonObject + { + ["index_matches_workspace"] = status.IndexMatchesWorkspace.HasValue ? JsonValue.Create(status.IndexMatchesWorkspace.Value) : null, + ["stale_after_seconds"] = status.StaleAfterSeconds.HasValue ? JsonValue.Create(status.StaleAfterSeconds.Value) : null, + ["index_age_seconds"] = status.IndexAgeSeconds.HasValue ? JsonValue.Create(status.IndexAgeSeconds.Value) : null, + ["workspace_check"] = status.WorkspaceCheck is null ? null : JsonSerializer.SerializeToNode(status.WorkspaceCheck, _jsonOptions), + }; + } + if (explain is "readiness" or "all") + { + payload["readiness"] = BuildMcpStatusReadiness(status); + payload["failed_check_details"] = BuildMcpStatusFailureArray(failures); + } + return payload; + } + + private static JsonObject BuildMcpStatusReadiness(StatusResult status) => new() + { + ["graph_table_available"] = status.GraphTableAvailable, + ["issues_table_available"] = status.IssuesTableAvailable, + ["file_issues_data_current"] = status.FileIssuesDataCurrent, + ["sql_graph_contract_ready"] = status.SqlGraphContractReady, + ["hotspot_family_ready"] = status.HotspotFamilyReady, + ["csharp_symbol_name_ready"] = status.CSharpSymbolNameReady, + ["csharp_metadata_target_ready"] = status.CSharpMetadataTargetReady, + ["fold_ready"] = status.FoldReady, + ["index_newer_than_reader"] = status.IndexNewerThanReader, + ["migration_in_progress"] = status.MigrationInProgress, + }; + + private static JsonArray BuildMcpStatusFailureArray(IReadOnlyList failures) + { + var array = new JsonArray(); + foreach (var failure in failures) + { + array.Add(new JsonObject + { + ["name"] = failure.Name, + ["is_stale"] = failure.IsStale, + ["diagnostic"] = failure.Diagnostic, + }); + } + return array; + } + + private static JsonObject BuildMcpCompactStatusPayload(StatusResult status, IReadOnlyList failures) + { + var payload = new JsonObject + { + ["format"] = "compact", + ["summary"] = status.Summary, + ["version"] = status.Version, + ["project_root"] = status.ProjectRoot, + ["files"] = status.Files, + ["chunks"] = status.Chunks, + ["symbols"] = status.Symbols, + ["references"] = status.References, + ["language_count"] = status.Languages.Count, + ["top_languages"] = new JsonArray(status.Languages + .OrderByDescending(kv => kv.Value) + .ThenBy(kv => kv.Key, StringComparer.Ordinal) + .Take(5) + .Select(kv => new JsonObject { ["lang"] = kv.Key, ["files"] = kv.Value }) + .ToArray()), + ["git_head"] = status.GitHead, + ["git_is_dirty"] = status.GitIsDirty.HasValue ? JsonValue.Create(status.GitIsDirty.Value) : null, + ["index_matches_workspace"] = status.IndexMatchesWorkspace.HasValue ? JsonValue.Create(status.IndexMatchesWorkspace.Value) : null, + ["stale_after_seconds"] = status.StaleAfterSeconds.HasValue ? JsonValue.Create(status.StaleAfterSeconds.Value) : null, + ["index_age_seconds"] = status.IndexAgeSeconds.HasValue ? JsonValue.Create(status.IndexAgeSeconds.Value) : null, + ["failed_checks"] = new JsonArray(failures.Select(failure => JsonValue.Create(failure.Name)).ToArray()), + ["failed_check_details"] = BuildMcpStatusFailureArray(failures), + ["readiness"] = BuildMcpStatusReadiness(status), + }; + if (status.WorkspaceCheck is not null) + payload["workspace_check"] = JsonSerializer.SerializeToNode(status.WorkspaceCheck); + return payload; + } + private JsonObject BuildMcpSessionStatus() { var roots = new JsonArray(); @@ -2962,7 +3205,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg "excerpt" => ExecuteExcerpt(null, toolArgs), "map" => ExecuteMap(null, toolArgs), "analyze_symbol" => ExecuteAnalyzeSymbol(null, toolArgs), - "status" => ExecuteStatus(null), + "status" => ExecuteStatus(null, toolArgs), "outline" => ExecuteOutline(null, toolArgs), "deps" => ExecuteDeps(null, toolArgs), "impact_analysis" => ExecuteImpactAnalysis(null, toolArgs), @@ -3476,16 +3719,52 @@ private static void AddImpactFailureFields(JsonObject payload, ImpactAnalysisRes private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) { var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); + var severity = args?["severity"]?.GetValue()?.Trim().ToLowerInvariant(); + if (severity is not (null or "error" or FileIssue.SeverityWarning or FileIssue.SeverityInfo)) + return CreateToolErrorResponse(id, "severity must be one of error, warning, info"); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; var pathPatterns = ReadScopedPathList(args); return WithDbReader(id, args, reader => { - var issues = reader.GetIssues(kind, pathPatterns); + var issues = reader.GetIssues(kind, pathPatterns, countOnly ? null : FetchLimitForEnvelope(limit), severity); + var truncated = !countOnly && TrimToRequestedLimit(issues, limit); + var pathFilterArray = new JsonArray(); + if (pathPatterns is not null) + { + foreach (var path in pathPatterns) + pathFilterArray.Add(path); + } var payload = new JsonObject { ["count"] = issues.Count, - ["issues"] = JsonSerializer.SerializeToNode(issues, _jsonOptions) + ["truncated"] = truncated, + ["more_available"] = truncated, + ["filters"] = new JsonObject + { + ["kind"] = kind, + ["severity"] = severity, + ["path"] = pathFilterArray, + }, + ["top_files"] = BuildTopFileHistogram(issues, issue => issue.Path), }; + if (countOnly) + { + payload["format"] = "count"; + } + else if (format == "compact") + { + payload["format"] = "compact"; + payload["issues"] = BuildCompactValidateIssues(issues); + } + else + { + payload["issues"] = JsonSerializer.SerializeToNode(issues, _jsonOptions); + } var summary = issues.Count > 0 ? $"Found {issues.Count} encoding issue(s)." : "No encoding issues found."; @@ -3493,6 +3772,24 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) }); } + private static JsonArray BuildCompactValidateIssues(IEnumerable issues) + { + var compact = new JsonArray(); + foreach (var issue in issues) + { + compact.Add(new JsonObject + { + ["path"] = issue.Path, + ["line"] = issue.Line, + ["kind"] = issue.Kind, + ["severity"] = issue.Severity, + ["origin"] = issue.Origin, + ["message"] = issue.Message, + }); + } + return compact; + } + private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args) { var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 0831e77cca..19b1c93008 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -183,6 +183,21 @@ private void MarkFoldReady() writer.MarkCSharpSymbolNameContractReady(); } + private void InsertValidationIssues(params FileIssue[] issues) + { + var writer = new DbWriter(_db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/app.cs", + Lang = "csharp", + Size = 200, + Lines = 10, + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, + Checksum = "issues", + }); + writer.InsertIssues(fileId, issues); + } + [Fact] public void ToolsCall_SearchFormatCompactEmitsFileLineOnly_Issue1642() { @@ -7903,6 +7918,69 @@ public void ToolsCall_Status_ReturnsCounts() Assert.Equal("cdidx index . --rebuild", response["result"]!["structuredContent"]!["alternative_action"]!.GetValue()); } + [Fact] + public void ToolsCall_Status_CheckCompactExposesReadinessDiagnostics_Issue3541() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{"check":true,"scopes":["issues"],"staleAfterSeconds":60,"format":"compact","explain":"readiness","config":true,"logPath":true}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal("compact", structured["format"]!.GetValue()); + Assert.Equal(60, structured["stale_after_seconds"]!.GetValue()); + Assert.NotNull(structured["workspace_check"]); + Assert.Empty(structured["failed_checks"]!.AsArray()); + Assert.True(structured["readiness"]!["issues_table_available"]!.GetValue()); + Assert.True(structured["explain"]!["readiness"]!["issues_table_available"]!.GetValue()); + Assert.Empty(structured["explain"]!["failed_check_details"]!.AsArray()); + Assert.Equal(_dbPath, structured["effective_config"]!["db_path"]!.GetValue()); + Assert.Equal(60, structured["effective_config"]!["stale_after_seconds"]!.GetValue()); + Assert.False(structured["effective_config"]!["update_check_requested"]!.GetValue()); + Assert.False(string.IsNullOrWhiteSpace(structured["log_path"]!.GetValue())); + } + + [Fact] + public void ToolsCall_Validate_FiltersSeverityAndSupportsCompactCount_Issue3541() + { + InsertValidationIssues( + new FileIssue + { + Path = "src/app.cs", + Kind = "replacement_char", + Line = 3, + Message = "warning replacement", + Origin = FileIssue.OriginDecodeReplacement, + Severity = FileIssue.SeverityWarning, + }, + new FileIssue + { + Path = "src/app.cs", + Kind = "replacement_char", + Line = 4, + Message = "info literal", + Origin = FileIssue.OriginSourceLiteral, + Severity = FileIssue.SeverityInfo, + }); + + var compactRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"validate","arguments":{"severity":"warning","limit":1,"format":"compact"}}}""")!; + var compactResponse = _server.HandleMessage(compactRequest)!; + + var compact = compactResponse["result"]!["structuredContent"]!; + Assert.Equal("compact", compact["format"]!.GetValue()); + Assert.Equal(1, compact["count"]!.GetValue()); + var issue = Assert.Single(compact["issues"]!.AsArray())!; + Assert.Equal(FileIssue.SeverityWarning, issue["severity"]!.GetValue()); + Assert.Equal("replacement_char", issue["kind"]!.GetValue()); + Assert.Equal("src/app.cs", Assert.Single(compact["top_files"]!.AsArray())!["path"]!.GetValue()); + + var countRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"validate","arguments":{"severity":"info","countOnly":true}}}""")!; + var countResponse = _server.HandleMessage(countRequest)!; + + var count = countResponse["result"]!["structuredContent"]!; + Assert.Equal("count", count["format"]!.GetValue()); + Assert.Equal(1, count["count"]!.GetValue()); + Assert.Null(count["issues"]); + } + [Fact] public void ToolsCall_Status_ReportsDegradedHotspotFamilyTrust() { From d1bb5ff837b35110c3dfa1fc3cfd8ea95b37a1fa Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:02:06 +0900 Subject: [PATCH 3/8] Expose MCP query parity options (#3542) --- changelog.d/unreleased/3542.added.md | 17 ++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 29 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 389 +++++++++++++++++++++--- tests/CodeIndex.Tests/McpServerTests.cs | 75 +++++ 4 files changed, 458 insertions(+), 52 deletions(-) create mode 100644 changelog.d/unreleased/3542.added.md diff --git a/changelog.d/unreleased/3542.added.md b/changelog.d/unreleased/3542.added.md new file mode 100644 index 0000000000..19270bbe18 --- /dev/null +++ b/changelog.d/unreleased/3542.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 3542 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP code-query tools now expose more CLI query controls (#3542)** — search accepts `snippetFocus`, definition/symbol/unused/hotspot tools accept visibility filters, callers/callees accept `rawKinds`, files/map expose byte-oriented and entrypoint-confidence controls, and `analyze_symbol` supports count and compact response shapes. + +## 日本語 + +- **MCP の code-query tools が CLI query control を追加しました (#3542)** — search は `snippetFocus`、definition/symbol/unused/hotspot 系は visibility filter、callers/callees は `rawKinds`、files/map は byte-oriented/entrypoint confidence control、`analyze_symbol` は count / compact response shape に対応しました。 diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index cf4b464428..d648654f4d 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -30,6 +30,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated` and `more_available` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (e.g. csharp, python, javascript)" }, ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Max snippet lines per result (default: 8, max: 20)", ["default"] = 8, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, + ["snippetFocus"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "quality", "leftmost", "proximity" }, ["description"] = "Snippet anchoring mode matching CLI `--snippet-focus`: quality (default), leftmost, or proximity.", ["default"] = "quality" }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping). Match lines are clamped around the first match; non-match lines are clamped from the head. Each clamp inserts a `...(+N)...` marker showing how many chars were elided.", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["rawQuery"] = new JsonObject { ["type"] = "boolean", ["description"] = "Use raw FTS5 syntax instead of literal-safe quoting: content:term, NEAR(a b, 5), OR, NOT, parenthesized groups, prefix*, and quoted phrases.", ["default"] = false }, ["cursor"] = new JsonObject { ["type"] = "string", ["description"] = "Optional pagination cursor returned as `next_cursor` by a previous search response with the same query and filters. Compare `result_stable_at` across pages to detect index drift." }, @@ -65,6 +66,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, + ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content when body ranges are available", ["default"] = false }, ["lsp_compatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Add file:// uri and LSP range fields to each result", ["default"] = false }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, @@ -116,6 +119,7 @@ private JsonNode HandleToolsList(JsonNode? id) { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Callee symbol name pattern to search for" }, ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by call-graph reference kind (call, instantiate, subscribe, friend). Non-call-graph kinds — metadata (attribute, annotation) and type-position (type_reference) — are rejected here; use `references` with the desired kind instead." }, + ["rawKinds"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preserve raw reference kinds instead of CLI logical grouping, matching `--raw-kinds`.", ["default"] = false }, ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1, friend=0.3), count, or kind.", ["default"] = "weighted" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, @@ -142,6 +146,7 @@ private JsonNode HandleToolsList(JsonNode? id) { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Caller/container symbol name pattern to search for" }, ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by call-graph reference kind (call, instantiate, subscribe). Non-call-graph kinds — metadata (attribute, annotation) and type-position (type_reference) — are rejected here; use `references` with the desired kind instead." }, + ["rawKinds"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preserve raw reference kinds instead of CLI logical grouping, matching `--raw-kinds`.", ["default"] = false }, ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1), count, or kind.", ["default"] = "weighted" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, @@ -170,6 +175,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["names"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Optional list of additional symbol name patterns, OR-joined with `query`. Use this to resolve multiple candidate names in one call." }, ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind (function, class, interface, import, etc.)" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, + ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, @@ -177,7 +184,9 @@ private JsonNode HandleToolsList(JsonNode? id) ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to symbols in files modified since this ISO 8601 timestamp" }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact symbol-name equality instead of substring, so `Run` no longer matches `RunAsync`/`RunImpact`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false } + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return count metadata and a top-file histogram without symbol rows.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full symbol rows, count metadata, or compact file/line/kind/name rows.", ["default"] = "full" } } }, ReadOnlyAnnotations()), @@ -196,7 +205,9 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to files modified since this ISO 8601 timestamp" } + ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to files modified since this ISO 8601 timestamp" }, + ["orderBySize"] = new JsonObject { ["type"] = "boolean", ["description"] = "Sort by indexed byte size descending before path, matching byte-oriented CLI views.", ["default"] = false }, + ["rawBytes"] = new JsonObject { ["type"] = "boolean", ["description"] = "CLI-compatible alias for byte-oriented file listing. MCP returns indexed size metadata, not raw file bytes.", ["default"] = false } } }, ReadOnlyAnnotations()), @@ -263,13 +274,14 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["sections"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "tree", "languages", "hotspots", "metrics" } }, ["description"] = "Only include selected response sections. Omit for the full backward-compatible map." }, - ["depth"] = new JsonObject { ["type"] = "integer", ["description"] = "Maximum module/tree depth to include; 0 keeps only root-level modules.", ["minimum"] = 0 } + ["depth"] = new JsonObject { ["type"] = "integer", ["description"] = "Maximum module/tree depth to include; 0 keeps only root-level modules.", ["minimum"] = 0 }, + ["minEntrypointConfidence"] = new JsonObject { ["type"] = "number", ["description"] = "Minimum entrypoint confidence threshold, from 0.0 to 1.0, matching CLI `--min-entrypoint-confidence`.", ["minimum"] = 0, ["maximum"] = 1 } } }, ReadOnlyAnnotations()), CreateToolDefinition( "analyze_symbol", - "Bundle definition, nearby symbols, references, callers, callees, file metadata, and graph-support metadata for one symbol query. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Bundled caller/callee rows carry the same `reference_kind` (preferred summary kind, back-compat) plus `reference_kinds` (sorted distinct) and `has_mixed_reference_kinds` fields as the standalone `callers` / `callees` tools, so mixed `call` + `subscribe` containers stay visible in the bundle. / 1つのシンボルクエリに対して、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、グラフ対応メタデータをまとめて返す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。バンドルされた caller / callee 行にも単独の `callers` / `callees` と同じ `reference_kind`(後方互換の優先サマリー種別)、`reference_kinds`(distinct kind の昇順配列)、`has_mixed_reference_kinds` が付くため、`call` + `subscribe` が混在するコンテナも要約 1 ラベルに潰れず見える。", + "Bundle definition, nearby symbols, references, callers, callees, file metadata, and graph-support metadata for one symbol query. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Bundled caller/callee rows carry the same `reference_kind` (preferred summary kind, back-compat) plus `reference_kinds` (sorted distinct) and `has_mixed_reference_kinds` fields as the standalone `callers` / `callees` tools, so mixed `call` + `subscribe` containers stay visible in the bundle. Supports `format: count|compact`; CLI `since` filtering is intentionally not exposed because the backing analysis reader does not support it yet. / 1つのシンボルクエリに対して、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、グラフ対応メタデータをまとめて返す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。バンドルされた caller / callee 行にも単独の `callers` / `callees` と同じ `reference_kind`(後方互換の優先サマリー種別)、`reference_kinds`(distinct kind の昇順配列)、`has_mixed_reference_kinds` が付くため、`call` + `subscribe` が混在するコンテナも要約 1 ラベルに潰れず見える。`format: count|compact` 対応。CLI の `since` filter は backing analysis reader 未対応のため意図的に未公開。", new JsonObject { ["type"] = "object", @@ -285,7 +297,9 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact bundle symbol-name equality. Propagates through definitions, references, callers, and callees so `Run` no longer pulls in `RunAsync` / `RunImpact`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false } + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only dossier counts and graph support metadata.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full dossier, count-only metadata, or compact file/line rows.", ["default"] = "full" } }, ["required"] = new JsonArray { "query" } }, @@ -476,6 +490,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, + ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, ["groupBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("symbol", "file", "statement"), ["description"] = "Grouping unit. Defaults to symbol for non-SQL scopes and statement for SQL scopes." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, @@ -501,6 +517,9 @@ private JsonNode HandleToolsList(JsonNode? id) ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind (function, class, property, interface, enum, struct, event, delegate)" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (recommended: use a graph-supported language)" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, + ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, + ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, + ["byBucket"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include `symbols_by_bucket` grouped by unused-symbol bucket.", ["default"] = false }, ["bucket"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("likely_unused_private", "maybe_unused_nonpublic", "public_or_exported_no_refs", "reflection_or_config_suspect"), ["description"] = "Return only one unused-symbol bucket." }, ["minConfidence"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("medium", "low"), ["description"] = "Return symbols at or above this confidence threshold." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 500c929372..860871ac8f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -38,6 +38,9 @@ public partial class McpServer "language", "rankBy", "severity", + "snippetFocus", + "visibility", + "excludeVisibility", }; internal const int MaxMcpIndexFailureMessageLength = 512; @@ -440,6 +443,14 @@ private static List ReadStringOrArrayList(JsonNode? args, string propert : []; } + private static List ReadStringOrCommaSeparatedList(JsonNode? args, string propertyName) + => ReadStringOrArrayList(args, propertyName) + .SelectMany(value => value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .ToList(); + private JsonNode? TryReadSearchGuardFilters(JsonNode? id, JsonNode? args, out List filters) { filters = []; @@ -498,7 +509,7 @@ private static List ReadStringOrArrayList(JsonNode? args, string propert private static JsonObject? ValidateCommonListArguments(JsonNode? args) { - foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability", "scopes" }) + foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability", "scopes", "visibility", "excludeVisibility" }) { if (ValidateStringListArgument(args, propertyName) is JsonObject error) return error; @@ -665,11 +676,13 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "check" or "excludeTests" or "includeGenerated" or "indexedOnly" or "rawQuery" or "noDedup" or "exactSubstring" or "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or - "optimize" or "reverse" or "cycles" or "config" or "logPath" or "updateCheck" => "boolean", - "project" or "capability" or "scopes" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", + "optimize" or "reverse" or "cycles" or "config" or "logPath" or "updateCheck" or + "rawKinds" or "orderBySize" or "rawBytes" or "byBucket" => "boolean", + "project" or "capability" or "scopes" or "visibility" or "excludeVisibility" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or - "solution" or "symbol" or "groupBy" or "category" or "language" or "severity" or "explain" or + "solution" or "symbol" or "groupBy" or "category" or "language" or "severity" or "explain" or "snippetFocus" or "bucket" or "minConfidence" or "extension" or "alias" or "description" or "context" or "toolInvocationContext" or "db" => "string", + "minEntrypointConfidence" => "number", "queries" or "evidencePaths" or "evidence_paths" => "array", _ => string.Empty, }; @@ -695,6 +708,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "string" => node is JsonValue value && value.TryGetValue(out _), "string_or_array" => node is JsonArray || node is JsonValue value && value.TryGetValue(out _), "array" => node is JsonArray, + "number" => node is JsonValue value && value.TryGetValue(out _), _ => true, }; @@ -726,16 +740,16 @@ private static string DescribeJsonType(JsonNode? node) private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch { - "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, - "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, + "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "snippetFocus", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, + "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "visibility", "excludeVisibility", "includeBody", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, - "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, - "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" }, - "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "project", "solution" }, + "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rawKinds", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, + "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "visibility", "excludeVisibility", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "countOnly", "format", "project", "solution" }, + "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "orderBySize", "rawBytes", "project", "solution" }, "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" }, - "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "project", "solution" }, - "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" }, + "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "minEntrypointConfidence", "project", "solution" }, + "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "countOnly", "format", "project", "solution" }, "status" => new HashSet(StringComparer.Ordinal) { "check", "scopes", "staleAfterSeconds", "explain", "config", "logPath", "updateCheck", "format" }, "outline" => new HashSet(StringComparer.Ordinal) { "path" }, "batch_query" => new HashSet(StringComparer.Ordinal) { "queries" }, @@ -743,8 +757,8 @@ private static string DescribeJsonType(JsonNode? node) "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, "languages" => new HashSet(StringComparer.Ordinal) { "indexedOnly", "capability", "extension", "alias" }, "validate" => new HashSet(StringComparer.Ordinal) { "kind", "severity", "limit", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution" }, - "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "project", "solution" }, - "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, + "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "visibility", "excludeVisibility", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "byBucket", "project", "solution" }, + "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "visibility", "excludeVisibility", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, "index" => new HashSet(StringComparer.Ordinal) { "path", "rebuild", "maxFileBytes" }, "backfill_fold" => new HashSet(StringComparer.Ordinal) { "dry_run", "dryRun", "force" }, "suggest_improvement" => new HashSet(StringComparer.Ordinal) { "category", "language", "description", "context", "toolInvocationContext", "evidencePaths", "evidence_paths" }, @@ -963,6 +977,198 @@ private JsonObject BuildCountOnlyPayload(int count, int? total, bool truncate return payload; } + private static JsonArray ToJsonStringArray(IEnumerable values) + { + var array = new JsonArray(); + foreach (var value in values) + array.Add(value); + return array; + } + + private static void AddVisibilityFilterEcho(JsonObject payload, IReadOnlyList visibilityFilters, IReadOnlyList excludeVisibilityFilters) + { + payload["visibility"] = ToJsonStringArray(visibilityFilters); + payload["excludeVisibility"] = ToJsonStringArray(excludeVisibilityFilters); + } + + private static JsonArray BuildCompactSymbolRows(IEnumerable results) + { + var rows = new JsonArray(); + foreach (var result in results) + { + var row = new JsonObject + { + ["file"] = result.Path, + ["line"] = result.Line, + ["kind"] = result.Kind, + ["name"] = result.Name, + }; + if (result.Lang != null) + row["lang"] = result.Lang; + if (result.Visibility != null) + row["visibility"] = result.Visibility; + if (result.ContainerName != null) + row["container"] = result.ContainerName; + rows.Add(row); + } + return rows; + } + + private static JsonArray BuildCompactReferenceRows(IEnumerable results) + { + var rows = new JsonArray(); + foreach (var result in results) + { + rows.Add(new JsonObject + { + ["file"] = result.Path, + ["line"] = result.Line, + ["column"] = result.Column, + ["symbol"] = result.SymbolName, + ["kind"] = result.ReferenceKind, + }); + } + return rows; + } + + private static JsonArray BuildCompactCallerRows(IEnumerable results) + { + var rows = new JsonArray(); + foreach (var result in results) + { + rows.Add(new JsonObject + { + ["file"] = result.Path, + ["line"] = result.FirstLine, + ["caller_kind"] = result.CallerKind, + ["caller"] = result.CallerName, + ["callee"] = result.CalleeName, + ["reference_kind"] = result.ReferenceKind, + ["reference_count"] = result.ReferenceCount, + }); + } + return rows; + } + + private static JsonArray BuildCompactCalleeRows(IEnumerable results) + { + var rows = new JsonArray(); + foreach (var result in results) + { + rows.Add(new JsonObject + { + ["file"] = result.Path, + ["line"] = result.FirstLine, + ["caller_kind"] = result.CallerKind, + ["caller"] = result.CallerName, + ["callee"] = result.CalleeName, + ["reference_kind"] = result.ReferenceKind, + ["reference_count"] = result.ReferenceCount, + }); + } + return rows; + } + + private JsonObject BuildUnusedSymbolsByBucket(IEnumerable results) + { + var grouped = results + .GroupBy(result => result.UnusedBucket, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal); + var buckets = new JsonObject(); + foreach (var bucket in QueryCommandRunner.OrderedUnusedBuckets) + { + if (grouped.TryGetValue(bucket, out var rows)) + buckets[bucket] = ToJsonArray(rows); + } + foreach (var (bucket, rows) in grouped.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + if (!buckets.ContainsKey(bucket)) + buckets[bucket] = ToJsonArray(rows); + } + return buckets; + } + + private JsonObject BuildAnalyzeSymbolCountPayload(SymbolAnalysisResult analysis, string? lang, JsonNode? pathEcho, bool excludeTests, int maxLineWidth) + { + var paths = analysis.Definitions.Select(definition => definition.Path) + .Concat(analysis.References.Select(reference => reference.Path)) + .Concat(analysis.Callers.Select(caller => caller.Path)) + .Concat(analysis.Callees.Select(callee => callee.Path)); + var payload = new JsonObject + { + ["format"] = "count", + ["count_only"] = true, + ["query"] = analysis.Query, + ["lang"] = lang, + ["path"] = pathEcho?.DeepClone(), + ["excludeTests"] = excludeTests, + ["maxLineWidth"] = maxLineWidth, + ["file_found"] = analysis.File != null, + ["definition_count"] = analysis.Definitions.Count, + ["nearby_symbol_count"] = analysis.NearbySymbols.Count, + ["reference_count"] = analysis.References.Count, + ["caller_count"] = analysis.Callers.Count, + ["callee_count"] = analysis.Callees.Count, + ["graph_language"] = analysis.GraphLanguage, + ["graph_supported"] = analysis.GraphSupported, + ["graph_support_reason"] = analysis.GraphSupportReason, + ["graph_table_available"] = analysis.GraphTableAvailable, + ["workspace_indexed_at"] = JsonSerializer.SerializeToNode(analysis.WorkspaceIndexedAt, _jsonOptions), + ["workspace_latest_modified"] = JsonSerializer.SerializeToNode(analysis.WorkspaceLatestModified, _jsonOptions), + ["top_files"] = BuildTopFileHistogram(paths, path => path), + ["results"] = new JsonArray(), + }; + if (analysis.ExactIndexAvailable.HasValue) + payload["exact_index_available"] = analysis.ExactIndexAvailable.Value; + if (analysis.DegradedReason != null) + payload["degraded_reason"] = analysis.DegradedReason; + return payload; + } + + private JsonObject BuildAnalyzeSymbolCompactPayload(SymbolAnalysisResult analysis, string? lang, JsonNode? pathEcho, bool excludeTests, int maxLineWidth) + { + var payload = new JsonObject + { + ["api_version"] = analysis.ApiVersion, + ["format"] = "compact", + ["query"] = analysis.Query, + ["lang"] = lang, + ["path"] = pathEcho?.DeepClone(), + ["excludeTests"] = excludeTests, + ["maxLineWidth"] = maxLineWidth, + ["file"] = analysis.File == null + ? null + : new JsonObject + { + ["path"] = analysis.File.Path, + ["lang"] = analysis.File.Lang, + ["lines"] = analysis.File.Lines, + ["size"] = analysis.File.Size, + }, + ["workspace_indexed_at"] = JsonSerializer.SerializeToNode(analysis.WorkspaceIndexedAt, _jsonOptions), + ["workspace_latest_modified"] = JsonSerializer.SerializeToNode(analysis.WorkspaceLatestModified, _jsonOptions), + ["graph_language"] = analysis.GraphLanguage, + ["graph_supported"] = analysis.GraphSupported, + ["graph_support_reason"] = analysis.GraphSupportReason, + ["graph_table_available"] = analysis.GraphTableAvailable, + ["definition_count"] = analysis.Definitions.Count, + ["nearby_symbol_count"] = analysis.NearbySymbols.Count, + ["reference_count"] = analysis.References.Count, + ["caller_count"] = analysis.Callers.Count, + ["callee_count"] = analysis.Callees.Count, + ["definitions"] = BuildCompactSymbolRows(analysis.Definitions), + ["nearby_symbols"] = BuildCompactSymbolRows(analysis.NearbySymbols), + ["references"] = BuildCompactReferenceRows(analysis.References), + ["callers"] = BuildCompactCallerRows(analysis.Callers), + ["callees"] = BuildCompactCalleeRows(analysis.Callees), + }; + if (analysis.ExactIndexAvailable.HasValue) + payload["exact_index_available"] = analysis.ExactIndexAvailable.Value; + if (analysis.DegradedReason != null) + payload["degraded_reason"] = analysis.DegradedReason; + return payload; + } + private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) { var payload = new JsonObject @@ -1253,6 +1459,9 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); var snippetLines = SearchSnippetFormatter.ClampSnippetLines(args?["snippetLines"]?.GetValue() ?? SearchSnippetFormatter.DefaultSnippetLines); + var snippetFocusText = args?["snippetFocus"]?.GetValue() ?? "quality"; + if (!QueryCommandRunner.TryParseSnippetFocusMode(snippetFocusText, out var snippetFocus)) + return CreateToolErrorResponse(id, "snippetFocus must be one of quality, leftmost, proximity"); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var rawQuery = args?["rawQuery"]?.GetValue() ?? false; @@ -1307,6 +1516,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) var payload = BuildCountOnlyPayload(countResults.Count, truncatedCount ? null : countResults.Count, truncatedCount, countResults, result => result.Path); payload["query"] = query; payload["rawQuery"] = rawQuery; + payload["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(); payload["path"] = PathEcho(pathPatterns); payload["excludeTests"] = excludeTests; AddSearchStabilityMetadata(payload, reader, cursor, []); @@ -1339,6 +1549,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["query"] = query, ["rawQuery"] = rawQuery, ["snippetLines"] = snippetLines, + ["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(), ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, @@ -1371,10 +1582,11 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["rawQuery"] = rawQuery, ["cursor"] = cursorValue, ["snippetLines"] = snippetLines, + ["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(), ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, - ["results"] = ToJsonArray(SearchSnippetFormatter.ToCompactResults(results, queryContext, snippetLines, exact, maxLineWidth, exposeLiteralHighlights: exact)) + ["results"] = ToJsonArray(SearchSnippetFormatter.ToCompactResults(results, queryContext, snippetLines, exact, maxLineWidth, lang, snippetFocus, exposeLiteralHighlights: exact)) }; AddSearchStabilityMetadata(structured, reader, cursor, results); AddResultEnvelope(structured, results.Count, truncated ? null : results.Count, truncated); @@ -1431,6 +1643,12 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, sinceError!); if (!TryResolveNameExactArgument(args, "symbols", out var exact, out var exactError)) return CreateToolErrorResponse(id, exactError!); + var visibilityFilters = ReadStringOrCommaSeparatedList(args, "visibility"); + var excludeVisibilityFilters = ReadStringOrCommaSeparatedList(args, "excludeVisibility"); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; // Merge query + names into a de-duplicated OR list. `|` is treated as a literal name character // so operator symbols (e.g. `operator |`) stay searchable; multi-name must use repeated `names[]`. @@ -1449,23 +1667,42 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.SearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact); + JsonNode? namesEcho = effectiveQueries == null ? null : JsonSerializer.SerializeToNode(effectiveQueries, _jsonOptions); var hasExactPredicate = exact && effectiveQueries is { Count: > 0 }; var exactSignal = reader.GetSymbolsExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, since); + if (countOnly) + { + var countSummary = reader.CountSearchSymbolsTotal(effectiveQueries, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); + var histogramResults = countSummary.Count > 0 + ? reader.SearchSymbols(effectiveQueries, Math.Min(countSummary.Count, MaxLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters) + : []; + var payload = BuildCountOnlyPayload(countSummary.Count, countSummary.Count, truncated: false, histogramResults, result => result.Path); + payload["query"] = query; + payload["names"] = namesEcho; + payload["kind"] = kind; + payload["lang"] = lang; + payload["path"] = PathEcho(pathPatterns); + payload["excludeTests"] = excludeTests; + AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); + if (hasExactPredicate) + AddExactGraphSignal(payload, exactSignal); + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countSummary.Count, "symbol")}.", payload); + } + + var results = reader.SearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); var multiNameExactHint = effectiveQueries != null && effectiveQueries.Count > 1; var exactZeroHint = multiNameExactHint ? QueryCommandRunner.BuildExactZeroHint( exact, - () => reader.AnySearchSymbols(effectiveQueries, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false), - () => reader.SearchSymbols(effectiveQueries, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false), + () => reader.AnySearchSymbols(effectiveQueries, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + () => reader.SearchSymbols(effectiveQueries, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), r => r.Name) : QueryCommandRunner.BuildExactZeroHint( exact && effectiveQueries != null && effectiveQueries.Count > 0, - () => reader.CountSearchSymbols(effectiveQueries, QueryCommandRunner.ExactZeroHintProbeLimit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false) > 0, - () => reader.CountSearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false), - () => reader.SearchSymbols(effectiveQueries, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false), + () => reader.CountSearchSymbols(effectiveQueries, QueryCommandRunner.ExactZeroHintProbeLimit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters) > 0, + () => reader.CountSearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + () => reader.SearchSymbols(effectiveQueries, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), r => r.Name); - JsonNode? namesEcho = effectiveQueries == null ? null : JsonSerializer.SerializeToNode(effectiveQueries, _jsonOptions); if (results.Count == 0) { var payload = new JsonObject @@ -1479,6 +1716,7 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) ["count"] = 0, ["results"] = new JsonArray() }; + AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); if (hasExactPredicate) AddExactGraphSignal(payload, exactSignal); AddExactZeroHint(payload, exactZeroHint); @@ -1497,6 +1735,12 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddVisibilityFilterEcho(structured, visibilityFilters, excludeVisibilityFilters); + if (format == "compact") + { + structured["results"] = BuildCompactSymbolRows(results); + structured["format"] = "compact"; + } if (hasExactPredicate) AddExactGraphSignal(structured, exactSignal); return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "symbol"), structured); @@ -1524,18 +1768,20 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, sinceError!); if (!TryResolveNameExactArgument(args, "definition", out var exact, out var exactError)) return CreateToolErrorResponse(id, exactError!); + var visibilityFilters = ReadStringOrCommaSeparatedList(args, "visibility"); + var excludeVisibilityFilters = ReadStringOrCommaSeparatedList(args, "excludeVisibility"); var format = ReadResponseFormat(args); if (ValidateResponseFormat(format) is string formatError) return CreateToolErrorResponse(id, formatError); return WithDbReader(id, args, reader => { - var results = reader.GetDefinitions(query, FetchLimitForEnvelope(limit), kind, lang, includeBody, pathPatterns, excludePaths, excludeTests, since, exact); + var results = reader.GetDefinitions(query, FetchLimitForEnvelope(limit), kind, lang, includeBody, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); var truncated = TrimToRequestedLimit(results, limit); if (format == "count") { var total = truncated - ? reader.CountDefinitionsTotal(query, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact).Count + ? reader.CountDefinitionsTotal(query, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters).Count : results.Count; var countPayload = BuildCountOnlyPayload(total, total, truncated: false, results, result => result.Path); countPayload["query"] = query; @@ -1543,6 +1789,7 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) countPayload["lang"] = lang; countPayload["path"] = PathEcho(pathPatterns); countPayload["excludeTests"] = excludeTests; + AddVisibilityFilterEcho(countPayload, visibilityFilters, excludeVisibilityFilters); return CreateToolResult(id, $"Counted {ConsoleUi.Counted(total, "definition")}.", countPayload); } if (lspCompatible) @@ -1550,9 +1797,9 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) var exactSignal = reader.GetDefinitionExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, since); var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( exact, - () => reader.CountSearchSymbols(query, QueryCommandRunner.ExactZeroHintProbeLimit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false) > 0, - () => reader.CountSearchSymbols(query, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false), - () => reader.SearchSymbols(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false), + () => reader.CountSearchSymbols(query, QueryCommandRunner.ExactZeroHintProbeLimit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters) > 0, + () => reader.CountSearchSymbols(query, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + () => reader.SearchSymbols(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), r => r.Name); var payload = new JsonObject { @@ -1565,6 +1812,7 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) ["excludeTests"] = excludeTests, ["results"] = ToJsonArray(results) }; + AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); AddResultEnvelope(payload, results.Count, truncated ? null : results.Count, truncated); if (format == "compact") ApplyCompactResults(payload, results, result => result.Path, result => result.StartLine); @@ -1711,28 +1959,30 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) if (ValidateResponseFormat(format) is string formatError) return CreateToolErrorResponse(id, formatError); var countOnly = ReadCountOnly(args) || format == "count"; + var rawKinds = args?["rawKinds"]?.GetValue() ?? false; return WithDbReader(id, args, reader => { if (countOnly) { - var countOnlyTotal = reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count; + var countOnlyTotal = reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count; var histogramResults = countOnlyTotal > 0 - ? reader.GetCallers(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode) + ? reader.GetCallers(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode) : []; var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); countOnlyPayload["query"] = query; countOnlyPayload["kind"] = kind; + countOnlyPayload["rawKinds"] = rawKinds; countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "caller")}.", countOnlyPayload); } - var results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode, offset: offset); + var results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count + ? reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( @@ -1743,14 +1993,15 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) var exactSignal = reader.GetCallersExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallers(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false) > 0, - () => reader.CountCallers(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false), - () => reader.GetCallers(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rankMode: rankMode), + () => reader.CountCallers(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds) > 0, + () => reader.CountCallers(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds), + () => reader.GetCallers(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode), r => r.CalleeName); var payload = new JsonObject { ["query"] = query, ["kind"] = kind, + ["rawKinds"] = rawKinds, ["lang"] = lang, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, @@ -1805,28 +2056,30 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) if (ValidateResponseFormat(format) is string formatError) return CreateToolErrorResponse(id, formatError); var countOnly = ReadCountOnly(args) || format == "count"; + var rawKinds = args?["rawKinds"]?.GetValue() ?? false; return WithDbReader(id, args, reader => { if (countOnly) { - var countOnlyTotal = reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count; + var countOnlyTotal = reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count; var histogramResults = countOnlyTotal > 0 - ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode) + ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode) : []; var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); countOnlyPayload["query"] = query; countOnlyPayload["kind"] = kind; + countOnlyPayload["rawKinds"] = rawKinds; countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "callee")}.", countOnlyPayload); } - var results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode, offset: offset); + var results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset); var truncated = TrimToRequestedLimit(results, limit); var total = truncated || offset > 0 - ? reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count + ? reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( @@ -1837,14 +2090,15 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) var exactSignal = reader.GetCalleesExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallees(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false) > 0, - () => reader.CountCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false), - () => reader.GetCallees(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rankMode: rankMode), + () => reader.CountCallees(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds) > 0, + () => reader.CountCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds), + () => reader.GetCallees(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode), r => r.CallerName); var payload = new JsonObject { ["query"] = query, ["kind"] = kind, + ["rawKinds"] = rawKinds, ["lang"] = lang, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, @@ -1885,10 +2139,12 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) var excludeTests = args?["excludeTests"]?.GetValue() ?? false; if (!TryReadSinceArgument(args, out var since, out var sinceError)) return CreateToolErrorResponse(id, sinceError!); + var orderBySize = args?["orderBySize"]?.GetValue() ?? false; + var rawBytes = args?["rawBytes"]?.GetValue() ?? false; return WithDbReader(id, args, reader => { - var results = reader.ListFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, since); + var results = reader.ListFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, since, orderBySize || rawBytes); if (results.Count == 0) { var payload = new JsonObject @@ -1897,9 +2153,16 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) ["lang"] = lang, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, + ["orderBySize"] = orderBySize, + ["rawBytes"] = rawBytes, ["count"] = 0, ["results"] = new JsonArray() }; + if (rawBytes) + { + payload["raw_bytes_payload_supported"] = false; + payload["raw_bytes_note"] = "MCP returns indexed file size metadata; raw file bytes are not returned."; + } AddFreshnessHint(payload, reader); return CreateToolResult(id, "No files found.", payload); } @@ -1910,9 +2173,16 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) ["lang"] = lang, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, + ["orderBySize"] = orderBySize, + ["rawBytes"] = rawBytes, ["count"] = results.Count, ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions) }; + if (rawBytes) + { + structured["raw_bytes_payload_supported"] = false; + structured["raw_bytes_note"] = "MCP returns indexed file size metadata; raw file bytes are not returned."; + } return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "file"), structured); }); } @@ -1926,10 +2196,13 @@ private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) var excludeTests = args?["excludeTests"]?.GetValue() ?? false; var sections = ReadStringList(args, "sections").Select(section => section.ToLowerInvariant()).ToHashSet(StringComparer.Ordinal); var depth = args?["depth"]?.GetValue(); + var minEntrypointConfidence = args?["minEntrypointConfidence"]?.GetValue() ?? 0; + if (minEntrypointConfidence is < 0 or > 1) + return CreateToolErrorResponse(id, "minEntrypointConfidence must be between 0.0 and 1.0"); return WithDbReader(id, args, reader => { - var map = reader.GetRepoMap(limit, lang, pathPatterns, excludePaths, excludeTests); + var map = reader.GetRepoMap(limit, lang, pathPatterns, excludePaths, excludeTests, minEntrypointConfidence); WorkspaceMetadataEnricher.Enrich(map, _dbPath, _dbPathExplicit); var structured = JsonSerializer.SerializeToNode(map, _jsonOptions)!.AsObject(); if (depth is >= 0) @@ -1955,6 +2228,7 @@ private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) structured["lang"] = lang; structured["path"] = PathEcho(pathPatterns); structured["excludeTests"] = excludeTests; + structured["minEntrypointConfidence"] = minEntrypointConfidence; var hasFilter = (pathPatterns is { Count: > 0 }) || excludePaths.Count > 0 || excludeTests || lang != null; if (map.FileCount == 0 && hasFilter) AddFreshnessHint(structured, reader); @@ -1972,7 +2246,7 @@ private static void ApplyMapSectionFilter(JsonObject structured, IReadOnlySet() ?? false; if (!TryResolveNameExactArgument(args, "analyze_symbol", out var exact, out var exactError)) return CreateToolErrorResponse(id, exactError!); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; return WithDbReader(id, args, reader => { @@ -2027,13 +2305,18 @@ private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) analysis.SqlGraphContractReady = sqlGraphSignal.Relevant ? sqlGraphSignal.Ready : null; analysis.SqlGraphContractDegradedReason = sqlGraphSignal.Relevant ? sqlGraphSignal.DegradedReason : null; WorkspaceMetadataEnricher.Enrich(analysis, _dbPath, _dbPathExplicit); - var structured = ToAnalyzeSymbolJsonObject(analysis); + var pathEcho = PathEcho(pathPatterns); + var structured = countOnly + ? BuildAnalyzeSymbolCountPayload(analysis, lang, pathEcho, excludeTests, maxLineWidth) + : format == "compact" + ? BuildAnalyzeSymbolCompactPayload(analysis, lang, pathEcho, excludeTests, maxLineWidth) + : ToAnalyzeSymbolJsonObject(analysis); AddSqlGraphContractSignal(structured, sqlGraphSignal); structured.Remove("exactZeroHint"); AddExactZeroHint(structured, analysis.ExactZeroHint); structured["maxLineWidth"] = maxLineWidth; structured["lang"] = lang; - structured["path"] = PathEcho(pathPatterns); + structured["path"] = pathEcho; structured["excludeTests"] = excludeTests; return CreateToolResult(id, BuildAnalyzeSymbolSummary(analysis), structured); }); @@ -3817,14 +4100,16 @@ private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var visibilityFilters = ReadStringOrCommaSeparatedList(args, "visibility"); + var excludeVisibilityFilters = ReadStringOrCommaSeparatedList(args, "excludeVisibility"); return WithDbReader(id, args, reader => { var fileResults = groupBy == "file" - ? reader.GetFileSymbolHotspots(limit, kind, lang, pathPatterns, excludePaths, excludeTests) + ? reader.GetFileSymbolHotspots(limit, kind, lang, pathPatterns, excludePaths, excludeTests, visibilityFilters, excludeVisibilityFilters) : null; var results = fileResults == null - ? reader.GetSymbolHotspots(limit, kind, lang, pathPatterns, excludePaths, excludeTests) + ? reader.GetSymbolHotspots(limit, kind, lang, pathPatterns, excludePaths, excludeTests, visibilityFilters, excludeVisibilityFilters) : []; var hotspotSignal = reader.GetHotspotFamilySignal(lang); var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests); @@ -3877,6 +4162,7 @@ private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args) ["grouped_by"] = groupBy, ["hotspots"] = hotspotsNode }; + AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); if (fileResults != null) payload["files"] = fileResults.Count; AddHotspotFamilySignal(payload, hotspotSignal); @@ -3913,6 +4199,9 @@ private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var visibilityFilters = ReadStringOrCommaSeparatedList(args, "visibility"); + var excludeVisibilityFilters = ReadStringOrCommaSeparatedList(args, "excludeVisibility"); + var byBucket = args?["byBucket"]?.GetValue() ?? false; if (bucket != null && !QueryCommandRunner.IsKnownUnusedBucket(bucket)) return CreateToolErrorResponse(id, $"Invalid bucket '{bucket}'. Use one of: {string.Join(", ", QueryCommandRunner.OrderedUnusedBuckets)}."); if (minConfidence != null && !QueryCommandRunner.IsKnownUnusedConfidence(minConfidence)) @@ -3932,6 +4221,8 @@ private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) pathPatterns, excludePaths, excludeTests, + visibilityFilters, + excludeVisibilityFilters, bucketFilter: bucket, minConfidence: minConfidence); var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests); @@ -3955,6 +4246,10 @@ private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) ["bucket_taxonomy"] = QueryCommandRunner.BuildUnusedBucketTaxonomyJson(), ["symbols"] = JsonSerializer.SerializeToNode(results, _jsonOptions) }; + AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); + payload["byBucket"] = byBucket; + if (byBucket) + payload["symbols_by_bucket"] = BuildUnusedSymbolsByBucket(results); AddSqlGraphContractSignal(payload, sqlGraphSignal); var summary = results.Count > 0 ? $"Found {ConsoleUi.Counted(results.Count, "potentially unused symbol")} across {ConsoleUi.Counted(bucketCounts.Count, "returned bucket")}. Private hits are ranked ahead of exported/config suspects, but not labeled high-confidence from indexed refs alone. Note: name-based matching — same-named symbols in different contexts may mask true unused symbols." diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 19b1c93008..ea7aa44284 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -229,6 +229,81 @@ public void ToolsCall_SearchFormatCountAliasesCountOnly_Issue1642() Assert.Empty(structured["results"]!.AsArray()); } + [Fact] + public void ToolsCall_SearchFilesAndMapExposeCliQueryOptions_Issue3542() + { + InsertIndexedFile("src/large.cs", "csharp", "public class Large { " + new string('x', 512) + " }\n"); + + var searchRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"Run","snippetFocus":"leftmost"}}}""")!; + var searchResponse = _server.HandleMessage(searchRequest)!; + var searchStructured = searchResponse["result"]!["structuredContent"]!; + Assert.Equal("leftmost", searchStructured["snippetFocus"]!.GetValue()); + + var filesRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"files","arguments":{"orderBySize":true,"rawBytes":true,"limit":1}}}""")!; + var filesResponse = _server.HandleMessage(filesRequest)!; + var filesStructured = filesResponse["result"]!["structuredContent"]!; + Assert.True(filesStructured["orderBySize"]!.GetValue()); + Assert.True(filesStructured["rawBytes"]!.GetValue()); + Assert.False(filesStructured["raw_bytes_payload_supported"]!.GetValue()); + Assert.Equal("src/large.cs", filesStructured["results"]![0]!["path"]!.GetValue()); + + var mapRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"map","arguments":{"minEntrypointConfidence":0.5,"sections":["hotspots"]}}}""")!; + var mapResponse = _server.HandleMessage(mapRequest)!; + var mapStructured = mapResponse["result"]!["structuredContent"]!; + Assert.Equal(0.5, mapStructured["minEntrypointConfidence"]!.GetValue()); + } + + [Fact] + public void ToolsCall_SymbolGraphAndAnalyzeExposeCliQueryOptions_Issue3542() + { + InsertIndexedFile("src/visible.cs", "csharp", "public class Visible { public void RunVisible() { } private void Hidden() { } }\n"); + + var symbolsRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"symbols","arguments":{"query":"RunVisible","visibility":["public"],"excludeVisibility":"private","format":"compact"}}}""")!; + var symbolsResponse = _server.HandleMessage(symbolsRequest)!; + var symbolsStructured = symbolsResponse["result"]!["structuredContent"]!; + Assert.Equal("compact", symbolsStructured["format"]!.GetValue()); + Assert.Equal("public", Assert.Single(symbolsStructured["visibility"]!.AsArray())!.GetValue()); + Assert.Equal("private", Assert.Single(symbolsStructured["excludeVisibility"]!.AsArray())!.GetValue()); + + var callersRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"callers","arguments":{"query":"RunVisible","rawKinds":true,"format":"count"}}}""")!; + var callersResponse = _server.HandleMessage(callersRequest)!; + var callersStructured = callersResponse["result"]!["structuredContent"]!; + Assert.True(callersStructured["rawKinds"]!.GetValue()); + Assert.True(callersStructured["count_only"]!.GetValue()); + + var analyzeRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"analyze_symbol","arguments":{"query":"RunVisible","format":"compact"}}}""")!; + var analyzeResponse = _server.HandleMessage(analyzeRequest)!; + var analyzeStructured = analyzeResponse["result"]!["structuredContent"]!; + Assert.Equal("compact", analyzeStructured["format"]!.GetValue()); + Assert.True(analyzeStructured["definition_count"]!.GetValue() >= 1); + Assert.NotNull(analyzeStructured["definitions"]); + } + + [Fact] + public void ToolsCall_UnusedAndHotspotsExposeVisibilityAndBucketOptions_Issue3542() + { + var hotspotsRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"symbol_hotspots","arguments":{"visibility":"public","excludeVisibility":["private"]}}}""")!; + var hotspotsResponse = _server.HandleMessage(hotspotsRequest)!; + var hotspotsStructured = hotspotsResponse["result"]!["structuredContent"]!; + Assert.Equal("public", Assert.Single(hotspotsStructured["visibility"]!.AsArray())!.GetValue()); + Assert.Equal("private", Assert.Single(hotspotsStructured["excludeVisibility"]!.AsArray())!.GetValue()); + + var unusedRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"unused_symbols","arguments":{"visibility":"public","byBucket":true}}}""")!; + var unusedResponse = _server.HandleMessage(unusedRequest)!; + var unusedStructured = unusedResponse["result"]!["structuredContent"]!; + Assert.True(unusedStructured["byBucket"]!.GetValue()); + Assert.NotNull(unusedStructured["symbols_by_bucket"]); + Assert.Equal("public", Assert.Single(unusedStructured["visibility"]!.AsArray())!.GetValue()); + } + [Theory] [InlineData("search", "format")] [InlineData("symbol_hotspots", "groupBy")] From d87c3271b9d1a4b0f8de49fe8502a2827015de3b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:23:42 +0900 Subject: [PATCH 4/8] Expose MCP index planning controls (#3543) --- changelog.d/unreleased/3543.added.md | 17 ++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 14 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 324 ++++++++++++++++++++++-- tests/CodeIndex.Tests/McpServerTests.cs | 143 ++++++++++- 4 files changed, 462 insertions(+), 36 deletions(-) create mode 100644 changelog.d/unreleased/3543.added.md diff --git a/changelog.d/unreleased/3543.added.md b/changelog.d/unreleased/3543.added.md new file mode 100644 index 0000000000..93b0c14bc6 --- /dev/null +++ b/changelog.d/unreleased/3543.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 3543 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP `index` now exposes safe advanced indexing controls (#3543)** — the tool supports dry-run planning, max-symbol limits, symlink policy, symbol-kind filters, and memory/duration diagnostics, while explicitly rejecting unsupported scoped/watch modes instead of silently widening the run. + +## 日本語 + +- **MCP `index` が安全な advanced indexing control に対応しました (#3543)** — dry-run planning、max-symbol limit、symlink policy、symbol-kind filter、memory/duration diagnostics を扱えるようになり、未対応の scope/watch mode は silently full scan にせず明示的に拒否します。 diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index d648654f4d..601acb047f 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -458,7 +458,19 @@ private JsonNode HandleToolsList(JsonNode? id) { ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Project directory path to index" }, ["rebuild"] = new JsonObject { ["type"] = "boolean", ["description"] = "Delete existing index and rebuild from scratch (default: false)", ["default"] = false }, - ["maxFileBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Override the per-file indexing size limit for this run. Defaults to CDIDX_MAX_FILE_BYTES or 4MiB.", ["minimum"] = 1, ["maximum"] = int.MaxValue } + ["dryRun"] = new JsonObject { ["type"] = "boolean", ["description"] = "Plan the index run without mutating the database. Reports scan counts, effective options, and unsupported MCP modes.", ["default"] = false }, + ["maxFileBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Override the per-file indexing size limit for this run. Defaults to CDIDX_MAX_FILE_BYTES or 4MiB.", ["minimum"] = 1, ["maximum"] = int.MaxValue }, + ["maxSymbolsPerFile"] = new JsonObject { ["type"] = "integer", ["description"] = "Skip symbol/reference indexing for files that produce more symbols than this limit, matching CLI --max-symbols-per-file.", ["default"] = IndexCommandRunner.DefaultMaxSymbolsPerFile, ["minimum"] = 1, ["maximum"] = IndexCommandRunner.MaxSymbolsPerFileLimit }, + ["followSymlinks"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "none", "internal", "all" }, ["description"] = "Symlink traversal policy matching CLI --follow-symlinks.", ["default"] = "none" }, + ["includeSymbolKind"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Only index symbols with these kinds. Accepts a value, comma-separated string, or array." }, + ["excludeSymbolKind"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop symbols with these kinds before indexing. Accepts a value, comma-separated string, or array." }, + ["memoryTrace"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include lightweight MCP memory samples and duration diagnostics in the response.", ["default"] = false }, + ["parallelism"] = new JsonObject { ["type"] = "integer", ["description"] = "CLI compatibility knob. MCP index currently runs serially and reports effective_parallelism=1 instead of silently using this value.", ["minimum"] = 1, ["maximum"] = IndexCommandRunner.MaxIndexParallelism }, + ["commits"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. Commit-scoped MCP indexing is not supported; non-dry runs reject it explicitly." }, + ["changedBetween"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. changed-between MCP indexing is not supported; non-dry runs reject it explicitly." }, + ["files"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. File-scoped MCP indexing is not supported; non-dry runs reject it explicitly." }, + ["watch"] = new JsonObject { ["type"] = "boolean", ["description"] = "CLI compatibility flag. Long-running watch mode is intentionally disabled for MCP; non-dry runs reject it explicitly.", ["default"] = false }, + ["debounce"] = new JsonObject { ["type"] = "integer", ["description"] = "Watch debounce in milliseconds. Reported as unsupported unless watch mode is added to MCP in the future.", ["minimum"] = 0, ["maximum"] = IndexWatchRunner.MaxDebounceMs } }, ["required"] = new JsonArray { "path" } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 860871ac8f..3ab6594b85 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -41,6 +41,7 @@ public partial class McpServer "snippetFocus", "visibility", "excludeVisibility", + "followSymlinks", }; internal const int MaxMcpIndexFailureMessageLength = 512; @@ -509,7 +510,7 @@ private static List ReadStringOrCommaSeparatedList(JsonNode? args, strin private static JsonObject? ValidateCommonListArguments(JsonNode? args) { - foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability", "scopes", "visibility", "excludeVisibility" }) + foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability", "scopes", "visibility", "excludeVisibility", "includeSymbolKind", "excludeSymbolKind", "commits", "changedBetween", "files" }) { if (ValidateStringListArgument(args, propertyName) is JsonObject error) return error; @@ -581,6 +582,21 @@ private static List ReadStringOrCommaSeparatedList(JsonNode? args, strin && offset < 0) return CreateIntegerMinimumArgumentError(toolName, "offset", minimum: 0, actual: offset); + if (args["maxSymbolsPerFile"] is JsonValue maxSymbolsValue + && maxSymbolsValue.TryGetValue(out var maxSymbolsPerFile) + && (maxSymbolsPerFile <= 0 || maxSymbolsPerFile > IndexCommandRunner.MaxSymbolsPerFileLimit)) + return CreateIntegerRangeArgumentError(toolName, "maxSymbolsPerFile", 1, IndexCommandRunner.MaxSymbolsPerFileLimit, maxSymbolsPerFile); + + if (args["parallelism"] is JsonValue parallelismValue + && parallelismValue.TryGetValue(out var parallelism) + && (parallelism <= 0 || parallelism > IndexCommandRunner.MaxIndexParallelism)) + return CreateIntegerRangeArgumentError(toolName, "parallelism", 1, IndexCommandRunner.MaxIndexParallelism, parallelism); + + if (args["debounce"] is JsonValue debounceValue + && debounceValue.TryGetValue(out var debounce) + && (debounce < 0 || debounce > IndexWatchRunner.MaxDebounceMs)) + return CreateIntegerRangeArgumentError(toolName, "debounce", 0, IndexWatchRunner.MaxDebounceMs, debounce); + return null; } @@ -594,6 +610,17 @@ private static List ReadStringOrCommaSeparatedList(JsonNode? args, strin ["jsonrpc_invalid_params"] = true, }; + private static JsonObject CreateIntegerRangeArgumentError(string toolName, string argumentName, int minimum, int maximum, int actual) => new() + { + ["message"] = $"Argument '{argumentName}' on tool '{toolName}' must be between {minimum} and {maximum}; got {actual}.", + ["tool"] = toolName, + ["parameter"] = argumentName, + ["minimum"] = minimum, + ["maximum"] = maximum, + ["actual"] = actual, + ["jsonrpc_invalid_params"] = true, + }; + private static JsonObject? ValidateBoundedEnumLikeScalarArguments(string toolName, JsonObject args) { foreach (var property in args) @@ -670,18 +697,18 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, { "limit" or "offset" or "snippetLines" or "maxLineWidth" or "before" or "after" or "focusLine" or "focusColumn" or "focusLength" or "startLine" or "endLine" or - "maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" or + "maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" or "maxSymbolsPerFile" or "debounce" or "staleAfterSeconds" or "guardWindow" or "maxOutputBytes" => "integer", "check" or "excludeTests" or "includeGenerated" or "indexedOnly" or "rawQuery" or "noDedup" or "exactSubstring" or "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or "optimize" or "reverse" or "cycles" or "config" or "logPath" or "updateCheck" or - "rawKinds" or "orderBySize" or "rawBytes" or "byBucket" => "boolean", - "project" or "capability" or "scopes" or "visibility" or "excludeVisibility" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", + "rawKinds" or "orderBySize" or "rawBytes" or "byBucket" or "memoryTrace" or "watch" => "boolean", + "project" or "capability" or "scopes" or "visibility" or "excludeVisibility" or "includeSymbolKind" or "excludeSymbolKind" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "solution" or "symbol" or "groupBy" or "category" or "language" or "severity" or "explain" or "snippetFocus" or - "bucket" or "minConfidence" or "extension" or "alias" or "description" or "context" or "toolInvocationContext" or "db" => "string", + "bucket" or "minConfidence" or "extension" or "alias" or "description" or "context" or "toolInvocationContext" or "db" or "followSymlinks" => "string", "minEntrypointConfidence" => "number", "queries" or "evidencePaths" or "evidence_paths" => "array", _ => string.Empty, @@ -759,7 +786,7 @@ private static string DescribeJsonType(JsonNode? node) "validate" => new HashSet(StringComparer.Ordinal) { "kind", "severity", "limit", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution" }, "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "visibility", "excludeVisibility", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "byBucket", "project", "solution" }, "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "visibility", "excludeVisibility", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, - "index" => new HashSet(StringComparer.Ordinal) { "path", "rebuild", "maxFileBytes" }, + "index" => new HashSet(StringComparer.Ordinal) { "path", "rebuild", "dryRun", "dry_run", "maxFileBytes", "maxSymbolsPerFile", "followSymlinks", "includeSymbolKind", "excludeSymbolKind", "memoryTrace", "parallelism", "commits", "changedBetween", "files", "watch", "debounce" }, "backfill_fold" => new HashSet(StringComparer.Ordinal) { "dry_run", "dryRun", "force" }, "suggest_improvement" => new HashSet(StringComparer.Ordinal) { "category", "language", "description", "context", "toolInvocationContext", "evidencePaths", "evidence_paths" }, _ => new HashSet(StringComparer.Ordinal), @@ -4408,6 +4435,130 @@ private static bool LanguageMatchesCapability(bool symbols, bool graph, string c private JsonNode ExecuteIndex(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) => ExecuteIndexAsync(id, args, progressToken).GetAwaiter().GetResult(); + private sealed record McpIndexUnsupportedMode(string Name, string Reason, bool BlocksIndexing); + + private static FileIssue BuildMcpSymbolCountExceededIssue(string path, int symbolCount, int maxSymbolsPerFile) => + new() + { + Path = path, + Kind = "symbol_count_exceeded", + Line = 0, + Message = $"Symbol extraction produced {symbolCount:N0} symbols, exceeding the maxSymbolsPerFile limit of {maxSymbolsPerFile:N0}; file content, symbols, and references were not indexed. Exclude the generated/pathological file or raise maxSymbolsPerFile if this is expected.", + }; + + private static bool TryReadMcpIndexSymlinkPolicy(JsonNode? args, out FileIndexer.SymlinkPolicy symlinkPolicy, out string? error) + { + symlinkPolicy = FileIndexer.SymlinkPolicy.None; + error = null; + var value = args?["followSymlinks"]?.GetValue()?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(value) || value == "none") + return true; + if (value == "internal") + { + symlinkPolicy = FileIndexer.SymlinkPolicy.Internal; + return true; + } + if (value == "all") + { + symlinkPolicy = FileIndexer.SymlinkPolicy.All; + return true; + } + + error = "followSymlinks must be one of none, internal, all"; + return false; + } + + private static string FormatMcpIndexSymlinkPolicy(FileIndexer.SymlinkPolicy symlinkPolicy) + => symlinkPolicy switch + { + FileIndexer.SymlinkPolicy.Internal => "internal", + FileIndexer.SymlinkPolicy.All => "all", + _ => "none", + }; + + private static List BuildMcpIndexUnsupportedModes(JsonNode? args, int? requestedParallelism, int? requestedDebounce) + { + var modes = new List(); + if (requestedParallelism.HasValue && requestedParallelism.Value != 1) + modes.Add(new McpIndexUnsupportedMode("parallelism", "MCP index currently runs serially; requested parallelism is reported but effective_parallelism remains 1.", false)); + if (ReadStringOrArrayList(args, "commits").Count > 0) + modes.Add(new McpIndexUnsupportedMode("commits", "Commit-scoped MCP indexing is not implemented; use CLI `cdidx index --commits ...` for this scope.", true)); + if (ReadStringOrArrayList(args, "changedBetween").Count > 0) + modes.Add(new McpIndexUnsupportedMode("changedBetween", "Changed-between MCP indexing is not implemented; use CLI `cdidx index --changed-between ...` for this scope.", true)); + if (ReadStringOrArrayList(args, "files").Count > 0) + modes.Add(new McpIndexUnsupportedMode("files", "File-scoped MCP indexing is not implemented; use CLI `cdidx index --files ...` for this scope.", true)); + + var watchRequested = args?["watch"]?.GetValue() ?? false; + if (watchRequested) + { + modes.Add(new McpIndexUnsupportedMode("watch", "Long-running watch mode is intentionally disabled for MCP tool calls; use the CLI watch command instead.", true)); + } + else if (requestedDebounce.HasValue) + { + modes.Add(new McpIndexUnsupportedMode("debounce", "debounce only applies to watch mode, which is disabled for MCP tool calls.", false)); + } + return modes; + } + + private static JsonArray BuildMcpIndexUnsupportedModesJson(IEnumerable unsupportedModes) + { + var array = new JsonArray(); + foreach (var mode in unsupportedModes) + { + array.Add(new JsonObject + { + ["name"] = mode.Name, + ["reason"] = mode.Reason, + ["blocks_indexing"] = mode.BlocksIndexing, + }); + } + return array; + } + + private static bool HasBlockingMcpIndexUnsupportedMode(IEnumerable unsupportedModes) + => unsupportedModes.Any(mode => mode.BlocksIndexing); + + private static JsonObject CaptureMcpIndexMemorySample(string stage, Stopwatch stopwatch) + { + using var process = Process.GetCurrentProcess(); + return new JsonObject + { + ["stage"] = stage, + ["elapsed_ms"] = stopwatch.ElapsedMilliseconds, + ["managed_bytes"] = GC.GetTotalMemory(forceFullCollection: false), + ["working_set_bytes"] = process.WorkingSet64, + ["private_bytes"] = process.PrivateMemorySize64, + }; + } + + private static JsonObject BuildMcpIndexOptionsPayload( + bool dryRun, + bool rebuild, + long? maxFileBytes, + int maxSymbolsPerFile, + FileIndexer.SymlinkPolicy symlinkPolicy, + IReadOnlyList includeSymbolKinds, + IReadOnlyList excludeSymbolKinds, + bool memoryTrace, + int? requestedParallelism, + int? requestedDebounce, + JsonNode? args) + => new() + { + ["dryRun"] = dryRun, + ["rebuild"] = rebuild, + ["maxFileBytes"] = maxFileBytes.HasValue ? JsonValue.Create(maxFileBytes.Value) : null, + ["maxSymbolsPerFile"] = maxSymbolsPerFile, + ["followSymlinks"] = FormatMcpIndexSymlinkPolicy(symlinkPolicy), + ["includeSymbolKind"] = ToJsonStringArray(includeSymbolKinds), + ["excludeSymbolKind"] = ToJsonStringArray(excludeSymbolKinds), + ["memoryTrace"] = memoryTrace, + ["parallelism_requested"] = requestedParallelism.HasValue ? JsonValue.Create(requestedParallelism.Value) : null, + ["effective_parallelism"] = 1, + ["watch_requested"] = args?["watch"]?.GetValue() ?? false, + ["debounce"] = requestedDebounce.HasValue ? JsonValue.Create(requestedDebounce.Value) : null, + }; + private async Task RefreshClientRootsIfNeededAsync() { if (!_clientRootsStale || !HasClientCapability("roots")) @@ -4471,6 +4622,21 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso return CreateToolErrorResponse(id, requiredError!); var rebuild = args?["rebuild"]?.GetValue() ?? false; + var dryRun = args?["dryRun"]?.GetValue() ?? args?["dry_run"]?.GetValue() ?? false; + var memoryTrace = args?["memoryTrace"]?.GetValue() ?? false; + var requestedParallelism = args?["parallelism"]?.GetValue(); + var requestedDebounce = args?["debounce"]?.GetValue(); + var maxSymbolsPerFile = args?["maxSymbolsPerFile"]?.GetValue() ?? IndexCommandRunner.DefaultMaxSymbolsPerFile; + if (maxSymbolsPerFile <= 0 || maxSymbolsPerFile > IndexCommandRunner.MaxSymbolsPerFileLimit) + return CreateToolErrorResponse(id, $"maxSymbolsPerFile must be between 1 and {IndexCommandRunner.MaxSymbolsPerFileLimit}"); + if (!TryReadMcpIndexSymlinkPolicy(args, out var symlinkPolicy, out var symlinkPolicyError)) + return CreateToolErrorResponse(id, symlinkPolicyError!); + var includeSymbolKinds = ReadStringOrCommaSeparatedList(args, "includeSymbolKind"); + var excludeSymbolKinds = ReadStringOrCommaSeparatedList(args, "excludeSymbolKind"); + var symbolKindFilter = SymbolKindFilter.Create(includeSymbolKinds, excludeSymbolKinds, parseError: null); + if (symbolKindFilter.ParseError != null) + return CreateToolErrorResponse(id, symbolKindFilter.ParseError); + var unsupportedModes = BuildMcpIndexUnsupportedModes(args, requestedParallelism, requestedDebounce); long? maxFileBytes = null; if (args?["maxFileBytes"] is { } maxFileBytesNode) { @@ -4488,6 +4654,21 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso var projectPath = Path.GetFullPath(path); var runStartedAtUtc = DateTime.UtcNow; var runStopwatch = Stopwatch.StartNew(); + var memorySamples = memoryTrace + ? new JsonArray { CaptureMcpIndexMemorySample("start", runStopwatch) } + : null; + var optionsPayload = BuildMcpIndexOptionsPayload( + dryRun, + rebuild, + maxFileBytes, + maxSymbolsPerFile, + symlinkPolicy, + includeSymbolKinds, + excludeSymbolKinds, + memoryTrace, + requestedParallelism, + requestedDebounce, + args); // Prevent path traversal — only allow indexing within current working directory // パストラバーサル防止 — カレントディレクトリ配下のみインデックスを許可 @@ -4501,6 +4682,66 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso if (!Directory.Exists(projectPath)) return CreateToolErrorResponse(id, "Directory not found"); + var unsupportedModesJson = BuildMcpIndexUnsupportedModesJson(unsupportedModes); + if (dryRun) + { + var ignoreCase = GitHelper.ResolveIgnoreCase(projectPath, _currentRequestToken.Value); + var dryRunIndexer = new FileIndexer( + projectPath, + ignoreCase, + GitHelper.TryGetRepositoryRoot(projectPath, _currentRequestToken.Value) ?? Path.GetFullPath(projectPath), + maxFileBytes, + directoryIgnoreCaseProbe: null, + symlinkPolicy: symlinkPolicy); + var scan = dryRunIndexer.ScanFilesDetailed(cancellationToken: _currentRequestToken.Value); + if (memorySamples != null) + memorySamples.Add(CaptureMcpIndexMemorySample("scan", runStopwatch)); + var dryRunFatalScanErrors = scan.Errors.Where(error => error.IsFatal).ToList(); + var dryRunPayload = new JsonObject + { + ["path"] = projectPath, + ["dry_run"] = true, + ["would_rebuild"] = rebuild, + ["max_file_bytes"] = maxFileBytes, + ["index_options"] = optionsPayload, + ["unsupported_modes"] = unsupportedModesJson, + ["summary"] = new JsonObject + { + ["files_scanned"] = scan.Files.Count, + ["scan_errors"] = scan.Errors.Count, + ["fatal_scan_errors"] = dryRunFatalScanErrors.Count, + ["unknown_extension_file_count"] = scan.UnknownExtensionFiles.Count, + ["would_mutate_database"] = false, + }, + ["duration_ms"] = runStopwatch.ElapsedMilliseconds, + ["started_at"] = runStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), + ["completed_at"] = GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture), + }; + if (memorySamples != null) + { + memorySamples.Add(CaptureMcpIndexMemorySample("finalize", runStopwatch)); + dryRunPayload["memory_trace"] = memorySamples; + } + return CreateToolResult(id, "Index dry run complete.", dryRunPayload); + } + + if (HasBlockingMcpIndexUnsupportedMode(unsupportedModes)) + { + var unsupportedData = new JsonObject + { + ["unsupported_modes"] = unsupportedModesJson, + ["index_options"] = optionsPayload, + ["index_started"] = false, + }; + return CreateToolErrorResponse( + id, + "MCP index does not support the requested scoped or watch indexing mode; no indexing started.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use dryRun:true to inspect the plan, remove unsupported scope/watch arguments, or run the equivalent cdidx index command in the CLI.", + retrySafe: false, + extraData: unsupportedData); + } + if (!McpIndexRunLock.TryAcquire(_dbPath, out var indexLock, out var lockError)) return CreateToolErrorResponse(id, lockError!); using var acquiredIndexLock = indexLock; @@ -4553,7 +4794,9 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso projectPath, GitHelper.ResolveIgnoreCase(projectPath, requestToken), GitHelper.TryGetRepositoryRoot(projectPath, requestToken) ?? Path.GetFullPath(projectPath), - maxFileBytes); + maxFileBytes, + directoryIgnoreCaseProbe: null, + symlinkPolicy: symlinkPolicy); using var postExtractionHooks = PostExtractionHookRunner.DiscoverDefault(); var currentHotspotFamilyMarkerFingerprints = GetHotspotFamilyMarkerFingerprints(indexer, requestToken); var currentCSharpSymbolNameContractVersion = DbContext.CSharpSymbolNameContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); @@ -4625,6 +4868,8 @@ static long SumReadableFileBytes(IEnumerable paths) // Scan and index / スキャン・インデックス var scanResult = indexer.ScanFilesDetailed(cancellationToken: requestToken); + if (memorySamples != null) + memorySamples.Add(CaptureMcpIndexMemorySample("scan", runStopwatch)); var files = scanResult.Files; EmitProgressNotification(progressToken, 0, files.Count, "Index scan complete; indexing files."); var csharpWorkspace = BuildMcpCSharpStaticInterfaceWorkspaceSymbols(writer, indexer, projectPath, files, requestToken); @@ -4638,6 +4883,7 @@ static long SumReadableFileBytes(IEnumerable paths) .Select(BuildScanFailure) .ToList(); var reusedHotspotFamilyLanguages = new HashSet(StringComparer.Ordinal); + var symbolsDroppedByKindFilter = 0; foreach (var filePath in files) { @@ -4671,26 +4917,37 @@ static long SumReadableFileBytes(IEnumerable paths) using var txn = writer.BeginTransaction(); var fileId = writer.UpsertFile(record); var chunks = ChunkSplitter.Split(fileId, content); - writer.InsertChunks(chunks); - var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, filePath, projectPath, requestToken); + var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, filePath, projectPath, requestToken).ToList(); SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(filePath, record.Lang)); var fileContext = new FileContext(projectPath, record.Path, filePath, record.Lang); postExtractionHooks.OnSymbolsExtracted(fileContext, symbols); - writer.InsertSymbols(symbols); - var references = ReferenceExtractor.Extract( - fileId, - record.Lang, - content, - symbols, - record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null, - requestToken); - postExtractionHooks.OnReferencesExtracted(fileContext, references); - writer.InsertReferences(references); - // Keep MCP index parity with CLI index: persist file-level validation issues too. - // MCPインデックスもCLIインデックスと同等に、ファイル検証issueを保存する。 - var issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); - writer.InsertIssues(fileId, issues); + symbolsDroppedByKindFilter += symbolKindFilter.Apply(symbols); + if (symbols.Count > maxSymbolsPerFile) + { + var issue = BuildMcpSymbolCountExceededIssue(record.Path, symbols.Count, maxSymbolsPerFile); + writer.InsertSymbols([]); + writer.InsertReferences([]); + writer.InsertIssues(fileId, [issue]); + } + else + { + writer.InsertChunks(chunks); + writer.InsertSymbols(symbols); + var references = ReferenceExtractor.Extract( + fileId, + record.Lang, + content, + symbols, + record.Path, + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + requestToken); + postExtractionHooks.OnReferencesExtracted(fileContext, references); + writer.InsertReferences(references); + // Keep MCP index parity with CLI index: persist file-level validation issues too. + // MCPインデックスもCLIインデックスと同等に、ファイル検証issueを保存する。 + var issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); + writer.InsertIssues(fileId, issues); + } WriteProjectRootOnce(); writer.ClearBatchInProgress(); txn.Commit(); @@ -4900,12 +5157,17 @@ static long SumReadableFileBytes(IEnumerable paths) } var (totalFiles, totalChunks, totalSymbols, totalReferences) = writer.GetCounts(); EmitProgressNotification(progressToken, files.Count, files.Count, errors == 0 ? "Indexing complete." : "Indexing completed with errors."); + if (memorySamples != null) + memorySamples.Add(CaptureMcpIndexMemorySample("finalize", runStopwatch)); var structured = new JsonObject { ["path"] = projectPath, ["rebuild"] = rebuild, + ["dry_run"] = false, ["max_file_bytes"] = maxFileBytes, + ["index_options"] = optionsPayload, + ["unsupported_modes"] = unsupportedModesJson, ["summary"] = new JsonObject { ["files"] = totalFiles, @@ -4917,8 +5179,18 @@ static long SumReadableFileBytes(IEnumerable paths) ["purged"] = purged, ["unknown_extension_file_count"] = scanResult.UnknownExtensionFiles.Count, ["errors"] = errors, - ["failed_count"] = failures.Count + ["failed_count"] = failures.Count, + ["symbols_dropped_by_kind_filter"] = symbolsDroppedByKindFilter + }, + ["symbol_kind_filter"] = new JsonObject + { + ["include"] = ToJsonStringArray(symbolKindFilter.Include), + ["exclude"] = ToJsonStringArray(symbolKindFilter.Exclude), + ["active"] = symbolKindFilter.IsActive, }, + ["duration_ms"] = runStopwatch.ElapsedMilliseconds, + ["started_at"] = runStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), + ["completed_at"] = GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture), ["sql_graph_contract_ready"] = sqlGraphContractReadyAfter, ["csharp_symbol_name_ready"] = csharpSymbolNameReadyAfter, ["csharp_metadata_target_ready"] = csharpMetadataTargetReadyAfter, @@ -4927,6 +5199,8 @@ static long SumReadableFileBytes(IEnumerable paths) ["fold_ready"] = foldReadyAfter, ["fold_ready_reason"] = foldReadyReason }; + if (memorySamples != null) + structured["memory_trace"] = memorySamples; if (failures.Count > 0) { var failureArray = new JsonArray(); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index ea7aa44284..c0351b17f4 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -9038,11 +9038,6 @@ public void ToolsCall_Index_MissingPath_ReturnsError() [Theory] [InlineData("db")] - [InlineData("parallelism")] - [InlineData("files")] - [InlineData("commits")] - [InlineData("changedBetween")] - [InlineData("dryRun")] [InlineData("optimize")] public void ToolsCall_Index_RejectsUnsupportedArguments_Issue2848(string argumentName) { @@ -9052,11 +9047,6 @@ public void ToolsCall_Index_RejectsUnsupportedArguments_Issue2848(string argumen [argumentName] = argumentName switch { "db" => JsonValue.Create("alternate.db"), - "parallelism" => JsonValue.Create(2), - "files" => new JsonArray(JsonValue.Create("src/app.cs")), - "commits" => new JsonArray(JsonValue.Create("HEAD")), - "changedBetween" => new JsonArray(JsonValue.Create("HEAD~1"), JsonValue.Create("HEAD")), - "dryRun" => JsonValue.Create(true), "optimize" => JsonValue.Create(true), _ => throw new ArgumentOutOfRangeException(nameof(argumentName), argumentName, null), }, @@ -9082,6 +9072,139 @@ public void ToolsCall_Index_RejectsUnsupportedArguments_Issue2848(string argumen Assert.Equal(argumentName, structured["unknown_argument"]!.GetValue()); } + [Fact] + public void ToolsCall_Index_DryRunReportsAdvancedControlsAndUnsupportedModes_Issue3543() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_dryrun_advanced_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + try + { + File.WriteAllText(Path.Combine(fixtureDir, "app.py"), "class App:\n def run(self):\n return 1\n"); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = new JsonObject + { + ["path"] = fixtureDir, + ["dryRun"] = true, + ["maxSymbolsPerFile"] = 12, + ["followSymlinks"] = "internal", + ["includeSymbolKind"] = new JsonArray(JsonValue.Create("function")), + ["excludeSymbolKind"] = "class", + ["memoryTrace"] = true, + ["parallelism"] = 2, + ["commits"] = new JsonArray(JsonValue.Create("HEAD")), + ["watch"] = true, + ["debounce"] = 25, + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.False(response["result"]?["isError"]?.GetValue() ?? false); + Assert.True(structured["dry_run"]!.GetValue()); + Assert.False(structured["summary"]!["would_mutate_database"]!.GetValue()); + Assert.Equal("internal", structured["index_options"]!["followSymlinks"]!.GetValue()); + Assert.Equal(1, structured["index_options"]!["effective_parallelism"]!.GetValue()); + Assert.NotNull(structured["memory_trace"]); + var unsupportedNames = structured["unsupported_modes"]!.AsArray() + .Select(mode => mode!["name"]!.GetValue()) + .ToHashSet(StringComparer.Ordinal); + Assert.Contains("parallelism", unsupportedNames); + Assert.Contains("commits", unsupportedNames); + Assert.Contains("watch", unsupportedNames); + } + finally + { + TestProjectHelper.DeleteDirectory(fixtureDir); + } + } + + [Fact] + public void ToolsCall_Index_RejectsScopedUnsupportedModesWithoutDryRun_Issue3543() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_scope_reject_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + try + { + File.WriteAllText(Path.Combine(fixtureDir, "app.py"), "def run():\n return 1\n"); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = new JsonObject + { + ["path"] = fixtureDir, + ["files"] = new JsonArray(JsonValue.Create("app.py")), + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var structured = response["result"]!["structuredContent"]!; + Assert.False(structured["index_started"]!.GetValue()); + Assert.Equal("files", Assert.Single(structured["unsupported_modes"]!.AsArray())!["name"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(fixtureDir); + } + } + + [Fact] + public void ToolsCall_Index_AppliesMaxSymbolsPerFile_Issue3543() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_max_symbols_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_index_max_symbols_{Guid.NewGuid():N}.db"); + try + { + File.WriteAllText(Path.Combine(fixtureDir, "app.py"), "class App:\n def one(self):\n return 1\n def two(self):\n return 2\n"); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = new JsonObject + { + ["path"] = fixtureDir, + ["maxSymbolsPerFile"] = 1, + }, + }, + }; + + var response = server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(0, structured["summary"]!["symbols"]!.GetValue()); + Assert.True(structured["summary"]!["errors"]!.GetValue() == 0); + Assert.Equal(1, structured["index_options"]!["maxSymbolsPerFile"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(fixtureDir); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void ToolsCall_UnknownArgumentName_TruncatesDisplay_Issue3117() { From ffcf1e97964c4543d480db753931654b6c1a3176 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:39:29 +0900 Subject: [PATCH 5/8] Expose MCP deps generated-code filtering (#3544) --- changelog.d/unreleased/3544.added.md | 18 +++++++ .../Database/DbReader.Dependencies.cs | 8 ++++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 1 + src/CodeIndex/Mcp/McpToolHandlers.cs | 6 ++- tests/CodeIndex.Tests/McpServerTests.cs | 47 ++++++++++++++++++- 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3544.added.md diff --git a/changelog.d/unreleased/3544.added.md b/changelog.d/unreleased/3544.added.md new file mode 100644 index 0000000000..59bc61ed6d --- /dev/null +++ b/changelog.d/unreleased/3544.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 3544 +affected: + - src/CodeIndex/Database/DbReader.Dependencies.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP `deps` now exposes generated-code filtering (#3544)** — `deps` accepts `includeGenerated`, applies it to dependency source and target files, and returns generated-code scope diagnostics in structured responses. + +## 日本語 + +- **MCP `deps` が generated-code filter に対応しました (#3544)** — `deps` は `includeGenerated` を受け付け、依存関係の source / target file の両方へ適用し、structured response で generated-code scope diagnostic を返します。 diff --git a/src/CodeIndex/Database/DbReader.Dependencies.cs b/src/CodeIndex/Database/DbReader.Dependencies.cs index ab7f2e6b79..eb1219c014 100644 --- a/src/CodeIndex/Database/DbReader.Dependencies.cs +++ b/src/CodeIndex/Database/DbReader.Dependencies.cs @@ -255,6 +255,7 @@ FROM symbol_references r // ある。call-graph 専用コマンド (`callers` / `callees`) 側では metadata // 種別の拒否を CLI / MCP boundary で引き続き行う — そちらは別契約。 sql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "src", "depsLang")}"; + AppendDependencyGeneratedFilter(ref sql, sourceFilterAlias); if (lang != null) sql += " AND src.lang = @lang"; if (!reverse && pathPatterns is { Count: > 0 }) @@ -365,6 +366,7 @@ FROM symbols s JOIN files dst ON s.file_id = dst.id WHERE 1 = 1"; sql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "dst", "depsTargetLang")}"; + AppendDependencyGeneratedFilter(ref sql, targetFilterAlias); if (lang != null) sql += " AND dst.lang = @lang"; if (reverse && pathPatterns is { Count: > 0 }) @@ -554,4 +556,10 @@ LEFT JOIN ranked_edge_symbols } return results; } + + private void AppendDependencyGeneratedFilter(ref string sql, string fileAlias) + { + if (!IncludeGeneratedScope.Value && _fileColumns.Contains("generated")) + sql += $" AND COALESCE({fileAlias}.generated, 0) = 0"; + } } diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 601acb047f..284067ed0c 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -372,6 +372,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict source files to glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include dependency edges whose source or target file is detected as generated code. Defaults to false, matching other query tools.", ["default"] = false }, ["reverse"] = new JsonObject { ["type"] = "boolean", ["description"] = "Reverse lookup: show files that depend ON the matched path", ["default"] = false }, ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "edgelist", "json-graph" }, ["description"] = "Structured response format. `edgelist` preserves the existing edges array; `json-graph` returns nodes and edges.", ["default"] = "edgelist" }, ["cycles"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return dependency cycles instead of ordinary edge rows.", ["default"] = false } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3ab6594b85..297ec54473 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -780,7 +780,7 @@ private static string DescribeJsonType(JsonNode? node) "status" => new HashSet(StringComparer.Ordinal) { "check", "scopes", "staleAfterSeconds", "explain", "config", "logPath", "updateCheck", "format" }, "outline" => new HashSet(StringComparer.Ordinal) { "path" }, "batch_query" => new HashSet(StringComparer.Ordinal) { "queries" }, - "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, + "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "includeGenerated", "project", "solution" }, "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, "languages" => new HashSet(StringComparer.Ordinal) { "indexedOnly", "capability", "extension", "alias" }, "validate" => new HashSet(StringComparer.Ordinal) { "kind", "severity", "limit", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution" }, @@ -3775,6 +3775,7 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var includeGenerated = args?["includeGenerated"]?.GetValue() ?? false; var reverse = args?["reverse"]?.GetValue() ?? false; var cyclesOnly = args?["cycles"]?.GetValue() ?? false; var format = args?["format"]?.GetValue()?.ToLowerInvariant() ?? "edgelist"; @@ -3810,6 +3811,9 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) else payload["edges"] = JsonSerializer.SerializeToNode(outputEdges, _jsonOptions); payload["format"] = format; + payload["includeGenerated"] = includeGenerated; + payload["generated_code_filter_supported"] = true; + payload["generated_code_scope"] = "source_and_target_files"; AddSqlGraphContractSignal(payload, sqlGraphSignal); var summary = payload["count"]!.GetValue() > 0 ? cyclesOnly ? $"Found {ConsoleUi.Counted(cycles.Count, "dependency cycle")}." : $"Found {ConsoleUi.Counted(results.Count, "dependency edge")}." diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index c0351b17f4..121aa1a4af 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4087,6 +4087,21 @@ public void ToolsList_ImpactAnalysisDescribesHeuristicFallback() Assert.Contains("truncated", limitDescription); } + [Fact] + public void ToolsList_DepsExposesGeneratedCodeFilter_Issue3544() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var response = _server.HandleMessage(request)!; + + var tools = response["result"]!["tools"]!.AsArray(); + var depsTool = tools.First(t => t!["name"]!.GetValue() == "deps")!; + var includeGenerated = depsTool["inputSchema"]!["properties"]!["includeGenerated"]!; + + Assert.Equal("boolean", includeGenerated["type"]!.GetValue()); + Assert.False(includeGenerated["default"]!.GetValue()); + Assert.Contains("source or target", includeGenerated["description"]!.GetValue()); + } + [Fact] public void ToolsList_IndexHasRequiredPathParam() { @@ -7213,7 +7228,7 @@ public void ToolsCall_DepsCyclesUsesGraphBudgetBeyondDisplayLimit_Issue3185() Assert.All(nodes, node => Assert.StartsWith("src/Cycle", node)); } - private static long InsertDependencyFile(DbWriter writer, string path) + private static long InsertDependencyFile(DbWriter writer, string path, bool generated = false) { return writer.UpsertFile(new FileRecord { @@ -7223,6 +7238,7 @@ private static long InsertDependencyFile(DbWriter writer, string path) Lines = 1, Modified = new DateTime(2026, 6, 6, 0, 0, 0, DateTimeKind.Utc), Checksum = Guid.NewGuid().ToString("N"), + Generated = generated, }); } @@ -7275,6 +7291,35 @@ public void ToolsCall_Deps_JsonGraph_ReturnsGraphPayload() } } + [Fact] + public void ToolsCall_Deps_IncludeGeneratedControlsGeneratedEdges_Issue3544() + { + var writer = new DbWriter(_db.Connection); + var targetId = InsertDependencyFile(writer, "src/DependencyTarget.cs"); + var generatedSourceId = InsertDependencyFile(writer, "src/GeneratedSource.g.cs", generated: true); + InsertDependencySymbols(writer, targetId, ["DependencyTarget"]); + InsertDependencyReferences(writer, generatedSourceId, ["DependencyTarget"]); + + var defaultRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deps","arguments":{"path":"src/GeneratedSource.g.cs","lang":"csharp"}}}""")!; + var defaultResponse = _server.HandleMessage(defaultRequest)!; + var defaultStructured = defaultResponse["result"]!["structuredContent"]!; + + Assert.Equal(0, defaultStructured["count"]!.GetValue()); + Assert.False(defaultStructured["includeGenerated"]!.GetValue()); + Assert.True(defaultStructured["generated_code_filter_supported"]!.GetValue()); + Assert.Equal("source_and_target_files", defaultStructured["generated_code_scope"]!.GetValue()); + + var includeRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"deps","arguments":{"path":"src/GeneratedSource.g.cs","lang":"csharp","includeGenerated":true}}}""")!; + var includeResponse = _server.HandleMessage(includeRequest)!; + var includeStructured = includeResponse["result"]!["structuredContent"]!; + var edge = Assert.Single(includeStructured["edges"]!.AsArray()); + + Assert.Equal(1, includeStructured["count"]!.GetValue()); + Assert.True(includeStructured["includeGenerated"]!.GetValue()); + Assert.Equal("src/GeneratedSource.g.cs", edge!["sourcePath"]!.GetValue()); + Assert.Equal("src/DependencyTarget.cs", edge["targetPath"]!.GetValue()); + } + [Fact] public void ToolsCall_Hotspots_ZeroResultSqlScopeStillIncludesDegradedState() { From 8b3cc80e9b6b1a25675d3db5ba2f27d6e0661d14 Mon Sep 17 00:00:00 2001 From: Widthdom <125688807+Widthdom@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:55:41 +0900 Subject: [PATCH 6/8] Fix MCP tool contract tests (#3541 #3543 #3544) --- tests/CodeIndex.Tests/McpToolContractTests.cs | 62 +++++++++++++------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index 066cde44e1..8b50c407ab 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -14,17 +14,25 @@ public class McpToolContractTests private static readonly HashSet<(string Tool, string Argument)> HiddenCompatibilityAliases = [ + ("index", "dry_run"), ("backfill_fold", "dryRun"), ("suggest_improvement", "evidence_paths"), ]; - private static readonly HashSet SpecializedListValidatedArguments = new(StringComparer.Ordinal) + private static readonly HashSet SpecializedArrayValidatedArguments = new(StringComparer.Ordinal) { "excludePaths", "names", "sections", }; + private static readonly HashSet SpecializedStringOrArrayValidatedArguments = new(StringComparer.Ordinal) + { + "commits", + "changedBetween", + "files", + }; + [Fact] public void ToolsList_AdvertisedInputPropertiesMatchArgumentAllowlist_Issue3199() { @@ -73,7 +81,7 @@ public void ToolsList_AdvertisedInputPropertiesHaveMatchingTypeValidation_Issue3 continue; } - if (SpecializedListValidatedArguments.Contains(argumentName)) + if (SpecializedArrayValidatedArguments.Contains(argumentName)) { if (schemaType != "array") { @@ -85,6 +93,18 @@ public void ToolsList_AdvertisedInputPropertiesHaveMatchingTypeValidation_Issue3 continue; } + if (SpecializedStringOrArrayValidatedArguments.Contains(argumentName)) + { + if (schemaType != "string_or_array") + { + failures.Add( + $"{toolName}.{argumentName}: schema={schemaType}; " + + "specialized_list_validator=string_or_array"); + } + + continue; + } + var (hasValidator, validatorType) = TryGetExpectedJsonType(toolName, argumentName); if (!hasValidator || validatorType != schemaType) { @@ -120,7 +140,7 @@ public void ToolsList_DepsArgumentsHaveSharedArgumentContract_Issue3196() var depsProperties = GetAdvertisedToolSchemas()["deps"]; var allowed = GetAllowedToolArguments("deps"); - foreach (var argumentName in new[] { "reverse", "format", "cycles" }) + foreach (var argumentName in new[] { "reverse", "format", "cycles", "includeGenerated" }) { Assert.True(depsProperties.ContainsKey(argumentName)); Assert.Contains(argumentName, allowed); @@ -128,15 +148,14 @@ public void ToolsList_DepsArgumentsHaveSharedArgumentContract_Issue3196() Assert.False(depsProperties.ContainsKey("direction")); Assert.DoesNotContain("direction", allowed); - Assert.False(depsProperties.ContainsKey("includeGenerated")); - Assert.DoesNotContain("includeGenerated", allowed); - Assert.Equal("boolean", ExpectedTypeFromSchema(depsProperties["reverse"])); Assert.Equal("string", ExpectedTypeFromSchema(depsProperties["format"])); Assert.Equal("boolean", ExpectedTypeFromSchema(depsProperties["cycles"])); + Assert.Equal("boolean", ExpectedTypeFromSchema(depsProperties["includeGenerated"])); Assert.Equal((true, "boolean"), TryGetExpectedJsonType("deps", "reverse")); Assert.Equal((true, "string"), TryGetExpectedJsonType("deps", "format")); Assert.Equal((true, "boolean"), TryGetExpectedJsonType("deps", "cycles")); + Assert.Equal((true, "boolean"), TryGetExpectedJsonType("deps", "includeGenerated")); } [Fact] @@ -148,7 +167,7 @@ public void ToolsList_MapSectionsAndDepthHaveSharedArgumentContract_Issue3197() Assert.True(mapProperties.ContainsKey("sections")); Assert.Contains("sections", allowed); Assert.Equal("array", ExpectedTypeFromSchema(mapProperties["sections"])); - Assert.Contains("sections", SpecializedListValidatedArguments); + Assert.Contains("sections", SpecializedArrayValidatedArguments); Assert.Equal((false, string.Empty), TryGetExpectedJsonType("map", "sections")); Assert.True(mapProperties.ContainsKey("depth")); @@ -163,18 +182,10 @@ public void ToolsList_OutlineAndValidateDoNotExposeHiddenNoopArguments_Issue3198 var advertisedSchemas = GetAdvertisedToolSchemas(); AssertToolArgumentsExactly(advertisedSchemas, "outline", ["path"]); - AssertToolArgumentsExactly(advertisedSchemas, "validate", ["kind", "path", "excludePaths", "excludeTests", "project", "solution"]); + AssertToolArgumentsExactly(advertisedSchemas, "validate", ["kind", "severity", "limit", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution"]); - foreach (var toolName in new[] { "outline", "validate" }) - { - var advertised = advertisedSchemas[toolName].Keys.ToHashSet(StringComparer.Ordinal); - var allowed = GetAllowedToolArguments(toolName); - foreach (var noopArgument in new[] { "limit", "includeImports", "maxLineWidth", "lang" }) - { - Assert.DoesNotContain(noopArgument, advertised); - Assert.DoesNotContain(noopArgument, allowed); - } - } + AssertNoopArgumentsAbsent(advertisedSchemas, "outline", ["limit", "includeImports", "maxLineWidth", "lang"]); + AssertNoopArgumentsAbsent(advertisedSchemas, "validate", ["includeImports", "maxLineWidth", "lang"]); static void AssertToolArgumentsExactly( Dictionary> advertisedSchemas, @@ -188,6 +199,20 @@ static void AssertToolArgumentsExactly( Assert.Equal(expected.Order(StringComparer.Ordinal), advertised.Order(StringComparer.Ordinal)); Assert.Equal(expected.Order(StringComparer.Ordinal), allowed.Order(StringComparer.Ordinal)); } + + static void AssertNoopArgumentsAbsent( + Dictionary> advertisedSchemas, + string toolName, + string[] noopArguments) + { + var advertised = advertisedSchemas[toolName].Keys.ToHashSet(StringComparer.Ordinal); + var allowed = GetAllowedToolArguments(toolName); + foreach (var noopArgument in noopArguments) + { + Assert.DoesNotContain(noopArgument, advertised); + Assert.DoesNotContain(noopArgument, allowed); + } + } } private static Dictionary> GetAdvertisedToolSchemas() @@ -262,6 +287,7 @@ private static (bool HasValidator, string ValidatorType) TryGetExpectedJsonType( "array" => "array", "boolean" => "boolean", "integer" => "integer", + "number" => "number", "string" => "string", _ => null, }; From 44695f8b5143580bec1d42943ba7c7c0b0737a50 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 20:26:29 +0900 Subject: [PATCH 7/8] Keep MCP index reuse aligned with symbol options (#3543) --- src/CodeIndex/Cli/IndexCommandRunner.cs | 2 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 29 ++++- tests/CodeIndex.Tests/McpServerTests.cs | 149 ++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index f23051bc2a..70feeb3694 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -24,7 +24,7 @@ public static partial class IndexCommandRunner internal const int MaxCommitRefCount = 64; internal const int MaxCommitRefLength = 256; internal const int MaxGitExcludeBytes = 256 * 1024; - private const string SymbolKindFilterMetaKey = "index_symbol_kind_filter"; + internal const string SymbolKindFilterMetaKey = "index_symbol_kind_filter"; private const int ScanCheckpointVersion = 1; private const string ScanCheckpointFileName = "scan-checkpoint.json"; private static readonly TimeSpan IndexExtractionStallTimeout = TimeSpan.FromMinutes(5); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 4829d956a2..b95ce8d2e2 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -45,6 +45,7 @@ public partial class McpServer "followSymlinks", }; internal const int MaxMcpIndexFailureMessageLength = 512; + internal static Action? McpIndexFileCommittedForTesting { get; set; } private QueryCommandRunner.ProjectFilterRootResolution? _projectFilterRootResolutionForCurrentToolCall; // --- Tool implementations / ツール実装 --- @@ -5341,6 +5342,7 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso var priorHotspotFamilyVersions = GetHotspotFamilyMetaSnapshot(db, DbContext.GetHotspotFamilyVersionMetaKey); var priorHotspotFamilyMarkerFingerprints = GetHotspotFamilyMetaSnapshot(db, DbContext.GetHotspotFamilyMarkerFingerprintMetaKey); var priorIndexedProjectRoot = db.GetMetaString(DbContext.IndexedProjectRootMetaKey); + var priorSymbolKindFilterSignature = db.GetMetaString(IndexCommandRunner.SymbolKindFilterMetaKey); var requestToken = _currentRequestToken.Value; requestToken.ThrowIfCancellationRequested(); // Capture git HEAD so subsequent queries can detect a worktree branch / HEAD switch @@ -5388,6 +5390,11 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso priorHotspotFamilyVersions, priorHotspotFamilyMarkerFingerprints, currentHotspotFamilyMarkerFingerprints); + var symbolKindFilterMatchesPrior = string.Equals( + priorSymbolKindFilterSignature, + symbolKindFilter.Signature, + StringComparison.Ordinal); + var symbolKindFilterMetaMarkedIncomplete = symbolKindFilterMatchesPrior; var normalizedProjectPath = Path.GetFullPath(projectPath); var normalizedPriorIndexedProjectRoot = string.IsNullOrWhiteSpace(priorIndexedProjectRoot) ? null @@ -5411,6 +5418,14 @@ void WriteProjectRootOnce() } } + void MarkSymbolKindFilterMetaIncompleteOnce() + { + if (symbolKindFilterMetaMarkedIncomplete) + return; + writer.SetMeta(IndexCommandRunner.SymbolKindFilterMetaKey, null); + symbolKindFilterMetaMarkedIncomplete = true; + } + static long SumReadableFileBytes(IEnumerable paths) { long total = 0; @@ -5479,12 +5494,21 @@ static long SumReadableFileBytes(IEnumerable paths) record.Checksum, size: record.Size, language: record.Lang, - allowReuse: record.Lang is not ("javascript" or "typescript") + allowReuse: symbolKindFilterMatchesPrior + && record.Lang is not ("javascript" or "typescript") && (record.Lang != "csharp" || csharpSymbolNameContractMatchesCurrent) && (record.Lang != "csharp" || !csharpWorkspace.HasStaticInterfaceContracts) && (record.Lang != "sql" || sqlGraphContractMatchesCurrent) && AllowReuseWithCurrentHotspotFamilyTrust(record.Lang, hotspotFamilyTrustMatchesCurrent)); if (existingId != null) + { + if (writer.CountSymbolsForFile(existingId.Value) > maxSymbolsPerFile + || writer.HasIssueForFile(existingId.Value, "symbol_count_exceeded")) + { + existingId = null; + } + } + if (existingId != null) { skipped++; processed++; @@ -5495,6 +5519,7 @@ static long SumReadableFileBytes(IEnumerable paths) writer.MarkBatchInProgress(); fileBatchMarked = true; + MarkSymbolKindFilterMetaIncompleteOnce(); using var txn = writer.BeginTransaction(); var fileId = writer.UpsertFile(record); var chunks = ChunkSplitter.Split(fileId, content); @@ -5532,6 +5557,7 @@ static long SumReadableFileBytes(IEnumerable paths) WriteProjectRootOnce(); writer.ClearBatchInProgress(); txn.Commit(); + McpIndexFileCommittedForTesting?.Invoke(record.Path); } catch (FileIndexer.BinaryFileSkippedException) { @@ -5669,6 +5695,7 @@ static long SumReadableFileBytes(IEnumerable paths) } writer.WriteCdidxWriterVersion(_version); + writer.SetMeta(IndexCommandRunner.SymbolKindFilterMetaKey, symbolKindFilter.Signature); // Successful no-op MCP full scans should repair explicit-DB roots only after // readiness is stamped, preserving the failure-path safety contract. diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 05a1ee1717..cfe2323f8b 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -176,6 +176,46 @@ private void InsertIndexedFile(string path, string lang, string content, bool ge writer.InsertReferences(ReferenceExtractor.Extract(fileId, lang, normalized, symbols)); } + private static JsonNode CallIndex(McpServer server, string path, Action? configure = null) + { + var arguments = new JsonObject + { + ["path"] = path, + }; + configure?.Invoke(arguments); + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = arguments, + }, + }; + + return server.HandleMessage(request)!; + } + + private static Dictionary ReadSymbolKindCounts(string dbPath) + { + var counts = new Dictionary(StringComparer.Ordinal); + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + }.ToString(); + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT kind, COUNT(*) FROM symbols GROUP BY kind"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + counts[reader.GetString(0)] = reader.GetInt32(1); + return counts; + } + private void MarkFoldReady() { var writer = new DbWriter(_db.Connection); @@ -9962,6 +10002,115 @@ public void ToolsCall_Index_AppliesMaxSymbolsPerFile_Issue3543() } } + [Fact] + public void ToolsCall_Index_ReprocessesUnchangedFilesWhenMaxSymbolsPerFileChanges_Issue3543() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_max_symbols_reuse_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_index_max_symbols_reuse_{Guid.NewGuid():N}.db"); + try + { + File.WriteAllText(Path.Combine(fixtureDir, "app.py"), "class App:\n def one(self):\n return 1\n def two(self):\n return 2\n"); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); + + var firstResponse = CallIndex(server, fixtureDir); + + Assert.False(firstResponse["result"]?["isError"]?.GetValue() ?? false); + Assert.True(ReadSymbolKindCounts(dbPath).Values.Sum() > 1); + + var secondResponse = CallIndex(server, fixtureDir, args => args["maxSymbolsPerFile"] = 1); + + Assert.False(secondResponse["result"]?["isError"]?.GetValue() ?? false); + var structured = secondResponse["result"]!["structuredContent"]!; + Assert.Equal(0, structured["summary"]!["symbols"]!.GetValue()); + Assert.Empty(ReadSymbolKindCounts(dbPath)); + } + finally + { + TestProjectHelper.DeleteDirectory(fixtureDir); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + + [Fact] + public void ToolsCall_Index_ReprocessesUnchangedFilesWhenSymbolKindFilterChanges_Issue3543() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_symbol_filter_reuse_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_index_symbol_filter_reuse_{Guid.NewGuid():N}.db"); + try + { + File.WriteAllText(Path.Combine(fixtureDir, "app.py"), "class App:\n def run(self):\n return 1\n"); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); + + var firstResponse = CallIndex(server, fixtureDir); + + Assert.False(firstResponse["result"]?["isError"]?.GetValue() ?? false); + var firstCounts = ReadSymbolKindCounts(dbPath); + Assert.True(firstCounts.GetValueOrDefault("function") > 0); + + var secondResponse = CallIndex(server, fixtureDir, args => args["excludeSymbolKind"] = "function"); + + Assert.False(secondResponse["result"]?["isError"]?.GetValue() ?? false); + var secondCounts = ReadSymbolKindCounts(dbPath); + Assert.True(secondCounts.GetValueOrDefault("class") > 0); + Assert.False(secondCounts.ContainsKey("function")); + } + finally + { + TestProjectHelper.DeleteDirectory(fixtureDir); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + + [Fact] + public void ToolsCall_Index_ReprocessesAfterPartialSymbolKindFilterChange_Issue3543() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_symbol_filter_partial_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_index_symbol_filter_partial_{Guid.NewGuid():N}.db"); + try + { + File.WriteAllText(Path.Combine(fixtureDir, "app.py"), "class App:\n def run(self):\n return 1\n"); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); + + var firstResponse = CallIndex(server, fixtureDir); + + Assert.False(firstResponse["result"]?["isError"]?.GetValue() ?? false); + Assert.True(ReadSymbolKindCounts(dbPath).GetValueOrDefault("function") > 0); + + var throwOnce = true; + McpServer.McpIndexFileCommittedForTesting = _ => + { + if (!throwOnce) + return; + throwOnce = false; + throw new InvalidOperationException("forced partial MCP index failure"); + }; + + var partialResponse = CallIndex(server, fixtureDir, args => args["excludeSymbolKind"] = "function"); + + var partialStructured = partialResponse["result"]!["structuredContent"]!; + Assert.True(partialStructured["summary"]!["errors"]!.GetValue() > 0); + Assert.False(ReadSymbolKindCounts(dbPath).ContainsKey("function")); + + McpServer.McpIndexFileCommittedForTesting = null; + var finalResponse = CallIndex(server, fixtureDir); + + Assert.False(finalResponse["result"]?["isError"]?.GetValue() ?? false); + Assert.True(ReadSymbolKindCounts(dbPath).GetValueOrDefault("function") > 0); + } + finally + { + McpServer.McpIndexFileCommittedForTesting = null; + TestProjectHelper.DeleteDirectory(fixtureDir); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void ToolsCall_UnknownArgumentName_TruncatesDisplay_Issue3117() { From 50c89277412b25e3958caa4b0b4a20d0351439e6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 22:12:54 +0900 Subject: [PATCH 8/8] Harden MCP index test cleanup on Windows (#3543) --- tests/CodeIndex.Tests/McpServerTests.cs | 39 ++++++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index cfe2323f8b..fa096bad8b 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -216,6 +216,33 @@ private static Dictionary ReadSymbolKindCounts(string dbPath) return counts; } + private static void DeleteSqliteDatabaseFiles(string dbPath) + { + DeleteFileWithRetry(dbPath); + DeleteFileWithRetry(dbPath + "-wal"); + DeleteFileWithRetry(dbPath + "-shm"); + } + + private static void DeleteFileWithRetry(string path) + { + const int maxAttempts = 5; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + SqliteConnection.ClearAllPools(); + if (File.Exists(path)) + File.Delete(path); + return; + } + catch (Exception ex) when (attempt < maxAttempts && ex is IOException or UnauthorizedAccessException) + { + Thread.Sleep(50 * attempt); + } + } + } + private void MarkFoldReady() { var writer = new DbWriter(_db.Connection); @@ -9997,8 +10024,7 @@ public void ToolsCall_Index_AppliesMaxSymbolsPerFile_Issue3543() finally { TestProjectHelper.DeleteDirectory(fixtureDir); - if (File.Exists(dbPath)) - File.Delete(dbPath); + DeleteSqliteDatabaseFiles(dbPath); } } @@ -10028,8 +10054,7 @@ public void ToolsCall_Index_ReprocessesUnchangedFilesWhenMaxSymbolsPerFileChange finally { TestProjectHelper.DeleteDirectory(fixtureDir); - if (File.Exists(dbPath)) - File.Delete(dbPath); + DeleteSqliteDatabaseFiles(dbPath); } } @@ -10060,8 +10085,7 @@ public void ToolsCall_Index_ReprocessesUnchangedFilesWhenSymbolKindFilterChanges finally { TestProjectHelper.DeleteDirectory(fixtureDir); - if (File.Exists(dbPath)) - File.Delete(dbPath); + DeleteSqliteDatabaseFiles(dbPath); } } @@ -10106,8 +10130,7 @@ public void ToolsCall_Index_ReprocessesAfterPartialSymbolKindFilterChange_Issue3 { McpServer.McpIndexFileCommittedForTesting = null; TestProjectHelper.DeleteDirectory(fixtureDir); - if (File.Exists(dbPath)) - File.Delete(dbPath); + DeleteSqliteDatabaseFiles(dbPath); } }