From d1744774fbdbbb5b534632e855633cddc22107c4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:35:59 +0900 Subject: [PATCH 1/4] Add MCP graph truncation coverage (#1415) --- changelog.d/unreleased/1415.fixed.md | 13 +++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 31 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 changelog.d/unreleased/1415.fixed.md diff --git a/changelog.d/unreleased/1415.fixed.md b/changelog.d/unreleased/1415.fixed.md new file mode 100644 index 0000000000..ce41221a3b --- /dev/null +++ b/changelog.d/unreleased/1415.fixed.md @@ -0,0 +1,13 @@ +--- +category: fixed +issues: + - 1415 +--- + +## English + +- Confirmed MCP `references`, `callers`, and `callees` expose truncation metadata when a result page reaches the requested limit. + +## 日本語 + +- MCP `references`、`callers`、`callees` が要求 limit に達した結果ページで truncation metadata を返すことを確認しました。 diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 87076292a0..44fa8b698d 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -228,6 +228,37 @@ void Target() { } Assert.Contains("Gamma", allNames); } + [Theory] + [InlineData("references", "Target")] + [InlineData("callees", "Source")] + public void ToolsCall_GraphTools_TruncatedResponseIncludesEnvelope_Issue1415(string tool, string query) + { + InsertIndexedFile( + "src/paged-graph.cs", + "csharp", + """ + class PagedGraph { + void Source() { Alpha(); Beta(); Gamma(); } + void Alpha() { Target(); } + void Beta() { Target(); } + void Gamma() { Target(); } + void Target() { } + } + """); + + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"TOOL","arguments":{"query":"Target","lang":"csharp","exactName":true,"path":"src/paged-graph.cs","limit":2}}}""" + .Replace("TOOL", tool, StringComparison.Ordinal) + .Replace("Target", query, StringComparison.Ordinal))!; + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + + Assert.Equal(2, structured["count"]!.GetValue()); + Assert.True(structured["truncated"]!.GetValue()); + Assert.True(structured["more_available"]!.GetValue()); + Assert.Equal(2, structured["next_offset"]!.GetValue()); + } + // --- Protocol tests / プロトコルテスト --- [Fact] From 9cb369b320b382b8f37e9b9f93eda0abef0ea1a7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:36:36 +0900 Subject: [PATCH 2/4] Centralize query limit defaults (#1595) --- changelog.d/unreleased/1595.changed.md | 13 ++++++++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 5 ++++- src/CodeIndex/Mcp/McpToolDefinitions.cs | 28 ++++++++++++------------- src/CodeIndex/Mcp/McpToolHandlers.cs | 28 ++++++++++++------------- 4 files changed, 45 insertions(+), 29 deletions(-) create mode 100644 changelog.d/unreleased/1595.changed.md diff --git a/changelog.d/unreleased/1595.changed.md b/changelog.d/unreleased/1595.changed.md new file mode 100644 index 0000000000..d0f56990dc --- /dev/null +++ b/changelog.d/unreleased/1595.changed.md @@ -0,0 +1,13 @@ +--- +category: changed +issues: + - 1595 +--- + +## English + +- Centralized query limit defaults so CLI and MCP defaults are reviewable from shared constants. + +## 日本語 + +- CLI と MCP の query limit 既定値を共有定数へ集約し、既定値をレビューしやすくしました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 7270bf0b96..16bde3d378 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -16,6 +16,9 @@ namespace CodeIndex.Cli; /// public static class QueryCommandRunner { + internal const int DefaultQueryLimit = 20; + internal const int DefaultMapLimit = 10; + internal const int DefaultImpactLimit = 50; internal const string DefaultLimitEnvironmentVariable = "CDIDX_DEFAULT_LIMIT"; internal const string DefaultSnippetLinesEnvironmentVariable = "CDIDX_DEFAULT_SNIPPET_LINES"; internal const string DefaultMaxLineWidthEnvironmentVariable = "CDIDX_DEFAULT_MAX_LINE_WIDTH"; @@ -4370,7 +4373,7 @@ public static QueryCommandOptions ParseArgs( string? dataDir = null; bool? json = null; string jsonOutputFormat = JsonOutputFormatNdjson; - int limit = ResolveDefaultPositiveInt(DefaultLimitEnvironmentVariable, 20, "--limit", out var defaultLimitError); + int limit = ResolveDefaultPositiveInt(DefaultLimitEnvironmentVariable, DefaultQueryLimit, "--limit", out var defaultLimitError); string? lang = null; string? kind = null; string? query = null; diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index d45c36361e..295affa975 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -27,7 +27,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["properties"] = new JsonObject { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query text. Append `*` to a token to make that token a prefix phrase (`計算*` matches `計算する`)." }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated` and `more_available` when more rows exist.", ["default"] = 20 }, + ["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 }, ["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 }, @@ -57,7 +57,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name pattern to resolve" }, ["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"] = 20 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["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." }, @@ -82,7 +82,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Referenced symbol name pattern to search for" }, ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (call, instantiate, subscribe, friend, attribute, annotation, type_reference)" }, ["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"] = 20 }, + ["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 }, ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line context payloads per result (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["lsp_compatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Add file:// uri and LSP range fields to each result", ["default"] = false }, @@ -109,7 +109,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["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." }, ["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"] = 20 }, + ["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 }, ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["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" }, @@ -134,7 +134,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["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." }, ["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"] = 20 }, + ["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 }, ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["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" }, @@ -159,7 +159,7 @@ 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" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, + ["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" }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, @@ -180,7 +180,7 @@ private JsonNode HandleToolsList(JsonNode? id) { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "File path pattern to filter by" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, + ["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"] = "Additional path filter 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 }, @@ -220,7 +220,7 @@ private JsonNode HandleToolsList(JsonNode? id) { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Literal substring to look for" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Required file/path scope. Accepts a single string or an array; multiple values are OR'd together." }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max matching occurrences to return (default: 20)", ["default"] = 20 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max matching occurrences to return (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["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 }, @@ -241,7 +241,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["type"] = "object", ["properties"] = new JsonObject { - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = 10 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = QueryCommandRunner.DefaultMapLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict 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." }, @@ -258,7 +258,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["properties"] = new JsonObject { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name to inspect" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = 10 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = QueryCommandRunner.DefaultMapLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content in definitions when available", ["default"] = false }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp bundled reference context lines so single-line files stay bounded (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, @@ -283,7 +283,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name to analyze impact for" }, ["maxHops"] = new JsonObject { ["type"] = "integer", ["description"] = "Max BFS hops, inclusive (default: 5; maxHops: N returns callers at hop 1..N, so a chain A→B→C→D queried against D with maxHops: 2 yields C at hop 1 and B at hop 2; 0 resolves the symbol without traversing callers). Server-side cap: 50; requests above the cap are clamped and a `warnings` entry plus `max_hops_requested` field is added to the response.", ["default"] = 5, ["minimum"] = 0, ["maximum"] = 50 }, ["maxDepth"] = new JsonObject { ["type"] = "integer", ["description"] = "Deprecated alias for `maxHops`; accepted during the compatibility period and reported in `warnings` when used.", ["minimum"] = 0, ["maximum"] = 50 }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max total callers or heuristic file-level dependency hints to return (default: 50). Check `truncated` when the limit is reached; `truncated_reason` distinguishes `user_limit` (raise `limit` to get more) from `safety_cap` (pathological graph, raising `limit` will not help).", ["default"] = 50 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max total callers or heuristic file-level dependency hints to return (default: 50). Check `truncated` when the limit is reached; `truncated_reason` distinguishes `user_limit` (raise `limit` to get more) from `safety_cap` (pathological graph, raising `limit` will not help).", ["default"] = QueryCommandRunner.DefaultImpactLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict 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" }, @@ -325,7 +325,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["type"] = "object", ["properties"] = new JsonObject { - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max edges (default: 50)", ["default"] = 50 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max edges (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["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." }, @@ -430,7 +430,7 @@ 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"] = 20 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["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." }, @@ -455,7 +455,7 @@ 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"] = 50 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, ["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." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude paths containing any of these texts" }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files (default: false)", ["default"] = false } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 850af962ce..68e4c4912a 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -728,7 +728,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) if (query.Length > QueryLimits.MaxQueryLength) return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); var snippetLines = SearchSnippetFormatter.ClampSnippetLines(args?["snippetLines"]?.GetValue() ?? SearchSnippetFormatter.DefaultSnippetLines); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) @@ -832,7 +832,7 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, "'names' is present but contains no usable entries (all were empty or whitespace)."); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var pathPatterns = ReadScopedPathList(args); @@ -927,7 +927,7 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var includeBody = args?["includeBody"]?.GetValue() ?? false; var lspCompatible = args?["lsp_compatible"]?.GetValue() ?? false; var pathPatterns = ReadScopedPathList(args); @@ -989,7 +989,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var lspCompatible = args?["lsp_compatible"]?.GetValue() ?? false; var offset = ReadOffset(args); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) @@ -1080,7 +1080,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) if (IsNonCallGraphReferenceKind(kind)) return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callers", kind!)); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var offset = ReadOffset(args); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); @@ -1168,7 +1168,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) if (IsNonCallGraphReferenceKind(kind)) return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callees", kind!)); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var offset = ReadOffset(args); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); @@ -1249,7 +1249,7 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) if (query != null && query.Length > QueryLimits.MaxQueryLength) return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -1297,7 +1297,7 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) { var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 10); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultMapLimit); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -1330,7 +1330,7 @@ private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) if (IsBareVerbatimQueryToken(query)) return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 10); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultMapLimit); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var includeBody = args?["includeBody"]?.GetValue() ?? false; if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) @@ -1805,7 +1805,7 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) ? "Parameter \"path\" cannot be empty or whitespace-only" : "Missing required parameter: path"); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -2334,7 +2334,7 @@ private static string BuildArgsSummary(JsonNode? toolArgs) private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) { - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 50); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultImpactLimit); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); @@ -2379,7 +2379,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) var usedDeprecatedMaxDepth = deprecatedMaxDepthNode != null; var maxDepthRequested = maxHopsNode?.GetValue() ?? deprecatedMaxDepthNode?.GetValue() ?? 5; var maxDepth = Math.Clamp(maxDepthRequested, 0, MaxImpactDepth); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 50); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultImpactLimit); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); @@ -2553,7 +2553,7 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args) { - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var groupBy = args?["groupBy"]?.GetValue()?.ToLowerInvariant() @@ -2657,7 +2657,7 @@ private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args) private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) { - var limit = ClampLimit(args?["limit"]?.GetValue() ?? 50); + var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultImpactLimit); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var pathPatterns = ReadScopedPathList(args); From 76d2dde1f44bb5b264c5c504473a4cff8b8c894f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:37:20 +0900 Subject: [PATCH 3/4] Cap MCP excerpt output bytes (#1605) --- changelog.d/unreleased/1605.fixed.md | 13 ++++++++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 3 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 44 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/1605.fixed.md diff --git a/changelog.d/unreleased/1605.fixed.md b/changelog.d/unreleased/1605.fixed.md new file mode 100644 index 0000000000..98d18c8b3e --- /dev/null +++ b/changelog.d/unreleased/1605.fixed.md @@ -0,0 +1,13 @@ +--- +category: fixed +issues: + - 1605 +--- + +## English + +- Added an MCP `excerpt` output byte cap that truncates content at line boundaries and reports `truncated` with `truncation_reason: output_size_cap`. + +## 日本語 + +- MCP `excerpt` に出力 byte 上限を追加し、行境界で content を切り詰めて `truncated` と `truncation_reason: output_size_cap` を返すようにしました。 diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 295affa975..9fb18b029e 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -205,7 +205,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["focusLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional line inside the excerpt whose focused column should stay visible when clamping; requires focusColumn", ["minimum"] = 1 }, ["focusColumn"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based column to keep centered when clamping long single-line content; must be within the focused line length", ["minimum"] = 1 }, ["focusLength"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional focused span width when clamping (default: 1); requires focusColumn", ["default"] = 1, ["minimum"] = 1 }, - ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line excerpt payloads per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth } + ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line excerpt payloads per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, + ["maxOutputBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Cap excerpt content bytes at a line boundary (default: 1048576; maximum: 1048576). Responses set `truncated: true` and `truncation_reason: output_size_cap` when the cap is reached.", ["default"] = MaxLineByteLength, ["minimum"] = 1, ["maximum"] = MaxLineByteLength } }, ["required"] = new JsonArray { "path", "startLine" } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 68e4c4912a..a48b8d4d80 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -17,6 +17,7 @@ namespace CodeIndex.Mcp; public partial class McpServer { private const int DefaultBatchQueryResponseByteLimit = MaxLineByteLength; + 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; @@ -1728,6 +1729,8 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) var explicitFocusLength = args?["focusLength"] != null; if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; + if (!TryReadMaxOutputBytes(args, out var maxOutputBytes, out var maxOutputBytesError)) + return CreateToolErrorResponse(id, maxOutputBytesError!); if (focusLine.HasValue && focusLine.Value <= 0) return CreateToolErrorResponse(id, "focusLine must be greater than or equal to 1"); @@ -1782,6 +1785,8 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) } var payload = JsonSerializer.SerializeToNode(excerpt, _jsonOptions)!.AsObject(); + ApplyExcerptOutputBudget(payload, maxOutputBytes); + payload["maxOutputBytes"] = maxOutputBytes; payload["maxLineWidth"] = maxLineWidth; if (focusLine.HasValue) payload["focusLine"] = focusLine.Value; @@ -1792,6 +1797,45 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) }); } + private static bool TryReadMaxOutputBytes(JsonNode? args, out int maxOutputBytes, out string? error) + { + maxOutputBytes = DefaultExcerptOutputByteLimit; + error = null; + if (args?["maxOutputBytes"] is not JsonNode node) + return true; + var requested = node.GetValue(); + if (requested <= 0) + { + error = "maxOutputBytes must be greater than or equal to 1"; + return false; + } + maxOutputBytes = Math.Min(requested, DefaultExcerptOutputByteLimit); + return true; + } + + private static void ApplyExcerptOutputBudget(JsonObject payload, int maxOutputBytes) + { + var contentKey = payload.ContainsKey("content") ? "content" : "Content"; + if (payload[contentKey]?.GetValue() is not string content) + return; + if (Encoding.UTF8.GetByteCount(content) <= maxOutputBytes) + return; + + var builder = new StringBuilder(); + foreach (var line in content.Replace("\r\n", "\n").Split('\n')) + { + var candidate = builder.Length == 0 ? line : builder.ToString() + "\n" + line; + if (Encoding.UTF8.GetByteCount(candidate) > maxOutputBytes) + break; + builder.Clear(); + builder.Append(candidate); + } + payload[contentKey] = builder.ToString(); + payload["contentTruncated"] = true; + payload["truncated"] = true; + payload["truncation_reason"] = "output_size_cap"; + } + private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) { if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) From 635e78d161242657f7526a8ead4dee16894ed9ac Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:57:06 +0900 Subject: [PATCH 4/4] Cover MCP excerpt output budget (#1605) --- src/CodeIndex/Mcp/McpToolHandlers.cs | 2 +- tests/CodeIndex.Tests/McpServerTests.cs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index a710974651..e552b788e3 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1905,7 +1905,7 @@ private static bool TryReadMaxOutputBytes(JsonNode? args, out int maxOutputBytes return true; } - private static void ApplyExcerptOutputBudget(JsonObject payload, int maxOutputBytes) + internal static void ApplyExcerptOutputBudget(JsonObject payload, int maxOutputBytes) { var contentKey = payload.ContainsKey("content") ? "content" : "Content"; if (payload[contentKey]?.GetValue() is not string content) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index ce316580d2..f85627988a 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6450,6 +6450,23 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() } } + [Fact] + public void ApplyExcerptOutputBudget_TruncatesAtLineBoundary_Issue1605() + { + var payload = new JsonObject + { + ["content"] = "short\n" + new string('x', 200), + ["contentTruncated"] = false, + }; + + McpServer.ApplyExcerptOutputBudget(payload, 20); + + Assert.True(payload["truncated"]!.GetValue()); + Assert.Equal("output_size_cap", payload["truncation_reason"]!.GetValue()); + Assert.Equal("short", payload["content"]!.GetValue()); + Assert.True(payload["contentTruncated"]!.GetValue()); + } + [Fact] public void ToolsCall_BatchQuery_ArgsSummaryReflectsRequestedArguments_Issue1537() {