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/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/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/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index b939afffa8..7d15e54c3c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -17,6 +17,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"; @@ -4552,7 +4555,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 5e167e09e5..b7d06ed77d 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 }, @@ -58,7 +58,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." }, @@ -84,7 +84,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 }, @@ -112,7 +112,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" }, @@ -138,7 +138,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" }, @@ -164,7 +164,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 }, @@ -185,7 +185,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 }, @@ -210,7 +210,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" } }, @@ -225,7 +226,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 }, @@ -250,7 +251,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." }, @@ -267,7 +268,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 }, @@ -292,7 +293,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" }, @@ -334,7 +335,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." }, @@ -443,7 +444,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." }, @@ -468,7 +469,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 50de8ef1c4..479fb8fa91 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -18,6 +18,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; @@ -815,7 +816,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) @@ -935,7 +936,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); @@ -1030,7 +1031,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); @@ -1111,7 +1112,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) @@ -1216,7 +1217,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"); @@ -1310,7 +1311,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"); @@ -1397,7 +1398,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; @@ -1445,7 +1446,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; @@ -1478,7 +1479,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) @@ -1877,6 +1878,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"); @@ -1937,6 +1940,8 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) } var payload = JsonSerializer.SerializeToNode(excerpt, _jsonOptions)!.AsObject(); + ApplyExcerptOutputBudget(payload, maxOutputBytes); + payload["maxOutputBytes"] = maxOutputBytes; payload["before"] = before; payload["after"] = after; payload["contextTruncated"] = contextTruncated; @@ -1954,6 +1959,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; + } + + internal 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)) @@ -1967,7 +2011,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; @@ -2536,7 +2580,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"); @@ -2581,7 +2625,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"); @@ -2756,7 +2800,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() @@ -2868,7 +2912,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); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index cf6e34e116..64722bebf1 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -258,6 +258,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()); + } + [Fact] public void ToolsCall_Search_WithResultsIncludesNextStepSuggestion() { @@ -6449,6 +6480,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() {