From 4a35270150e19989b6695f29d28a4f7499257615 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:01:20 +0900 Subject: [PATCH 01/13] Fix MCP pagination argument validation (#3195) --- changelog.d/unreleased/3195.fixed.md | 16 +++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 28 ++++++++++++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 35 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 changelog.d/unreleased/3195.fixed.md diff --git a/changelog.d/unreleased/3195.fixed.md b/changelog.d/unreleased/3195.fixed.md new file mode 100644 index 0000000000..46b133e31c --- /dev/null +++ b/changelog.d/unreleased/3195.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3195 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP pagination now rejects invalid lower bounds (#3195)** — non-positive `limit` values and negative `offset` values now return structured invalid-params errors instead of being silently clamped. + +## 日本語 + +- **MCP pagination が不正な下限値を拒否するようになりました (#3195)** — 非正の `limit` と負の `offset` は、黙って補正されず structured invalid-params error を返すようになりました。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index d95aacb6d6..3f287d5c64 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -536,12 +536,40 @@ private static List ReadStringList(JsonNode? args, string propertyName) if (ValidateToolArgumentTypes(toolName, obj) is JsonObject typeError) return typeError; + if (ValidateToolArgumentRanges(toolName, obj) is JsonObject rangeError) + return rangeError; + if (ValidateBoundedEnumLikeScalarArguments(toolName, obj) is JsonObject scalarError) return scalarError; return null; } + private static JsonObject? ValidateToolArgumentRanges(string toolName, JsonObject args) + { + if (args["limit"] is JsonValue limitValue + && limitValue.TryGetValue(out var limit) + && limit <= 0) + return CreateIntegerMinimumArgumentError(toolName, "limit", minimum: 1, actual: limit); + + if (args["offset"] is JsonValue offsetValue + && offsetValue.TryGetValue(out var offset) + && offset < 0) + return CreateIntegerMinimumArgumentError(toolName, "offset", minimum: 0, actual: offset); + + return null; + } + + private static JsonObject CreateIntegerMinimumArgumentError(string toolName, string argumentName, int minimum, int actual) => new() + { + ["message"] = $"Argument '{argumentName}' on tool '{toolName}' must be greater than or equal to {minimum}; got {actual}.", + ["tool"] = toolName, + ["parameter"] = argumentName, + ["minimum"] = minimum, + ["actual"] = actual, + ["jsonrpc_invalid_params"] = true, + }; + private static JsonObject? ValidateBoundedEnumLikeScalarArguments(string toolName, JsonObject args) { foreach (var property in args) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 2ad039817d..f49b9fc01f 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -3363,6 +3363,41 @@ public void ToolsList_CommonSchemasAdvertiseClientSideConstraints() Assert.Contains("type_reference", kindEnum); } + [Theory] + [InlineData("search", """{"query":"App","limit":0}""", "limit", 1, 0)] + [InlineData("definition", """{"query":"App","limit":-1}""", "limit", 1, -1)] + [InlineData("references", """{"query":"App","offset":-1}""", "offset", 0, -1)] + public void ToolsCall_InvalidLimitOrOffsetBounds_ReturnsInvalidParams_Issue3195( + string toolName, + string argumentsJson, + string parameter, + int minimum, + int actual) + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = toolName, + ["arguments"] = JsonNode.Parse(argumentsJson), + }, + }; + + var response = _server.HandleMessage(request)!; + + var error = response["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + Assert.Contains($"Argument '{parameter}'", error["message"]!.GetValue()); + var data = error["data"]!; + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, data["category"]!.GetValue()); + Assert.Equal(parameter, data["parameter"]!.GetValue()); + Assert.Equal(minimum, data["minimum"]!.GetValue()); + Assert.Equal(actual, data["actual"]!.GetValue()); + } + [Fact] public void ToolCall_WithStructuredContent_DeclaresJsonMimeType() { From 0d0c3415dc11ff166c1e91db99a9df6bc116dafd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:04:06 +0900 Subject: [PATCH 02/13] Fix MCP since validation (#3194) --- changelog.d/unreleased/3194.fixed.md | 16 +++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 56 +++++++++++++------------ tests/CodeIndex.Tests/McpServerTests.cs | 29 +++++++++++++ 3 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 changelog.d/unreleased/3194.fixed.md diff --git a/changelog.d/unreleased/3194.fixed.md b/changelog.d/unreleased/3194.fixed.md new file mode 100644 index 0000000000..1539f14ea7 --- /dev/null +++ b/changelog.d/unreleased/3194.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3194 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP `definition` and `symbols` now reject invalid `since` values (#3194)** — invalid ISO 8601 timestamps now return the same structured invalid-argument response used by other MCP tools instead of silently dropping the freshness filter. + +## 日本語 + +- **MCP `definition` / `symbols` が不正な `since` 値を拒否するようになりました (#3194)** — 不正な ISO 8601 timestamp は freshness filter を黙って無視せず、他の MCP tool と同じ structured invalid-argument response を返します。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3f287d5c64..f53be20093 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1027,6 +1027,28 @@ private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) return paths.Count == 0 ? null : paths; } + private static bool TryReadSinceArgument(JsonNode? args, out DateTime? since, out string? error) + { + var sinceStr = args?["since"]?.GetValue(); + if (sinceStr == null) + { + since = null; + error = null; + return true; + } + + if (QueryCommandRunner.TryParseIso8601Since(sinceStr, out var parsedSince)) + { + since = parsedSince; + error = null; + return true; + } + + since = null; + error = $"Invalid 'since' timestamp: '{sinceStr}'. Use ISO 8601 format (e.g. 2024-01-01 or 2024-01-01T00:00:00Z)."; + return false; + } + private static bool TryReadRequiredStringParameter(JsonNode? args, string propertyName, out string value, out string? error) { var node = args?[propertyName]; @@ -1147,15 +1169,8 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - var sinceStr = args?["since"]?.GetValue(); - DateTime? since = null; - if (sinceStr != null) - { - if (QueryCommandRunner.TryParseIso8601Since(sinceStr, out var parsedSince)) - since = parsedSince; - else - return CreateToolErrorResponse(id, $"Invalid 'since' timestamp: '{sinceStr}'. Use ISO 8601 format (e.g. 2024-01-01 or 2024-01-01T00:00:00Z)."); - } + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); var deduplicate = !(args?["noDedup"]?.GetValue() ?? false); var format = ReadResponseFormat(args); if (ValidateResponseFormat(format) is string formatError) @@ -1306,10 +1321,8 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - var sinceStr = args?["since"]?.GetValue(); - DateTime? since = null; - if (sinceStr != null && QueryCommandRunner.TryParseIso8601Since(sinceStr, out var parsedSince)) - since = parsedSince; + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); if (!TryResolveNameExactArgument(args, "symbols", out var exact, out var exactError)) return CreateToolErrorResponse(id, exactError!); @@ -1401,10 +1414,8 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - var sinceStr = args?["since"]?.GetValue(); - DateTime? since = null; - if (sinceStr != null && QueryCommandRunner.TryParseIso8601Since(sinceStr, out var parsedDefSince)) - since = parsedDefSince; + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); if (!TryResolveNameExactArgument(args, "definition", out var exact, out var exactError)) return CreateToolErrorResponse(id, exactError!); var format = ReadResponseFormat(args); @@ -1766,15 +1777,8 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - var sinceStr = args?["since"]?.GetValue(); - DateTime? since = null; - if (sinceStr != null) - { - if (QueryCommandRunner.TryParseIso8601Since(sinceStr, out var parsedSince)) - since = parsedSince; - else - return CreateToolErrorResponse(id, $"Invalid 'since' timestamp: '{sinceStr}'. Use ISO 8601 format (e.g. 2024-01-01 or 2024-01-01T00:00:00Z)."); - } + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); return WithDbReader(id, args, reader => { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index f49b9fc01f..14a40eff5e 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1292,6 +1292,35 @@ public void ToolCall_RequiredQuery_DistinguishesMissingFromWhitespace(string too Assert.Equal("Parameter \"query\" cannot be empty or whitespace-only", blank); } + [Theory] + [InlineData("definition")] + [InlineData("symbols")] + public void ToolCall_InvalidSince_ReturnsInvalidArgument_Issue3194(string toolName) + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = toolName, + ["arguments"] = new JsonObject + { + ["query"] = "App", + ["since"] = "not-a-timestamp", + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + var result = response["result"]!; + Assert.True(result["isError"]!.GetValue()); + Assert.Contains("Invalid 'since' timestamp", result["content"]![0]!["text"]!.GetValue()); + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, result["structuredContent"]!["category"]!.GetValue()); + } + [Theory] [InlineData("outline")] [InlineData("excerpt")] From a29cf4d36574eb1b13aa87aefb8923b7e33435e1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:07:19 +0900 Subject: [PATCH 03/13] Fix MCP impact query length validation (#3184) --- changelog.d/unreleased/3184.fixed.md | 16 +++++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 2 ++ tests/CodeIndex.Tests/McpServerTests.cs | 26 +++++++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 changelog.d/unreleased/3184.fixed.md diff --git a/changelog.d/unreleased/3184.fixed.md b/changelog.d/unreleased/3184.fixed.md new file mode 100644 index 0000000000..39122e6a67 --- /dev/null +++ b/changelog.d/unreleased/3184.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3184 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP `impact_analysis` now enforces the shared query length limit (#3184)** — oversized impact queries now fail with the same structured invalid-argument response used by other query tools before impact analysis runs. + +## 日本語 + +- **MCP `impact_analysis` が共有 query 長上限を適用するようになりました (#3184)** — 長すぎる impact query は impact analysis 実行前に、他の query tool と同じ structured invalid-argument response で失敗します。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index f53be20093..3f033303fb 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3145,6 +3145,8 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) { if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); + if (query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); if (IsBareVerbatimQueryToken(query)) return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 14a40eff5e..7d792f630a 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4078,6 +4078,32 @@ public void ToolsCall_BareVerbatimPrefix_IsRejected(string toolName) Assert.Contains("bare verbatim prefixes like `@` are not valid queries", text); } + [Fact] + public void ToolsCall_ImpactAnalysis_RejectsOversizedQuery_Issue3184() + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "impact_analysis", + ["arguments"] = new JsonObject + { + ["query"] = new string('a', QueryLimits.MaxQueryLength + 1), + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + var result = response["result"]!; + Assert.True(result["isError"]!.GetValue()); + Assert.Equal(QueryLimits.FormatQueryTooLongError(), result["content"]![0]!["text"]!.GetValue()); + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, result["structuredContent"]!["category"]!.GetValue()); + } + [Fact] public void ToolsCall_Search_SnippetLinesControlsExcerptLength() { From 72adc714dceb11891fbdfffd70f3de4e32686c2f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:15:44 +0900 Subject: [PATCH 04/13] Fix MCP path list validation bounds (#3182) --- changelog.d/unreleased/3182.fixed.md | 17 ++++++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 38 ++++++++++-- src/CodeIndex/Mcp/McpToolHandlers.cs | 4 +- tests/CodeIndex.Tests/McpServerTests.cs | 77 ++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/3182.fixed.md diff --git a/changelog.d/unreleased/3182.fixed.md b/changelog.d/unreleased/3182.fixed.md new file mode 100644 index 0000000000..0d3ef7f36b --- /dev/null +++ b/changelog.d/unreleased/3182.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3182 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP path/list argument validation now shares CLI path-filter bounds (#3182)** — path-like arrays now use the CLI count and length limits, and `tools/list` advertises those bounds for client-side validation. + +## 日本語 + +- **MCP path/list 引数検証が CLI の path-filter 上限を共有するようになりました (#3182)** — path 系配列は CLI と同じ件数・長さ上限を使い、`tools/list` でもクライアント側検証用にその上限を広告します。 diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 2ee00a335e..2b24813521 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -643,13 +643,41 @@ private static void ApplyCommonSchemaConstraint(string toolName, string name, Js case "path": case "project": case "solution": - obj.TryAdd("minLength", 1); - obj.TryAdd("maxLength", 4096); - obj.TryAdd("pattern", @"^(?!/)(?![A-Za-z]:)(?!.*(^|/)\.\.(/|$))(?!.*\u0000).*$"); - AppendConstraintDescription(obj, "Must be workspace-relative, non-empty, and must not contain NUL bytes or `..` path traversal segments."); + if (obj["type"]?.GetValue() == "array") + { + obj.TryAdd("maxItems", MaxMcpArrayFilterCount); + } + else + { + obj.TryAdd("minLength", 1); + obj.TryAdd("maxLength", MaxMcpArrayFilterStringLength); + obj.TryAdd("pattern", @"^(?!/)(?![A-Za-z]:)(?!.*(^|/)\.\.(/|$))(?!.*\u0000).*$"); + AppendConstraintDescription(obj, "Must be workspace-relative, non-empty, and must not contain NUL bytes or `..` path traversal segments."); + } break; case "excludePaths": - obj.TryAdd("maxItems", 100); + if (obj["type"]?.GetValue() == "array") + { + obj.TryAdd("maxItems", MaxMcpArrayFilterCount); + } + else + { + obj.TryAdd("minLength", 1); + obj.TryAdd("maxLength", MaxMcpArrayFilterStringLength); + obj.TryAdd("pattern", @"^(?!/)(?![A-Za-z]:)(?!.*(^|/)\.\.(/|$))(?!.*\u0000).*$"); + AppendConstraintDescription(obj, "Must be workspace-relative, non-empty, and must not contain NUL bytes or `..` path traversal segments."); + } + break; + case "sections": + if (obj["type"]?.GetValue() == "array") + { + obj.TryAdd("maxItems", MaxMcpArrayFilterCount); + } + else + { + obj.TryAdd("minLength", 1); + obj.TryAdd("maxLength", MaxMcpArrayFilterStringLength); + } break; case "limit": obj.TryAdd("minimum", 1); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3f033303fb..4b5e8910b9 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -23,8 +23,8 @@ public partial class McpServer internal const int MaxBatchQueryResponseByteLimit = 10 * 1024 * 1024; private const int DefaultExcerptOutputByteLimit = MaxLineByteLength; private const string BatchQueryResponseByteLimitEnvVar = "CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES"; - internal const int MaxMcpArrayFilterCount = 100; - internal const int MaxMcpArrayFilterStringLength = 4096; + internal const int MaxMcpArrayFilterCount = QueryCommandRunner.MaxQueryPathFilterCount; + internal const int MaxMcpArrayFilterStringLength = QueryCommandRunner.MaxQueryPathFilterLength; private static readonly HashSet BoundedEnumLikeScalarArguments = new(StringComparer.Ordinal) { "category", diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 7d792f630a..39d1952005 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -3381,8 +3381,14 @@ public void ToolsList_CommonSchemasAdvertiseClientSideConstraints() Assert.Equal(200, searchProperties["limit"]!["maximum"]!.GetValue()); var pathStringSchema = searchProperties["path"]!["oneOf"]!.AsArray()[0]!; - Assert.Equal(4096, pathStringSchema["maxLength"]!.GetValue()); + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, pathStringSchema["maxLength"]!.GetValue()); Assert.NotNull(pathStringSchema["pattern"]); + var pathArraySchema = searchProperties["path"]!["oneOf"]!.AsArray()[1]!; + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterCount, pathArraySchema["maxItems"]!.GetValue()); + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, pathArraySchema["items"]!["maxLength"]!.GetValue()); + var excludePathsSchema = searchProperties["excludePaths"]!; + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterCount, excludePathsSchema["maxItems"]!.GetValue()); + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, excludePathsSchema["items"]!["maxLength"]!.GetValue()); var referencesTool = tools.First(t => t!["name"]!.GetValue() == "references")!; var kindEnum = referencesTool["inputSchema"]!["properties"]!["kind"]!["enum"]!.AsArray() @@ -3390,6 +3396,11 @@ public void ToolsList_CommonSchemasAdvertiseClientSideConstraints() .ToArray(); Assert.Contains("call", kindEnum); Assert.Contains("type_reference", kindEnum); + + var mapTool = tools.First(t => t!["name"]!.GetValue() == "map")!; + var sectionsSchema = mapTool["inputSchema"]!["properties"]!["sections"]!; + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterCount, sectionsSchema["maxItems"]!.GetValue()); + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, sectionsSchema["items"]!["maxLength"]!.GetValue()); } [Theory] @@ -7945,6 +7956,70 @@ public void ToolsCall_RejectsOversizedPathArrays_Issue2028() Assert.Equal(1, structured["invalid_count"]!.GetValue()); } + [Fact] + public void ToolsCall_PathListArgumentsUseCliBounds_Issue3182() + { + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterCount, McpServer.MaxMcpArrayFilterCount); + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, McpServer.MaxMcpArrayFilterStringLength); + + var tooManyPaths = new JsonArray(); + for (var i = 0; i < QueryCommandRunner.MaxQueryPathFilterCount + 1; i++) + tooManyPaths.Add($"src/{i}.cs"); + AssertListError( + "search", + new JsonObject + { + ["query"] = "App", + ["path"] = tooManyPaths, + }, + "path must contain at most", + expectedInvalidCount: 1); + + AssertListError( + "search", + new JsonObject + { + ["query"] = "App", + ["excludePaths"] = new JsonArray { new string('a', QueryCommandRunner.MaxQueryPathFilterLength + 1) }, + }, + $"Entries must be non-empty strings no longer than {QueryCommandRunner.MaxQueryPathFilterLength} characters.", + expectedInvalidCount: 1); + + AssertListError( + "map", + new JsonObject + { + ["sections"] = new JsonArray { 42 }, + }, + "sections contains 1 invalid entry", + expectedInvalidCount: 1); + + void AssertListError(string toolName, JsonObject arguments, string expectedText, int expectedInvalidCount) + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = toolName, + ["arguments"] = arguments, + }, + }; + + var response = _server.HandleMessage(request)!; + + var result = response["result"]!; + Assert.True(result["isError"]!.GetValue(), response.ToJsonString()); + var text = result["content"]![0]!["text"]!.GetValue(); + Assert.Contains(expectedText, text); + var structured = result["structuredContent"]!; + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, structured["category"]!.GetValue()); + Assert.Equal(expectedInvalidCount, structured["invalid_count"]!.GetValue()); + } + } + [Fact] public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() { From ee4bc5d8ed34afffe2f8b67edc6516d90529bfd5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:22:23 +0900 Subject: [PATCH 05/13] Fix MCP required path validation (#3186) --- changelog.d/unreleased/3186.fixed.md | 16 +++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 36 +++++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 78 ++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/3186.fixed.md diff --git a/changelog.d/unreleased/3186.fixed.md b/changelog.d/unreleased/3186.fixed.md new file mode 100644 index 0000000000..c169ff1552 --- /dev/null +++ b/changelog.d/unreleased/3186.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3186 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP required `path` arguments now fail before lookup when invalid (#3186)** — `outline`, `excerpt`, and `index` now reject non-string, overlong, absolute, drive-prefixed, NUL-containing, or traversal paths with structured invalid-argument errors before file or database lookup. + +## 日本語 + +- **MCP の必須 `path` 引数が不正な場合 lookup 前に失敗するようになりました (#3186)** — `outline`、`excerpt`、`index` は non-string、長すぎる値、絶対パス、drive prefix、NUL、traversal を含む path を、file/database lookup 前に structured invalid-argument error として拒否します。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 4b5e8910b9..2f298e310a 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1070,6 +1070,36 @@ private static bool TryReadRequiredStringParameter(JsonNode? args, string proper return true; } + private static bool TryReadRequiredPathParameter(JsonNode? args, string propertyName, out string value, out string? error) + { + if (!TryReadRequiredStringParameter(args, propertyName, out value, out error)) + return false; + + if (value.Length > MaxMcpArrayFilterStringLength) + { + error = $"Parameter \"{propertyName}\" must be no longer than {MaxMcpArrayFilterStringLength} characters."; + return false; + } + + var normalized = value.Replace("\\", "/", StringComparison.Ordinal); + if (value.IndexOf("\0", StringComparison.Ordinal) >= 0 + || normalized.StartsWith("/", StringComparison.Ordinal) + || HasWindowsDrivePrefix(normalized) + || normalized.Split(new[] { '/' }, StringSplitOptions.None).Any(segment => segment == "..")) + { + error = $"Parameter \"{propertyName}\" must be workspace-relative and must not contain NUL bytes or `..` path traversal segments."; + return false; + } + + error = null; + return true; + } + + private static bool HasWindowsDrivePrefix(string path) + => path.Length >= 2 + && path[1] == ':' + && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')); + private static bool HasBlankPathFilter(JsonNode? args) { var node = args?["path"]; @@ -2275,7 +2305,7 @@ private static string QuoteCommandArgument(string value) private JsonNode ExecuteOutline(JsonNode? id, JsonNode? args) { - if (!TryReadRequiredStringParameter(args, "path", out var path, out var requiredError)) + if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); return WithDbReader(id, args, reader => @@ -2299,7 +2329,7 @@ private JsonNode ExecuteOutline(JsonNode? id, JsonNode? args) private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) { - if (!TryReadRequiredStringParameter(args, "path", out var path, out var requiredError)) + if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); var startLine = args?["startLine"]?.GetValue(); @@ -3652,7 +3682,7 @@ private bool IsPathWithinClientRoots(string path) private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) { - if (!TryReadRequiredStringParameter(args, "path", out var path, out var requiredError)) + if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); var rebuild = args?["rebuild"]?.GetValue() ?? false; diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 39d1952005..c732d1ca8e 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1334,6 +1334,68 @@ public void ToolCall_RequiredPath_DistinguishesMissingFromWhitespace(string tool Assert.Equal("Parameter \"path\" cannot be empty or whitespace-only", blank); } + [Theory] + [InlineData("outline")] + [InlineData("excerpt")] + [InlineData("index")] + public void ToolCall_RequiredPath_RejectsNonStringType_Issue3186(string toolName) + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = toolName, + ["arguments"] = BuildRequiredPathArguments(toolName, new JsonArray { "src/app.cs" }), + }, + }; + + var response = _server.HandleMessage(request)!; + + var error = response["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + Assert.Contains("Invalid type for argument 'path'", error["message"]!.GetValue()); + Assert.Equal("path", error["data"]!["parameter"]!.GetValue()); + } + + [Theory] + [InlineData("outline", "../outside.cs", "`..` path traversal")] + [InlineData("excerpt", "/tmp/outside.cs", "workspace-relative")] + [InlineData("index", "C:/outside", "workspace-relative")] + [InlineData("outline", "TOO_LONG", "must be no longer than")] + public void ToolCall_RequiredPath_RejectsInvalidPathValues_Issue3186( + string toolName, + string pathValue, + string expectedText) + { + if (pathValue == "TOO_LONG") + pathValue = new string('a', QueryCommandRunner.MaxQueryPathFilterLength + 1); + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = toolName, + ["arguments"] = BuildRequiredPathArguments(toolName, pathValue), + }, + }; + + var response = _server.HandleMessage(request)!; + + var result = response["result"]!; + Assert.True(result["isError"]!.GetValue(), response.ToJsonString()); + var text = result["content"]![0]!["text"]!.GetValue(); + Assert.Contains(expectedText, text, StringComparison.Ordinal); + Assert.DoesNotContain("file not found in index", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Directory not found", text, StringComparison.Ordinal); + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, result["structuredContent"]!["category"]!.GetValue()); + } + [Fact] public void ToolCall_FindInFilePath_DistinguishesMissingFromWhitespace() { @@ -3334,7 +3396,7 @@ public void ToolsCall_IndexAllowsAdvertisedMaxFileBytesArgument() Assert.True(result["isError"]!.GetValue()); var text = result["content"]![0]!["text"]!.GetValue(); Assert.DoesNotContain("Unknown argument 'maxFileBytes'", text, StringComparison.Ordinal); - Assert.Contains("Path must be within the current working directory", text, StringComparison.Ordinal); + Assert.Contains("Parameter \"path\" must be workspace-relative", text, StringComparison.Ordinal); } [Fact] @@ -13027,6 +13089,20 @@ public void RegisterShutdownHandlers_AfterDispose_DoesNotInvokeHandler() Assert.False(cts.IsCancellationRequested); } + private static JsonObject BuildRequiredPathArguments(string toolName, string pathValue) + => BuildRequiredPathArguments(toolName, JsonValue.Create(pathValue)!); + + private static JsonObject BuildRequiredPathArguments(string toolName, JsonNode pathValue) + { + var arguments = new JsonObject + { + ["path"] = pathValue, + }; + if (toolName == "excerpt") + arguments["startLine"] = 1; + return arguments; + } + private string CallToolAndReadErrorMessage(string toolName, JsonObject arguments) { var request = new JsonObject From 03f21e51c5aaabe6198f2206fa700de6a63a488b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:27:12 +0900 Subject: [PATCH 06/13] Fix MCP project filter validation (#3160) --- changelog.d/unreleased/3160.fixed.md | 17 ++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 9 +++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 36 +++++++++++++++++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 35 ++++++++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 changelog.d/unreleased/3160.fixed.md diff --git a/changelog.d/unreleased/3160.fixed.md b/changelog.d/unreleased/3160.fixed.md new file mode 100644 index 0000000000..13224f7cf9 --- /dev/null +++ b/changelog.d/unreleased/3160.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3160 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP project filter resolver failures now return structured invalid-argument errors (#3160)** — unresolved or inaccessible project filters now fail before tool execution with a bounded diagnostic instead of falling through to a generic sanitized tool failure. + +## 日本語 + +- **MCP project filter resolver failure が structured invalid-argument error を返すようになりました (#3160)** — 解決できない、またはアクセスできない project filter は tool 実行前に bounded diagnostic 付きで失敗し、generic な sanitized tool failure に落ちないようになりました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 5d1ddfccbd..72534a061b 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -2464,6 +2464,15 @@ JsonObject CreateUnknownToolResponseForMetrics() retrySafe: false, extraData: listArgumentError); } + else if (ValidateProjectFilterArguments(args) is JsonObject projectFilterError) + { + metricsError = "invalid_project_filter"; + response = CreateToolErrorResponse(id, projectFilterError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", + retrySafe: false, + extraData: projectFilterError); + } else { // Per-(tool, caller) rate limiter check (#1560). Disabled by default; when an diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 2f298e310a..79aa26e4b1 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1027,6 +1027,32 @@ private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) return paths.Count == 0 ? null : paths; } + private static JsonObject? ValidateProjectFilterArguments(JsonNode? args) + { + var projects = ReadPathList(args, "project") ?? []; + if (projects.Count == 0) + return null; + + var solution = args?["solution"]?.GetValue(); + try + { + _ = SolutionProjectResolver.ResolveProjectDirectoryGlobs(Environment.CurrentDirectory, projects, solution); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + var diagnostic = McpBoundedText.ForDisplay(ex.Message); + var error = new JsonObject + { + ["message"] = $"Project filter could not be resolved: {diagnostic.Text}", + ["parameter"] = "project", + ["diagnostic"] = diagnostic.Text, + }; + diagnostic.AddMetadata(error, "diagnostic"); + return error; + } + } + private static bool TryReadSinceArgument(JsonNode? args, out DateTime? since, out string? error) { var sinceStr = args?["since"]?.GetValue(); @@ -2783,6 +2809,16 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg continue; } + if (ValidateProjectFilterArguments(toolArgs) is JsonObject projectFilterError) + { + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, projectFilterError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", + retrySafe: false, + extraData: projectFilterError); + continue; + } + // Honor the per-deployment enablement gate inside batch_query too (#1561). Without // this, an operator who disabled a tool through `CDIDX_MCP_TOOLS_ALLOW` / // `CDIDX_MCP_TOOLS_DENY` could still reach it by smuggling the name into a batch diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index c732d1ca8e..f486fbb24b 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1321,6 +1321,41 @@ public void ToolCall_InvalidSince_ReturnsInvalidArgument_Issue3194(string toolNa Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, result["structuredContent"]!["category"]!.GetValue()); } + [Fact] + public void ToolsCall_ProjectFilterResolverFailure_ReturnsInvalidArgument_Issue3160() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"App","project":"DefinitelyMissingProject3160"}}}""")!; + + var response = _server.HandleMessage(request)!; + + var result = response["result"]!; + Assert.True(result["isError"]!.GetValue(), response.ToJsonString()); + var text = result["content"]![0]!["text"]!.GetValue(); + Assert.Contains("Project filter could not be resolved", text, StringComparison.Ordinal); + Assert.DoesNotContain("Tool 'search' failed", text, StringComparison.Ordinal); + Assert.DoesNotContain(nameof(InvalidOperationException), text, StringComparison.Ordinal); + var structured = result["structuredContent"]!; + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, structured["category"]!.GetValue()); + Assert.Equal("project", structured["parameter"]!.GetValue()); + Assert.Contains("DefinitelyMissingProject3160", structured["diagnostic"]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public void ToolsCall_BatchQuery_ProjectFilterResolverFailure_ReturnsSlotError_Issue3160() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"queries":[{"tool":"search","arguments":{"query":"App","project":"DefinitelyMissingProject3160"}}]}}}""")!; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1, structured["metadata"]!["errors"]!.GetValue()); + var slot = Assert.Single(structured["results"]!.AsArray()); + Assert.False(slot!["ok"]!.GetValue()); + Assert.Contains("Project filter could not be resolved", slot["error"]!.GetValue(), StringComparison.Ordinal); + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, slot["category"]!.GetValue()); + Assert.Equal("project", slot["parameter"]!.GetValue()); + } + [Theory] [InlineData("outline")] [InlineData("excerpt")] From 884d658924b2e85363da01701e7a387fd76c9f2b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:29:52 +0900 Subject: [PATCH 07/13] Lock MCP search cursor contract (#3192) --- changelog.d/unreleased/3192.fixed.md | 15 +++++++++++++++ tests/CodeIndex.Tests/McpToolContractTests.cs | 14 ++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 changelog.d/unreleased/3192.fixed.md diff --git a/changelog.d/unreleased/3192.fixed.md b/changelog.d/unreleased/3192.fixed.md new file mode 100644 index 0000000000..4911eb4e60 --- /dev/null +++ b/changelog.d/unreleased/3192.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 3192 +affected: + - tests/CodeIndex.Tests/McpToolContractTests.cs +--- + +## English + +- **MCP `search.cursor` contract is now regression-locked (#3192)** — `cursor` stays advertised, allowlisted, and string-typed across `tools/list` and server-side argument validation. + +## 日本語 + +- **MCP `search.cursor` contract を regression lock しました (#3192)** — `cursor` が `tools/list` と server-side argument validation の両方で advertised / allowlisted / string-typed のまま保たれるよう確認します。 diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index a60d6fa9e5..6d1389335f 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -100,6 +100,20 @@ public void ToolsList_AdvertisedInputPropertiesHaveMatchingTypeValidation_Issue3 "MCP tools/list schema and argument type validator drift detected:\n" + string.Join('\n', failures)); } + [Fact] + public void ToolsList_SearchCursorHasSharedArgumentContract_Issue3192() + { + var searchProperties = GetAdvertisedToolSchemas()["search"]; + var allowed = GetAllowedToolArguments("search"); + var (hasValidator, validatorType) = TryGetExpectedJsonType("search", "cursor"); + + Assert.True(searchProperties.ContainsKey("cursor")); + Assert.Contains("cursor", allowed); + Assert.Equal("string", ExpectedTypeFromSchema(searchProperties["cursor"])); + Assert.True(hasValidator); + Assert.Equal("string", validatorType); + } + private static Dictionary> GetAdvertisedToolSchemas() { using var server = new McpServer("unused.db", "test", dbPathExplicit: false, McpToolFilter.AllowAll()); From 6946bc187feb01a967f6100142e4618c9fb20fe4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:31:43 +0900 Subject: [PATCH 08/13] Lock MCP deps argument contract (#3196) --- changelog.d/unreleased/3196.fixed.md | 15 +++++++++++ tests/CodeIndex.Tests/McpToolContractTests.cs | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 changelog.d/unreleased/3196.fixed.md diff --git a/changelog.d/unreleased/3196.fixed.md b/changelog.d/unreleased/3196.fixed.md new file mode 100644 index 0000000000..f8dd57b2c1 --- /dev/null +++ b/changelog.d/unreleased/3196.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 3196 +affected: + - tests/CodeIndex.Tests/McpToolContractTests.cs +--- + +## English + +- **MCP `deps` argument contract is now regression-locked (#3196)** — `reverse`, `format`, and `cycles` stay advertised, allowlisted, and typed, while stale hidden arguments such as `direction` and `includeGenerated` stay absent. + +## 日本語 + +- **MCP `deps` argument contract を regression lock しました (#3196)** — `reverse`、`format`、`cycles` が advertised / allowlisted / typed のまま保たれ、`direction` や `includeGenerated` のような古い hidden 引数が戻らないことを確認します。 diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index 6d1389335f..a9fa1d4172 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -114,6 +114,31 @@ public void ToolsList_SearchCursorHasSharedArgumentContract_Issue3192() Assert.Equal("string", validatorType); } + [Fact] + public void ToolsList_DepsArgumentsHaveSharedArgumentContract_Issue3196() + { + var depsProperties = GetAdvertisedToolSchemas()["deps"]; + var allowed = GetAllowedToolArguments("deps"); + + foreach (var argumentName in new[] { "reverse", "format", "cycles" }) + { + Assert.True(depsProperties.ContainsKey(argumentName)); + Assert.Contains(argumentName, allowed); + } + + 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((true, "boolean"), TryGetExpectedJsonType("deps", "reverse")); + Assert.Equal((true, "string"), TryGetExpectedJsonType("deps", "format")); + Assert.Equal((true, "boolean"), TryGetExpectedJsonType("deps", "cycles")); + } + private static Dictionary> GetAdvertisedToolSchemas() { using var server = new McpServer("unused.db", "test", dbPathExplicit: false, McpToolFilter.AllowAll()); From a500b1498b728dfc434047985973371d6e749fca Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:33:57 +0900 Subject: [PATCH 09/13] Lock MCP map argument contract (#3197) --- changelog.d/unreleased/3197.fixed.md | 15 +++++++++++++++ tests/CodeIndex.Tests/McpToolContractTests.cs | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 changelog.d/unreleased/3197.fixed.md diff --git a/changelog.d/unreleased/3197.fixed.md b/changelog.d/unreleased/3197.fixed.md new file mode 100644 index 0000000000..01d802eaee --- /dev/null +++ b/changelog.d/unreleased/3197.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 3197 +affected: + - tests/CodeIndex.Tests/McpToolContractTests.cs +--- + +## English + +- **MCP `map` argument contract is now regression-locked (#3197)** — `sections` and `depth` stay advertised, allowlisted, and validated with their documented JSON shapes. + +## 日本語 + +- **MCP `map` argument contract を regression lock しました (#3197)** — `sections` と `depth` が advertised / allowlisted され、documented JSON shape のまま検証されることを確認します。 diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index a9fa1d4172..9f9df7ca8f 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -139,6 +139,24 @@ public void ToolsList_DepsArgumentsHaveSharedArgumentContract_Issue3196() Assert.Equal((true, "boolean"), TryGetExpectedJsonType("deps", "cycles")); } + [Fact] + public void ToolsList_MapSectionsAndDepthHaveSharedArgumentContract_Issue3197() + { + var mapProperties = GetAdvertisedToolSchemas()["map"]; + var allowed = GetAllowedToolArguments("map"); + + Assert.True(mapProperties.ContainsKey("sections")); + Assert.Contains("sections", allowed); + Assert.Equal("array", ExpectedTypeFromSchema(mapProperties["sections"])); + Assert.Contains("sections", SpecializedListValidatedArguments); + Assert.Equal((false, string.Empty), TryGetExpectedJsonType("map", "sections")); + + Assert.True(mapProperties.ContainsKey("depth")); + Assert.Contains("depth", allowed); + Assert.Equal("integer", ExpectedTypeFromSchema(mapProperties["depth"])); + Assert.Equal((true, "integer"), TryGetExpectedJsonType("map", "depth")); + } + private static Dictionary> GetAdvertisedToolSchemas() { using var server = new McpServer("unused.db", "test", dbPathExplicit: false, McpToolFilter.AllowAll()); From 6e7e84cc3b78de80c14145be750f95ca70bec4c1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:36:34 +0900 Subject: [PATCH 10/13] Lock MCP outline and validate contracts (#3198) --- changelog.d/unreleased/3198.fixed.md | 15 +++++++++ tests/CodeIndex.Tests/McpToolContractTests.cs | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 changelog.d/unreleased/3198.fixed.md diff --git a/changelog.d/unreleased/3198.fixed.md b/changelog.d/unreleased/3198.fixed.md new file mode 100644 index 0000000000..8cbf913a28 --- /dev/null +++ b/changelog.d/unreleased/3198.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 3198 +affected: + - tests/CodeIndex.Tests/McpToolContractTests.cs +--- + +## English + +- **MCP `outline` and `validate` no-op argument contracts are now regression-locked (#3198)** — hidden no-op arguments stay absent and advertised arguments stay aligned with server-side allowlists. + +## 日本語 + +- **MCP `outline` / `validate` の no-op 引数 contract を regression lock しました (#3198)** — hidden no-op 引数が戻らず、advertised 引数と server-side allowlist が一致し続けることを確認します。 diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index 9f9df7ca8f..066cde44e1 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -157,6 +157,39 @@ public void ToolsList_MapSectionsAndDepthHaveSharedArgumentContract_Issue3197() Assert.Equal((true, "integer"), TryGetExpectedJsonType("map", "depth")); } + [Fact] + public void ToolsList_OutlineAndValidateDoNotExposeHiddenNoopArguments_Issue3198() + { + var advertisedSchemas = GetAdvertisedToolSchemas(); + + AssertToolArgumentsExactly(advertisedSchemas, "outline", ["path"]); + AssertToolArgumentsExactly(advertisedSchemas, "validate", ["kind", "path", "excludePaths", "excludeTests", "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); + } + } + + static void AssertToolArgumentsExactly( + Dictionary> advertisedSchemas, + string toolName, + string[] expectedArguments) + { + var expected = expectedArguments.ToHashSet(StringComparer.Ordinal); + var advertised = advertisedSchemas[toolName].Keys.ToHashSet(StringComparer.Ordinal); + var allowed = GetAllowedToolArguments(toolName); + + Assert.Equal(expected.Order(StringComparer.Ordinal), advertised.Order(StringComparer.Ordinal)); + Assert.Equal(expected.Order(StringComparer.Ordinal), allowed.Order(StringComparer.Ordinal)); + } + } + private static Dictionary> GetAdvertisedToolSchemas() { using var server = new McpServer("unused.db", "test", dbPathExplicit: false, McpToolFilter.AllowAll()); From c0f8a3ec3eaaabc949caa534a8e87274d9263acc Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:40:11 +0900 Subject: [PATCH 11/13] Preserve MCP index path compatibility (#3186) --- changelog.d/unreleased/3186.fixed.md | 4 ++-- src/CodeIndex/Mcp/McpToolHandlers.cs | 23 ++++++++++++++++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 4 ++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/changelog.d/unreleased/3186.fixed.md b/changelog.d/unreleased/3186.fixed.md index c169ff1552..fb176395ae 100644 --- a/changelog.d/unreleased/3186.fixed.md +++ b/changelog.d/unreleased/3186.fixed.md @@ -9,8 +9,8 @@ affected: ## English -- **MCP required `path` arguments now fail before lookup when invalid (#3186)** — `outline`, `excerpt`, and `index` now reject non-string, overlong, absolute, drive-prefixed, NUL-containing, or traversal paths with structured invalid-argument errors before file or database lookup. +- **MCP required `path` arguments now fail before lookup when invalid (#3186)** — `outline` and `excerpt` now reject invalid indexed-file paths before database lookup, while `index` rejects non-string, overlong, or NUL-containing project paths before filesystem checks. ## 日本語 -- **MCP の必須 `path` 引数が不正な場合 lookup 前に失敗するようになりました (#3186)** — `outline`、`excerpt`、`index` は non-string、長すぎる値、絶対パス、drive prefix、NUL、traversal を含む path を、file/database lookup 前に structured invalid-argument error として拒否します。 +- **MCP の必須 `path` 引数が不正な場合 lookup 前に失敗するようになりました (#3186)** — `outline` と `excerpt` は不正な indexed-file path を database lookup 前に拒否し、`index` は non-string、長すぎる値、NUL を含む project path を filesystem check 前に拒否します。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 79aa26e4b1..d990465978 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1121,6 +1121,27 @@ private static bool TryReadRequiredPathParameter(JsonNode? args, string property return true; } + private static bool TryReadRequiredIndexPathParameter(JsonNode? args, string propertyName, out string value, out string? error) + { + if (!TryReadRequiredStringParameter(args, propertyName, out value, out error)) + return false; + + if (value.Length > MaxMcpArrayFilterStringLength) + { + error = $"Parameter \"{propertyName}\" must be no longer than {MaxMcpArrayFilterStringLength} characters."; + return false; + } + + if (value.IndexOf("\0", StringComparison.Ordinal) >= 0) + { + error = $"Parameter \"{propertyName}\" must not contain NUL bytes."; + return false; + } + + error = null; + return true; + } + private static bool HasWindowsDrivePrefix(string path) => path.Length >= 2 && path[1] == ':' @@ -3718,7 +3739,7 @@ private bool IsPathWithinClientRoots(string path) private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) { - if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) + if (!TryReadRequiredIndexPathParameter(args, "path", out var path, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); var rebuild = args?["rebuild"]?.GetValue() ?? false; diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index f486fbb24b..1701fd4fce 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1398,7 +1398,7 @@ public void ToolCall_RequiredPath_RejectsNonStringType_Issue3186(string toolName [Theory] [InlineData("outline", "../outside.cs", "`..` path traversal")] [InlineData("excerpt", "/tmp/outside.cs", "workspace-relative")] - [InlineData("index", "C:/outside", "workspace-relative")] + [InlineData("index", "TOO_LONG", "must be no longer than")] [InlineData("outline", "TOO_LONG", "must be no longer than")] public void ToolCall_RequiredPath_RejectsInvalidPathValues_Issue3186( string toolName, @@ -3431,7 +3431,7 @@ public void ToolsCall_IndexAllowsAdvertisedMaxFileBytesArgument() Assert.True(result["isError"]!.GetValue()); var text = result["content"]![0]!["text"]!.GetValue(); Assert.DoesNotContain("Unknown argument 'maxFileBytes'", text, StringComparison.Ordinal); - Assert.Contains("Parameter \"path\" must be workspace-relative", text, StringComparison.Ordinal); + Assert.Contains("Path must be within the current working directory", text, StringComparison.Ordinal); } [Fact] From 8f976e9a7e1b927d1fb24575ffe7e102f2fe0647 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:45:02 +0900 Subject: [PATCH 12/13] Align MCP index path schema (#3186) --- changelog.d/unreleased/3186.fixed.md | 1 + src/CodeIndex/Mcp/McpToolDefinitions.cs | 7 +++++++ tests/CodeIndex.Tests/McpServerTests.cs | 16 ++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/changelog.d/unreleased/3186.fixed.md b/changelog.d/unreleased/3186.fixed.md index fb176395ae..0a54e3cd07 100644 --- a/changelog.d/unreleased/3186.fixed.md +++ b/changelog.d/unreleased/3186.fixed.md @@ -3,6 +3,7 @@ category: fixed issues: - 3186 affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs - src/CodeIndex/Mcp/McpToolHandlers.cs - tests/CodeIndex.Tests/McpServerTests.cs --- diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 2b24813521..31958f131e 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -647,6 +647,13 @@ private static void ApplyCommonSchemaConstraint(string toolName, string name, Js { obj.TryAdd("maxItems", MaxMcpArrayFilterCount); } + else if (name == "path" && toolName == "index") + { + obj.TryAdd("minLength", 1); + obj.TryAdd("maxLength", MaxMcpArrayFilterStringLength); + obj.TryAdd("pattern", @"^(?!.*\u0000).+$"); + AppendConstraintDescription(obj, "May be absolute or relative, but must be non-empty and must not contain NUL bytes."); + } else { obj.TryAdd("minLength", 1); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 1701fd4fce..fb989c90ea 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1431,6 +1431,22 @@ public void ToolCall_RequiredPath_RejectsInvalidPathValues_Issue3186( Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, result["structuredContent"]!["category"]!.GetValue()); } + [Fact] + public void ToolsList_IndexPathSchemaReflectsProjectPathContract_Issue3186() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var response = _server.HandleMessage(request)!; + + var tools = response["result"]!["tools"]!.AsArray(); + var indexTool = tools.First(t => t!["name"]!.GetValue() == "index")!; + var pathSchema = indexTool["inputSchema"]!["properties"]!["path"]!; + + Assert.Equal("string", pathSchema["type"]!.GetValue()); + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, pathSchema["maxLength"]!.GetValue()); + Assert.DoesNotContain("(?!/)", pathSchema["pattern"]!.GetValue(), StringComparison.Ordinal); + Assert.Contains("absolute or relative", pathSchema["description"]!.GetValue(), StringComparison.Ordinal); + } + [Fact] public void ToolCall_FindInFilePath_DistinguishesMissingFromWhitespace() { From 94303e6df214fe1f9c6e1c423075958c823b57f5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:50:08 +0900 Subject: [PATCH 13/13] Rate-limit MCP project filter validation (#3160) --- src/CodeIndex/Mcp/McpServer.cs | 18 +++++++++--------- src/CodeIndex/Mcp/McpToolHandlers.cs | 20 ++++++++++---------- tests/CodeIndex.Tests/McpServerTests.cs | 21 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 72534a061b..aa39522dfb 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -2464,15 +2464,6 @@ JsonObject CreateUnknownToolResponseForMetrics() retrySafe: false, extraData: listArgumentError); } - else if (ValidateProjectFilterArguments(args) is JsonObject projectFilterError) - { - metricsError = "invalid_project_filter"; - response = CreateToolErrorResponse(id, projectFilterError["message"]!.GetValue(), - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", - retrySafe: false, - extraData: projectFilterError); - } else { // Per-(tool, caller) rate limiter check (#1560). Disabled by default; when an @@ -2488,6 +2479,15 @@ JsonObject CreateUnknownToolResponseForMetrics() DeferFrameLog(BuildRateLimitedLog(toolName, _caller, decision.RetryAfterMs)); response = CreateRateLimitedErrorResponse(id, toolName, _caller, decision.RetryAfterMs); } + else if (ValidateProjectFilterArguments(args) is JsonObject projectFilterError) + { + metricsError = "invalid_project_filter"; + response = CreateToolErrorResponse(id, projectFilterError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", + retrySafe: false, + extraData: projectFilterError); + } else { response = toolName switch diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index d990465978..03bff5cf22 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2830,16 +2830,6 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg continue; } - if (ValidateProjectFilterArguments(toolArgs) is JsonObject projectFilterError) - { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, projectFilterError["message"]!.GetValue(), - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", - retrySafe: false, - extraData: projectFilterError); - continue; - } - // Honor the per-deployment enablement gate inside batch_query too (#1561). Without // this, an operator who disabled a tool through `CDIDX_MCP_TOOLS_ALLOW` / // `CDIDX_MCP_TOOLS_DENY` could still reach it by smuggling the name into a batch @@ -2904,6 +2894,16 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg continue; } + if (ValidateProjectFilterArguments(toolArgs) is JsonObject projectFilterError) + { + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, projectFilterError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", + retrySafe: false, + extraData: projectFilterError); + continue; + } + try { // Execute the tool and extract the structured content / ツールを実行し構造化コンテンツを抽出 diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index fb989c90ea..66c60d32f1 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -12374,6 +12374,27 @@ public void ToolsCall_RateLimit_KeysByTool() Assert.Null(languages["error"]); } + [Fact] + public void ToolsCall_RateLimitPrecedesProjectFilterResolution_Issue3160() + { + InstallRateLimiter(_server, new RateLimiterOptions { RefillTokensPerSecond = 1.0, BurstCapacity = 1.0 }); + + var initialize = JsonNode.Parse("""{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"clientInfo":{"name":"client-a","version":"1.2.3"}}}""")!; + _server.HandleMessage(initialize); + + var first = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"App"}}}""")!)!; + Assert.Null(first["error"]); + + var second = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"App","project":"DefinitelyMissingProject3160"}}}""")!)!; + + var error = second["error"]!; + Assert.Equal(-32000, error["code"]!.GetValue()); + Assert.Contains("Rate limit exceeded", error["message"]!.GetValue()); + Assert.DoesNotContain("Project filter could not be resolved", second.ToJsonString(), StringComparison.Ordinal); + } + [Fact] public void Initialize_CapturesClientInfoAsCallerIdentity() {