Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog.d/unreleased/3199.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: internal
issues:
- 3199
affected:
- src/CodeIndex/Mcp/McpToolDefinitions.cs
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/McpToolContractTests.cs
---

## English

- **Added MCP tool contract drift tests (#3199)** — MCP tests now compare advertised `tools/list` input properties against the server allowlist and JSON type validator, catching schema, validation, and hidden argument drift before release.

## 日本語

- **MCP ツール契約の drift テストを追加しました (#3199)** — MCP テストが `tools/list` で広告される入力プロパティとサーバーの allowlist / JSON 型バリデータを突き合わせ、schema・validation・隠れ引数のずれをリリース前に検出します。
5 changes: 3 additions & 2 deletions src/CodeIndex/Mcp/McpToolDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,6 @@ 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 files detected as generated code", ["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 }
Expand All @@ -373,7 +372,9 @@ 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)" },
["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." }
["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 }
}
},
ReadOnlyAnnotations()),
Expand Down
53 changes: 36 additions & 17 deletions src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ public partial class McpServer
private static readonly HashSet<string> BoundedEnumLikeScalarArguments = new(StringComparer.Ordinal)
{
"category",
"direction",
"format",
"groupBy",
"kind",
Expand Down Expand Up @@ -486,7 +485,7 @@ private static List<string> ReadStringList(JsonNode? args, string propertyName)

private static JsonObject? ValidateCommonListArguments(JsonNode? args)
{
foreach (var propertyName in new[] { "path", "project", "excludePaths", "names" })
foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections" })
{
if (ValidateStringListArgument(args, propertyName) is JsonObject error)
return error;
Expand Down Expand Up @@ -603,23 +602,31 @@ private static JsonObject AddUnknownArgumentData(JsonObject error, string argume

private static bool TryGetExpectedJsonType(string toolName, string argumentName, out string expected)
{
if (argumentName is "path" or "project" or "excludePaths" or "names" or "files" or "commits" or "changedBetween")
if (argumentName is "excludePaths" or "names" or "sections" or "files" or "commits" or "changedBetween")
{
expected = string.Empty;
return false;
}

if (argumentName == "path")
{
expected = ToolAllowsStringOrArrayPath(toolName) ? "string_or_array" : "string";
return true;
}

expected = argumentName switch
{
"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 "guardWindow" => "integer",
"maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" or
"guardWindow" or "maxOutputBytes" => "integer",
"excludeTests" or "includeGenerated" 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" => "boolean",
"requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array",
"query" or "lang" or "kind" or "format" or "rankBy" or "since" or "path" or "project" or
"solution" or "symbol" or "direction" or "groupBy" or "category" or "language" 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",
"query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or
"solution" or "symbol" or "groupBy" or "category" or "language" or
"description" or "context" or "toolInvocationContext" or "db" => "string",
"queries" or "evidencePaths" or "evidence_paths" => "array",
_ => string.Empty,
Expand All @@ -631,6 +638,14 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName,
return true;
}

private static bool ToolAllowsStringOrArrayPath(string toolName) => toolName switch
{
"search" or "definition" or "references" or "callers" or "callees" or "symbols" or
"files" or "find_in_file" or "map" or "analyze_symbol" or "deps" or "impact_analysis" or
"validate" or "unused_symbols" or "symbol_hotspots" => true,
_ => false,
};

private static bool MatchesExpectedJsonType(JsonNode? node, string expected) => expected switch
{
"integer" => node is JsonValue value && value.TryGetValue<int>(out _),
Expand Down Expand Up @@ -674,16 +689,16 @@ private static string DescribeJsonType(JsonNode? node)
"references" => new HashSet<string>(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<string>(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" },
"symbols" => new HashSet<string>(StringComparer.Ordinal) { "query", "names", "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" },
"files" => new HashSet<string>(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since" },
"files" => new HashSet<string>(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "project", "solution" },
"find_in_file" => new HashSet<string>(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" },
"excerpt" => new HashSet<string>(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth" },
"map" => new HashSet<string>(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "project", "solution" },
"excerpt" => new HashSet<string>(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" },
"map" => new HashSet<string>(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "project", "solution" },
"analyze_symbol" => new HashSet<string>(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" },
"outline" => new HashSet<string>(StringComparer.Ordinal) { "path", "limit", "includeImports", "maxLineWidth" },
"outline" => new HashSet<string>(StringComparer.Ordinal) { "path" },
"batch_query" => new HashSet<string>(StringComparer.Ordinal) { "queries" },
"deps" => new HashSet<string>(StringComparer.Ordinal) { "path", "direction", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" },
"impact_analysis" => new HashSet<string>(StringComparer.Ordinal) { "symbol", "query", "lang", "maxHops", "maxDepth", "depth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" },
"validate" => new HashSet<string>(StringComparer.Ordinal) { "path", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" },
"deps" => new HashSet<string>(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" },
"impact_analysis" => new HashSet<string>(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" },
"validate" => new HashSet<string>(StringComparer.Ordinal) { "kind", "path", "excludePaths", "excludeTests", "project", "solution" },
"unused_symbols" => new HashSet<string>(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "project", "solution" },
"symbol_hotspots" => new HashSet<string>(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" },
"index" => new HashSet<string>(StringComparer.Ordinal) { "path", "rebuild", "maxFileBytes" },
Expand Down Expand Up @@ -740,8 +755,12 @@ private static string DescribeJsonType(JsonNode? node)

if (node is JsonValue scalar && scalar.TryGetValue<string>(out var scalarText))
{
if (propertyName == "names")
return null;
if (propertyName is "excludePaths" or "names" or "sections")
return new JsonObject
{
["message"] = $"{propertyName} must be an array of strings.",
["invalid_count"] = 1,
};
if (propertyName == "path" && string.IsNullOrWhiteSpace(scalarText))
return null;
if (string.IsNullOrWhiteSpace(scalarText))
Expand Down
Loading
Loading