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
13 changes: 13 additions & 0 deletions changelog.d/unreleased/1415.fixed.md
Original file line number Diff line number Diff line change
@@ -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 を返すことを確認しました。
13 changes: 13 additions & 0 deletions changelog.d/unreleased/1595.changed.md
Original file line number Diff line number Diff line change
@@ -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 既定値を共有定数へ集約し、既定値をレビューしやすくしました。
13 changes: 13 additions & 0 deletions changelog.d/unreleased/1605.fixed.md
Original file line number Diff line number Diff line change
@@ -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` を返すようにしました。
5 changes: 4 additions & 1 deletion src/CodeIndex/Cli/QueryCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ namespace CodeIndex.Cli;
/// </summary>
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";
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 16 additions & 15 deletions src/CodeIndex/Mcp/McpToolDefinitions.cs

Large diffs are not rendered by default.

72 changes: 58 additions & 14 deletions src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue<string>());
var snippetLines = SearchSnippetFormatter.ClampSnippetLines(args?["snippetLines"]?.GetValue<int>() ?? SearchSnippetFormatter.DefaultSnippetLines);
if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError)
Expand Down Expand Up @@ -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<string>()?.ToLowerInvariant();
var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue<string>());
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError)
return maxLineWidthError;
var pathPatterns = ReadScopedPathList(args);
Expand Down Expand Up @@ -1030,7 +1031,7 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args)

var kind = args?["kind"]?.GetValue<string>()?.ToLowerInvariant();
var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue<string>());
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
var includeBody = args?["includeBody"]?.GetValue<bool>() ?? false;
var lspCompatible = args?["lsp_compatible"]?.GetValue<bool>() ?? false;
var pathPatterns = ReadScopedPathList(args);
Expand Down Expand Up @@ -1111,7 +1112,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args)

var kind = args?["kind"]?.GetValue<string>()?.ToLowerInvariant();
var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue<string>());
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
var lspCompatible = args?["lsp_compatible"]?.GetValue<bool>() ?? false;
var offset = ReadOffset(args);
if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError)
Expand Down Expand Up @@ -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<string>());
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
var offset = ReadOffset(args);
var pathPatterns = ReadScopedPathList(args);
var excludePaths = ReadStringList(args, "excludePaths");
Expand Down Expand Up @@ -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<string>());
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
var offset = ReadOffset(args);
var pathPatterns = ReadScopedPathList(args);
var excludePaths = ReadStringList(args, "excludePaths");
Expand Down Expand Up @@ -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<string>());
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
var pathPatterns = ReadScopedPathList(args);
var excludePaths = ReadStringList(args, "excludePaths");
var excludeTests = args?["excludeTests"]?.GetValue<bool>() ?? false;
Expand Down Expand Up @@ -1445,7 +1446,7 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args)
private JsonNode ExecuteMap(JsonNode? id, JsonNode? args)
{
var lang = args?["lang"]?.GetValue<string>()?.ToLowerInvariant();
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 10);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultMapLimit);
var pathPatterns = ReadScopedPathList(args);
var excludePaths = ReadStringList(args, "excludePaths");
var excludeTests = args?["excludeTests"]?.GetValue<bool>() ?? false;
Expand Down Expand Up @@ -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<int>() ?? 10);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultMapLimit);
var lang = args?["lang"]?.GetValue<string>()?.ToLowerInvariant();
var includeBody = args?["includeBody"]?.GetValue<bool>() ?? false;
if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError)
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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<int>();
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<string>() 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))
Expand All @@ -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<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
var lang = args?["lang"]?.GetValue<string>()?.ToLowerInvariant();
var excludePaths = ReadStringList(args, "excludePaths");
var excludeTests = args?["excludeTests"]?.GetValue<bool>() ?? false;
Expand Down Expand Up @@ -2536,7 +2580,7 @@ private static string BuildArgsSummary(JsonNode? toolArgs)

private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args)
{
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 50);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultImpactLimit);
var lang = args?["lang"]?.GetValue<string>()?.ToLowerInvariant();
var pathPatterns = ReadScopedPathList(args);
var excludePaths = ReadStringList(args, "excludePaths");
Expand Down Expand Up @@ -2581,7 +2625,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args)
var usedDeprecatedMaxDepth = deprecatedMaxDepthNode != null;
var maxDepthRequested = maxHopsNode?.GetValue<int>() ?? deprecatedMaxDepthNode?.GetValue<int>() ?? 5;
var maxDepth = Math.Clamp(maxDepthRequested, 0, MaxImpactDepth);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 50);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultImpactLimit);
var lang = args?["lang"]?.GetValue<string>()?.ToLowerInvariant();
var pathPatterns = ReadScopedPathList(args);
var excludePaths = ReadStringList(args, "excludePaths");
Expand Down Expand Up @@ -2756,7 +2800,7 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args)

private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args)
{
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 20);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultQueryLimit);
var kind = args?["kind"]?.GetValue<string>()?.ToLowerInvariant();
var lang = args?["lang"]?.GetValue<string>()?.ToLowerInvariant();
var groupBy = args?["groupBy"]?.GetValue<string>()?.ToLowerInvariant()
Expand Down Expand Up @@ -2868,7 +2912,7 @@ private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args)

private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args)
{
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? 50);
var limit = ClampLimit(args?["limit"]?.GetValue<int>() ?? QueryCommandRunner.DefaultImpactLimit);
var kind = args?["kind"]?.GetValue<string>()?.ToLowerInvariant();
var lang = args?["lang"]?.GetValue<string>()?.ToLowerInvariant();
var pathPatterns = ReadScopedPathList(args);
Expand Down
48 changes: 48 additions & 0 deletions tests/CodeIndex.Tests/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>());
Assert.True(structured["truncated"]!.GetValue<bool>());
Assert.True(structured["more_available"]!.GetValue<bool>());
Assert.Equal(2, structured["next_offset"]!.GetValue<int>());
}

[Fact]
public void ToolsCall_Search_WithResultsIncludesNextStepSuggestion()
{
Expand Down Expand Up @@ -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<bool>());
Assert.Equal("output_size_cap", payload["truncation_reason"]!.GetValue<string>());
Assert.Equal("short", payload["content"]!.GetValue<string>());
Assert.True(payload["contentTruncated"]!.GetValue<bool>());
}

[Fact]
public void ToolsCall_BatchQuery_ArgsSummaryReflectsRequestedArguments_Issue1537()
{
Expand Down
Loading