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
4 changes: 2 additions & 2 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2002,7 +2002,7 @@ When both are set, the allowlist wins. `tools/list` only advertises enabled tool

#### MCP roots and sampling

`cdidx mcp` advertises roots and sampling support during `initialize`. When the client supports roots, `index` refreshes `roots/list` and rejects paths outside the granted client roots. `suggest_improvement` uses `sampling/createMessage` to extract an optional one-line title and tag list before storing the raw suggestion. Set `CDIDX_MCP_SAMPLING=0` (or `false` / `off`) to disable server-to-client sampling requests.
`cdidx mcp` advertises roots and sampling support during `initialize`. When the client supports roots, `index` refreshes `roots/list` and rejects paths outside the granted client roots. `suggest_improvement` uses `sampling/createMessage` to extract an optional one-line title and tag list before storing the raw suggestion. Sampling prompts are byte-bounded, long fields are clamped to one-line summaries, and `toolInvocationContext` is summarized without sending its raw content to the sampling client. Set `CDIDX_MCP_SAMPLING=0` (or `false` / `off`) to disable server-to-client sampling requests.

### Why cdidx over grep/ripgrep for AI workflows?

Expand Down Expand Up @@ -4087,7 +4087,7 @@ stdio トランスポートはバイト単位で挙動が変わらないため

#### MCP roots と sampling

`cdidx mcp` は `initialize` で roots と sampling support を広告します。クライアントが roots をサポートする場合、`index` は `roots/list` を更新し、許可された client root の外にある path を拒否します。`suggest_improvement` は raw suggestion を保存する前に `sampling/createMessage` で任意の 1 行タイトルとタグ一覧を抽出します。server-to-client sampling request を無効化するには `CDIDX_MCP_SAMPLING=0`(または `false` / `off`)を設定してください。
`cdidx mcp` は `initialize` で roots と sampling support を広告します。クライアントが roots をサポートする場合、`index` は `roots/list` を更新し、許可された client root の外にある path を拒否します。`suggest_improvement` は raw suggestion を保存する前に `sampling/createMessage` で任意の 1 行タイトルとタグ一覧を抽出します。sampling prompt は byte 上限内に収められ、長い field は 1 行 summary に切り詰められ、`toolInvocationContext` は raw 内容を sampling client に送らず summary 化されます。server-to-client sampling request を無効化するには `CDIDX_MCP_SAMPLING=0`(または `false` / `off`)を設定してください。

### AIワークフローで grep/ripgrep より cdidx が優れる理由

Expand Down
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2897.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2897
affected:
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/McpServerTests.cs
---

## English

- **MCP suggestion sampling now bounds sampled JSON before parsing (#2897)** — `suggest_improvement` ignores oversized or overly deep sampling responses instead of parsing unbounded client-provided metadata JSON.

## 日本語

- **MCP suggestion sampling が sampled JSON を parse 前に制限するようになりました (#2897)** — `suggest_improvement` は sampling client から返った metadata JSON が大きすぎる、または深すぎる場合に、無制限に parse せず metadata enrichment を失敗扱いにします。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2898.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2898
affected:
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- USER_GUIDE.md
---

## English

- **MCP suggestion sampling now bounds outbound prompts (#2898)** — `suggest_improvement` clamps sampling prompt fields, enforces a complete prompt byte budget, and summarizes `toolInvocationContext` without forwarding its raw content to the sampling client.

## 日本語

- **MCP suggestion sampling が送信 prompt を制限するようになりました (#2898)** — `suggest_improvement` は sampling prompt の field を切り詰め、prompt 全体の byte budget を強制し、`toolInvocationContext` の raw 内容を sampling client に転送せず summary 化します。
164 changes: 150 additions & 14 deletions src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3966,6 +3966,14 @@ private static JsonObject BuildBackfillProgressJson(int rowsDone, int rowsTotal)
/// </summary>
private const int MaxContextLength = 1000;

private const int MaxSamplingPromptBytes = 4096;
private const int MaxSamplingShortFieldChars = 80;
private const int MaxSamplingDescriptionChars = 800;
private const int MaxSamplingContextChars = 400;
private const int MaxSamplingToolInvocationSummaryChars = 160;
private const int MaxSamplingResponseTextChars = 8192;
private const int MaxSamplingResponseJsonDepth = 16;

/// <summary>
/// Handle the suggest_improvement tool call.
/// Records a structured suggestion to .cdidx/suggestions-*.json.
Expand Down Expand Up @@ -4164,18 +4172,7 @@ private sealed record SuggestionSamplingResult(string? Title, string[]? Tags);
if (!IsSamplingEnabled() || !HasClientCapability("sampling"))
return null;

var prompt = new StringBuilder();
prompt.AppendLine("Extract structured metadata for a cdidx improvement suggestion.");
prompt.AppendLine("Return only compact JSON with keys: title (one line, <=80 chars) and tags (array of 1-6 lowercase identifiers).");
prompt.AppendLine("Do not include source code.");
prompt.AppendLine($"category: {category}");
if (!string.IsNullOrWhiteSpace(language))
prompt.AppendLine($"language: {language}");
prompt.AppendLine($"description: {description}");
if (!string.IsNullOrWhiteSpace(context))
prompt.AppendLine($"context: {context}");
if (!string.IsNullOrWhiteSpace(toolInvocationContext))
prompt.AppendLine($"tool_invocation_context: {toolInvocationContext}");
var prompt = BuildSuggestionSamplingPrompt(category, language, description, context, toolInvocationContext);

var result = await SendClientRequestAsync("sampling/createMessage", new JsonObject
{
Expand All @@ -4187,7 +4184,7 @@ private sealed record SuggestionSamplingResult(string? Title, string[]? Tags);
["content"] = new JsonObject
{
["type"] = "text",
["text"] = prompt.ToString(),
["text"] = prompt,
}
}
},
Expand All @@ -4197,9 +4194,11 @@ private sealed record SuggestionSamplingResult(string? Title, string[]? Tags);
var text = ExtractSamplingText(result);
if (string.IsNullOrWhiteSpace(text))
return null;
if (text.Length > MaxSamplingResponseTextChars)
return null;
try
{
var parsed = JsonNode.Parse(text);
var parsed = JsonNode.Parse(text, documentOptions: new JsonDocumentOptions { MaxDepth = MaxSamplingResponseJsonDepth });
var title = SanitizeSampledTitle(TryReadStringValue(parsed?["title"]));
var tags = parsed?["tags"] is JsonArray tagArray
? tagArray.Select(TryReadStringValue)
Expand All @@ -4221,6 +4220,143 @@ private sealed record SuggestionSamplingResult(string? Title, string[]? Tags);
}
}

private static string BuildSuggestionSamplingPrompt(
string category,
string? language,
string description,
string? context,
string? toolInvocationContext)
{
var prompt = new StringBuilder();
var remainingBytes = MaxSamplingPromptBytes;
AppendSamplingPromptLine(prompt, "Extract structured metadata for a cdidx improvement suggestion.", ref remainingBytes);
AppendSamplingPromptLine(prompt, "Return only compact JSON with keys: title (one line, <=80 chars) and tags (array of 1-6 lowercase identifiers).", ref remainingBytes);
AppendSamplingPromptLine(prompt, "Do not include source code.", ref remainingBytes);
AppendSamplingPromptField(prompt, "category", category, MaxSamplingShortFieldChars, ref remainingBytes);
if (!string.IsNullOrWhiteSpace(language))
AppendSamplingPromptField(prompt, "language", language, MaxSamplingShortFieldChars, ref remainingBytes);
AppendSamplingPromptField(prompt, "description", description, MaxSamplingDescriptionChars, ref remainingBytes);
if (!string.IsNullOrWhiteSpace(context))
AppendSamplingPromptField(prompt, "context", context, MaxSamplingContextChars, ref remainingBytes);
if (!string.IsNullOrWhiteSpace(toolInvocationContext))
{
var summary = SummarizeToolInvocationContextForSampling(toolInvocationContext);
AppendSamplingPromptField(prompt, "tool_invocation_context", summary, MaxSamplingToolInvocationSummaryChars, ref remainingBytes);
}

return prompt.ToString();
}

private static void AppendSamplingPromptField(StringBuilder prompt, string name, string value, int maxChars, ref int remainingBytes)
{
var sanitized = SanitizeSamplingPromptField(value, maxChars);
if (sanitized.Length == 0)
return;
AppendSamplingPromptLine(prompt, $"{name}: {sanitized}", ref remainingBytes);
}

private static void AppendSamplingPromptLine(StringBuilder prompt, string line, ref int remainingBytes)
{
if (remainingBytes <= 0)
return;

var lineBytes = Encoding.UTF8.GetByteCount(line) + 1;
if (lineBytes > remainingBytes)
{
const string suffix = " ... [truncated]";
var suffixBytes = Encoding.UTF8.GetByteCount(suffix);
var prefixBudget = remainingBytes - suffixBytes - 1;
if (prefixBudget <= 0)
return;
line = TruncateUtf8(line, prefixBudget).TrimEnd() + suffix;
lineBytes = Encoding.UTF8.GetByteCount(line) + 1;
if (lineBytes > remainingBytes)
return;
}

prompt.Append(line);
prompt.Append('\n');
remainingBytes -= lineBytes;
}

private static string SanitizeSamplingPromptField(string value, int maxChars)
{
var collapsed = CollapseSamplingPromptWhitespace(value);
if (collapsed.Length <= maxChars)
return collapsed;
var end = Math.Min(maxChars, collapsed.Length);
if (end > 0 && char.IsHighSurrogate(collapsed[end - 1]))
end--;
return collapsed[..end].TrimEnd() + " ... [truncated]";
}

private static string CollapseSamplingPromptWhitespace(string value)
{
var trimmed = value.Trim();
var collapsed = new StringBuilder(trimmed.Length);
var previousWhitespace = false;
foreach (var ch in trimmed)
{
if (char.IsControl(ch) || char.IsWhiteSpace(ch))
{
if (!previousWhitespace)
collapsed.Append(' ');
previousWhitespace = true;
continue;
}

collapsed.Append(ch);
previousWhitespace = false;
}

return collapsed.ToString().Trim();
}

private static string SummarizeToolInvocationContextForSampling(string value)
{
var trimmed = value.Trim();
var lineCount = CountLogicalLines(trimmed);
var byteCount = Encoding.UTF8.GetByteCount(trimmed);
return $"provided; {trimmed.Length} chars; {byteCount} UTF-8 bytes; {lineCount} line(s); raw content withheld";
}

private static int CountLogicalLines(string value)
{
if (value.Length == 0)
return 0;
var lines = 1;
foreach (var ch in value)
{
if (ch == '\n')
lines++;
}
return lines;
}

private static string TruncateUtf8(string value, int maxBytes)
{
if (maxBytes <= 0)
return string.Empty;
if (Encoding.UTF8.GetByteCount(value) <= maxBytes)
return value;

var low = 0;
var high = value.Length;
while (low < high)
{
var mid = low + ((high - low + 1) / 2);
var bytes = Encoding.UTF8.GetByteCount(value.AsSpan(0, mid));
if (bytes <= maxBytes)
low = mid;
else
high = mid - 1;
}

if (low > 0 && char.IsHighSurrogate(value[low - 1]))
low--;
return value[..low];
}

private bool HasClientCapability(string name)
=> _clientCapabilities is JsonObject obj
&& obj.TryGetPropertyValue(name, out var node)
Expand Down
Loading
Loading