From 43c4958cf1d169aad5b69fb77ff744980cd10483 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:06:11 +0900 Subject: [PATCH 1/5] Split MCP tool argument contracts (#3382) --- changelog.d/unreleased/3382.changed.md | 16 ++++++++ src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 39 +++++++++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 35 ----------------- 3 files changed, 55 insertions(+), 35 deletions(-) create mode 100644 changelog.d/unreleased/3382.changed.md create mode 100644 src/CodeIndex/Mcp/McpToolArgumentContracts.cs diff --git a/changelog.d/unreleased/3382.changed.md b/changelog.d/unreleased/3382.changed.md new file mode 100644 index 0000000000..04aeda0079 --- /dev/null +++ b/changelog.d/unreleased/3382.changed.md @@ -0,0 +1,16 @@ +--- +category: changed +issues: + - 3382 +affected: + - src/CodeIndex/Mcp/McpToolArgumentContracts.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs +--- + +## English + +- **MCP tool argument contracts now live in a focused module (#3382)** — The server keeps tool-name and argument allow-list validation separate from handler execution so the MCP surface is easier to review and maintain. + +## 日本語 + +- **MCP tool argument contract を専用 module に分離しました (#3382)** — tool name と argument allow-list の検証を handler 実行から分け、MCP surface を確認・保守しやすくしました。 diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs new file mode 100644 index 0000000000..ff7b35647a --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -0,0 +1,39 @@ +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private static bool IsKnownToolName(string toolName) => toolName switch + { + "search" or "definition" or "references" or "callers" or "callees" or "symbols" or + "files" or "find_in_file" or "excerpt" or "map" or "analyze_symbol" or "status" or + "outline" or "batch_query" or "deps" or "impact_analysis" or "languages" or "validate" or + "unused_symbols" or "symbol_hotspots" or "ping" or "index" or "backfill_fold" or + "suggest_improvement" => true, + _ => false, + }; + + private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch + { + "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, + "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, + "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, + "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, + "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" }, + "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "project", "solution" }, + "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, + "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" }, + "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "project", "solution" }, + "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" }, + "outline" => new HashSet(StringComparer.Ordinal) { "path" }, + "batch_query" => new HashSet(StringComparer.Ordinal) { "queries", "maxResponseBytes", "estimateOnly" }, + "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, + "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, + "validate" => new HashSet(StringComparer.Ordinal) { "kind", "path", "excludePaths", "excludeTests", "project", "solution" }, + "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "project", "solution" }, + "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, + "index" => new HashSet(StringComparer.Ordinal) { "path", "rebuild", "maxFileBytes" }, + "backfill_fold" => new HashSet(StringComparer.Ordinal) { "dry_run", "dryRun", "force" }, + "suggest_improvement" => new HashSet(StringComparer.Ordinal) { "category", "language", "description", "context", "toolInvocationContext", "evidencePaths", "evidence_paths" }, + _ => new HashSet(StringComparer.Ordinal), + }; +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 33498999d0..83d46ae0e7 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -840,41 +840,6 @@ private static string DescribeJsonType(JsonNode? node) }; } - private static bool IsKnownToolName(string toolName) => toolName switch - { - "search" or "definition" or "references" or "callers" or "callees" or "symbols" or - "files" or "find_in_file" or "excerpt" or "map" or "analyze_symbol" or "status" or - "outline" or "batch_query" or "deps" or "impact_analysis" or "languages" or "validate" or - "unused_symbols" or "symbol_hotspots" or "ping" or "index" or "backfill_fold" or - "suggest_improvement" => true, - _ => false, - }; - - private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch - { - "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, - "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, - "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, - "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, - "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" }, - "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "project", "solution" }, - "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, - "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" }, - "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "project", "solution" }, - "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" }, - "outline" => new HashSet(StringComparer.Ordinal) { "path" }, - "batch_query" => new HashSet(StringComparer.Ordinal) { "queries", "maxResponseBytes", "estimateOnly" }, - "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, - "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, - "validate" => new HashSet(StringComparer.Ordinal) { "kind", "path", "excludePaths", "excludeTests", "project", "solution" }, - "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "project", "solution" }, - "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, - "index" => new HashSet(StringComparer.Ordinal) { "path", "rebuild", "maxFileBytes" }, - "backfill_fold" => new HashSet(StringComparer.Ordinal) { "dry_run", "dryRun", "force" }, - "suggest_improvement" => new HashSet(StringComparer.Ordinal) { "category", "language", "description", "context", "toolInvocationContext", "evidencePaths", "evidence_paths" }, - _ => new HashSet(StringComparer.Ordinal), - }; - private static JsonObject? ValidateStringListArgument(JsonNode? args, string propertyName) { var node = args?[propertyName]; From 4197fd7761e8890943f7c3ae1a4f7080c56db307 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:12:19 +0900 Subject: [PATCH 2/5] Require explicit MCP suggestion sampling opt-in (#3405) --- USER_GUIDE.md | 4 +- changelog.d/unreleased/3405.security.md | 17 ++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 74 +++++++++++++++-- tests/CodeIndex.Tests/McpServerTests.cs | 103 +++++++++++++++++++++++- 4 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 changelog.d/unreleased/3405.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 30ed7283e6..70b50292a5 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2247,7 +2247,7 @@ Filter parsing also warns on `stderr` when an allow/deny variable is empty, cont #### 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. 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. +`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` only calls `sampling/createMessage` when the client advertises sampling and `CDIDX_MCP_SAMPLING` is explicitly opted in with `1`, `true`, `yes`, or `on`; unset, opt-out, and unrecognized values fail closed and return a bounded `sampling_diagnostic` in the tool result. When enabled, sampling extracts 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. ### Why cdidx over grep/ripgrep for AI workflows? @@ -4588,7 +4588,7 @@ filter 解析では、allow / deny 変数が空、CSV 内に空 entry がある #### MCP roots と sampling -`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`)を設定してください。 +`cdidx mcp` は `initialize` で roots と sampling support を広告します。クライアントが roots をサポートする場合、`index` は `roots/list` を更新し、許可された client root の外にある path を拒否します。`suggest_improvement` は、クライアントが sampling を広告し、かつ `CDIDX_MCP_SAMPLING` が `1`、`true`、`yes`、`on` のいずれかで明示 opt-in された場合だけ `sampling/createMessage` を呼びます。未設定、opt-out、不明な値は fail closed になり、tool result に bounded な `sampling_diagnostic` を返します。有効な場合は raw suggestion を保存する前に任意の 1 行タイトルとタグ一覧を抽出します。sampling prompt は byte 上限内に収められ、長い field は 1 行 summary に切り詰められ、`toolInvocationContext` は raw 内容を sampling client に送らず summary 化されます。 ### AIワークフローで grep/ripgrep より cdidx が優れる理由 diff --git a/changelog.d/unreleased/3405.security.md b/changelog.d/unreleased/3405.security.md new file mode 100644 index 0000000000..49a87baa48 --- /dev/null +++ b/changelog.d/unreleased/3405.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3405 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP suggestion sampling now requires explicit opt-in (#3405)** — `suggest_improvement` only sends `sampling/createMessage` when `CDIDX_MCP_SAMPLING` is set to an affirmative value, and disabled or invalid settings return bounded sampling diagnostics instead of calling the client. + +## 日本語 + +- **MCP suggestion sampling が明示 opt-in 必須になりました (#3405)** — `suggest_improvement` は `CDIDX_MCP_SAMPLING` が有効値の場合だけ `sampling/createMessage` を送信し、無効または不正な設定では client を呼ばず bounded な sampling diagnostic を返します。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 83d46ae0e7..1c5a18bbc2 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -4924,12 +4924,14 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo if (toolInvocationContext != null && SourceCodeDetector.ContainsSourceCode(toolInvocationContext)) return CreateToolErrorResponse(id, "Tool invocation context appears to contain source code. Please describe the invocation without including code."); + var samplingDecision = ResolveSuggestionSamplingDecision(); var sampling = await TrySampleSuggestionMetadataAsync( category, language, RedactSuggestionSamplingInput(description), context == null ? null : RedactSuggestionSamplingInput(context), - toolInvocationContext == null ? null : RedactSuggestionSamplingInput(toolInvocationContext)).ConfigureAwait(false); + toolInvocationContext == null ? null : RedactSuggestionSamplingInput(toolInvocationContext), + samplingDecision).ConfigureAwait(false); sampling = RedactSuggestionSamplingResult(sampling); // 4. Compute dedup hash / 重複排除ハッシュを計算 @@ -5016,6 +5018,7 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo dupPayload["upstream_url"] = result.UpstreamUrl; dupPayload["github_issue_url"] = result.UpstreamUrl; } + AddSuggestionSamplingDiagnostics(dupPayload, samplingDecision, sampling); return CreateToolResult(id, "Duplicate suggestion (already recorded).", dupPayload); } @@ -5032,6 +5035,7 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo ["lifecycle_status"] = JsonNamingPolicy.SnakeCaseLower.ConvertName(result.Status.ToString()), ["cdidx_dir"] = cdidxDir, }; + AddSuggestionSamplingDiagnostics(payload, samplingDecision, sampling); if (result.SubmissionError != null) payload["github_submission_error"] = result.SubmissionError; if (result.UpstreamUrl != null) @@ -5114,6 +5118,11 @@ private static bool StartsWithHttpStatusCode(string value) private sealed record SuggestionSamplingResult(string? Title, string[]? Tags); + private readonly record struct SuggestionSamplingDecision( + bool ShouldRequestClient, + string Status, + string? Diagnostic); + private static string RedactSuggestionSamplingInput(string value) => SuggestionStore.RedactSensitiveText(value, out _); @@ -5146,9 +5155,10 @@ private static string RedactSuggestionSamplingInput(string value) string? language, string description, string? context, - string? toolInvocationContext) + string? toolInvocationContext, + SuggestionSamplingDecision samplingDecision) { - if (!IsSamplingEnabled() || !HasClientCapability("sampling")) + if (!samplingDecision.ShouldRequestClient) return null; var prompt = BuildSuggestionSamplingPrompt(category, language, description, context, toolInvocationContext); @@ -5348,12 +5358,62 @@ private bool HasClientCapability(string name) && node is not null, }; - private static bool IsSamplingEnabled() + private SuggestionSamplingDecision ResolveSuggestionSamplingDecision() { var raw = Environment.GetEnvironmentVariable(SamplingEnabledEnvironmentVariable); - return raw is null || !(raw.Equals("0", StringComparison.OrdinalIgnoreCase) - || raw.Equals("false", StringComparison.OrdinalIgnoreCase) - || raw.Equals("off", StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(raw)) + { + return new SuggestionSamplingDecision( + false, + "disabled", + $"{SamplingEnabledEnvironmentVariable} is unset; suggestion metadata sampling requires explicit opt-in with true, 1, yes, or on."); + } + + var value = raw.Trim(); + if (IsSamplingOptInValue(value)) + { + return HasClientCapability("sampling") + ? new SuggestionSamplingDecision(true, "enabled", null) + : new SuggestionSamplingDecision( + false, + "client_capability_missing", + "Client did not advertise MCP sampling capability; suggestion metadata sampling skipped."); + } + + if (IsSamplingOptOutValue(value)) + { + return new SuggestionSamplingDecision( + false, + "disabled", + $"{SamplingEnabledEnvironmentVariable} is set to an opt-out value; suggestion metadata sampling disabled."); + } + + return new SuggestionSamplingDecision( + false, + "disabled", + $"{SamplingEnabledEnvironmentVariable} contains an unrecognized value; suggestion metadata sampling disabled. Use true, 1, yes, or on to enable."); + } + + private static bool IsSamplingOptInValue(string value) + => value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Equals("on", StringComparison.OrdinalIgnoreCase); + + private static bool IsSamplingOptOutValue(string value) + => value.Equals("0", StringComparison.OrdinalIgnoreCase) + || value.Equals("false", StringComparison.OrdinalIgnoreCase) + || value.Equals("no", StringComparison.OrdinalIgnoreCase) + || value.Equals("off", StringComparison.OrdinalIgnoreCase); + + private static void AddSuggestionSamplingDiagnostics( + JsonObject payload, + SuggestionSamplingDecision samplingDecision, + SuggestionSamplingResult? sampling) + { + payload["sampling_status"] = sampling != null ? "sampled" : samplingDecision.Status; + if (samplingDecision.Diagnostic != null) + payload["sampling_diagnostic"] = samplingDecision.Diagnostic; } private static string? ExtractSamplingText(JsonNode? result) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 41c197455c..82456a7e5f 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -12417,6 +12417,8 @@ public void SuggestImprovement_RejectsNonRelativeEvidencePath() [Fact] public void SuggestImprovement_WhenSamplingAvailable_StoresSampledMetadata() { + using var samplingEnv = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + samplingEnv.Set("CDIDX_MCP_SAMPLING", "1"); _server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); _server.ClientRequestHandlerForTests = (method, _) => @@ -12452,6 +12454,7 @@ public void SuggestImprovement_WhenSamplingAvailable_StoresSampledMetadata() var response = _server.HandleMessage(request)!; var structured = response["result"]!["structuredContent"]!; + Assert.Equal("sampled", structured["sampling_status"]!.GetValue()); Assert.Equal("Improve TypeScript arrow symbol extraction", structured["sampled_title"]!.GetValue()); Assert.Contains(structured["sampled_tags"]!.AsArray(), tag => tag!.GetValue() == "typescript"); var stored = new SuggestionStore(Path.GetDirectoryName(_dbPath)!, Path.GetFileNameWithoutExtension(_dbPath)).LoadAll() @@ -12465,6 +12468,8 @@ public void SuggestImprovement_WhenSamplingReturnsSensitiveMetadata_RedactsBefor { using var env = EnvironmentVariableScope.Capture("CDIDX_GITHUB_TOKEN"); env.Set("CDIDX_GITHUB_TOKEN", null); + using var samplingEnv = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + samplingEnv.Set("CDIDX_MCP_SAMPLING", "1"); _server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); var secret = $"sample-secret-{Guid.NewGuid():N}"; @@ -12521,6 +12526,8 @@ public void SuggestImprovement_WhenSamplingReturnsSensitiveMetadata_RedactsBefor [Fact] public void SuggestImprovement_WhenSamplingResponseIsTooLarge_IgnoresSampledMetadata() { + using var samplingEnv = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + samplingEnv.Set("CDIDX_MCP_SAMPLING", "1"); _server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); _server.ClientRequestHandlerForTests = (method, _) => @@ -12556,6 +12563,7 @@ public void SuggestImprovement_WhenSamplingResponseIsTooLarge_IgnoresSampledMeta var structured = response["result"]!["structuredContent"]!; Assert.Equal("recorded", structured["status"]!.GetValue()); + Assert.Equal("enabled", structured["sampling_status"]!.GetValue()); Assert.Null(structured["sampled_title"]); var stored = new SuggestionStore(Path.GetDirectoryName(_dbPath)!, Path.GetFileNameWithoutExtension(_dbPath)).LoadAll() .Single(s => s.Description == uniqueDesc); @@ -12566,6 +12574,8 @@ public void SuggestImprovement_WhenSamplingResponseIsTooLarge_IgnoresSampledMeta [Fact] public void SuggestImprovement_WhenSamplingClientResponseJsonIsTooLarge_IgnoresSampledMetadata_Issue3098() { + using var samplingEnv = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + samplingEnv.Set("CDIDX_MCP_SAMPLING", "1"); _server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); _server.ClientRequestHandlerForTests = (method, _) => @@ -12611,6 +12621,8 @@ public void SuggestImprovement_WhenSamplingClientResponseJsonIsTooLarge_IgnoresS [Fact] public void SuggestImprovement_WhenSamplingResponseJsonIsTooDeep_IgnoresSampledMetadata() { + using var samplingEnv = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + samplingEnv.Set("CDIDX_MCP_SAMPLING", "1"); _server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); _server.ClientRequestHandlerForTests = (method, _) => @@ -12657,6 +12669,8 @@ public void SuggestImprovement_WhenSamplingResponseJsonIsTooDeep_IgnoresSampledM [Fact] public void SuggestImprovement_WhenSamplingAvailable_BoundsPromptAndSummarizesInvocationContext() { + using var samplingEnv = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + samplingEnv.Set("CDIDX_MCP_SAMPLING", "1"); _server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); string? capturedPrompt = null; @@ -12711,6 +12725,88 @@ public void SuggestImprovement_WhenSamplingAvailable_BoundsPromptAndSummarizesIn Assert.Equal(toolInvocationContext, stored.ToolInvocationContext); } + [Fact] + public void SuggestImprovement_WhenSamplingEnvUnset_DoesNotCallClientSampling_Issue3405() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + env.Set("CDIDX_MCP_SAMPLING", null); + _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); + var called = false; + _server.ClientRequestHandlerForTests = (_, _) => + { + called = true; + return null; + }; + var uniqueDesc = $"Sampling unset fail-closed regression {Guid.NewGuid():N}"; + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "suggest_improvement", + ["arguments"] = new JsonObject + { + ["category"] = "other", + ["description"] = uniqueDesc, + } + } + }; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.False(called); + Assert.Equal("recorded", structured["status"]!.GetValue()); + Assert.Equal("disabled", structured["sampling_status"]!.GetValue()); + Assert.Contains("requires explicit opt-in", structured["sampling_diagnostic"]!.GetValue()); + Assert.Null(structured["sampled_title"]); + } + + [Fact] + public void SuggestImprovement_WhenSamplingEnvInvalid_DoesNotCallClientSampling_Issue3405() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + env.Set("CDIDX_MCP_SAMPLING", new string('x', 512)); + _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); + var called = false; + _server.ClientRequestHandlerForTests = (_, _) => + { + called = true; + return null; + }; + var uniqueDesc = $"Sampling invalid env fail-closed regression {Guid.NewGuid():N}"; + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "suggest_improvement", + ["arguments"] = new JsonObject + { + ["category"] = "other", + ["description"] = uniqueDesc, + } + } + }; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + var diagnostic = structured["sampling_diagnostic"]!.GetValue(); + Assert.False(called); + Assert.Equal("disabled", structured["sampling_status"]!.GetValue()); + Assert.Contains("unrecognized value", diagnostic); + Assert.True(diagnostic.Length < 200); + Assert.DoesNotContain(new string('x', 80), diagnostic); + Assert.Null(structured["sampled_title"]); + } + [Fact] public void SuggestImprovement_WhenSamplingDisabled_DoesNotCallClientSampling() { @@ -12744,8 +12840,11 @@ public void SuggestImprovement_WhenSamplingDisabled_DoesNotCallClientSampling() var response = _server.HandleMessage(request)!; Assert.False(called); - Assert.Equal("recorded", response["result"]!["structuredContent"]!["status"]!.GetValue()); - Assert.Null(response["result"]!["structuredContent"]!["sampled_title"]); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal("recorded", structured["status"]!.GetValue()); + Assert.Equal("disabled", structured["sampling_status"]!.GetValue()); + Assert.Contains("opt-out", structured["sampling_diagnostic"]!.GetValue()); + Assert.Null(structured["sampled_title"]); } [Fact] From b7ff5294bbfaee65e2df338c43116b438ef3d0fe Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:26:38 +0900 Subject: [PATCH 3/5] Expose search recipes through MCP (#3545) --- USER_GUIDE.md | 11 + changelog.d/unreleased/3545.added.md | 20 ++ src/CodeIndex/Cli/SearchAuditRecipes.cs | 308 +++++++++++++++++- src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 2 +- src/CodeIndex/Mcp/McpToolDefinitions.cs | 11 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 167 +++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 82 ++++- 7 files changed, 590 insertions(+), 11 deletions(-) create mode 100644 changelog.d/unreleased/3545.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 70b50292a5..0c52f4cfa4 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -916,6 +916,12 @@ Recipe runs support text output, `--json` / `--format json`, and `--format issue-drafts`; `--list-recipes` supports text or JSON. Other search export formats and `--json=array` are rejected for recipe modes because recipe output is grouped by query or list metadata. +The MCP `search` tool exposes the same recipe surface with +`{"listRecipes":true}` for discovery and `{"recipe":"risky-code"}` for +execution. Set `CDIDX_SEARCH_RECIPE_PATHS` to one or more JSON files separated +by the platform path separator to add configured recipe sources; each file may +be a recipe array or `{ "recipes": [...] }`, and invalid sources are reported as +bounded `recipe_source_diagnostics`. For triage automation, `--format issue-drafts` emits draft issue objects with titles, labels, evidence paths, Markdown bodies, and duplicate-preflight metadata. `--open-issues ` accepts an open-issue JSON list such as @@ -3276,6 +3282,11 @@ recipe run が対応する形式は text output、`--json` / `--format json`、 `--format issue-drafts` です。`--list-recipes` は text または JSON に対応します。 その他の search export format と `--json=array` は、recipe output が query または list metadata ごとに grouped されるため usage error で拒否します。 +MCP `search` tool では `{"listRecipes":true}` で recipe を発見し、 +`{"recipe":"risky-code"}` で実行できます。`CDIDX_SEARCH_RECIPE_PATHS` に +platform path separator 区切りの JSON file を指定すると、設定済み recipe source を +追加できます。各 file は recipe array または `{ "recipes": [...] }` を受け付け、 +不正な source は bounded な `recipe_source_diagnostics` として報告されます。 triage automation では `--format issue-drafts` を使うと、title、label、evidence path、 Markdown body、duplicate-preflight metadata を持つ issue draft object を出力します。 `--open-issues ` は `gh issue list --state open --json number,title,labels,url` diff --git a/changelog.d/unreleased/3545.added.md b/changelog.d/unreleased/3545.added.md new file mode 100644 index 0000000000..e87fba38ef --- /dev/null +++ b/changelog.d/unreleased/3545.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 3545 +affected: + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Mcp/McpToolArgumentContracts.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP search can list and run audit recipes (#3545)** — `search` now accepts `listRecipes:true` and `recipe:"name"`, and `CDIDX_SEARCH_RECIPE_PATHS` adds bounded JSON recipe sources with diagnostics for invalid files. + +## 日本語 + +- **MCP search が audit recipe の一覧・実行に対応しました (#3545)** — `search` は `listRecipes:true` と `recipe:"name"` を受け付け、`CDIDX_SEARCH_RECIPE_PATHS` で bounded な JSON recipe source を追加し、不正 file の diagnostic を返せます。 diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 7e8afde3d2..7f9eea6f9d 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -1,10 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace CodeIndex.Cli; internal static class SearchAuditRecipes { - private static readonly List Recipes = + internal const string RecipePathsEnvironmentVariable = "CDIDX_SEARCH_RECIPE_PATHS"; + private const int MaxRecipeSourceFiles = 8; + private const long MaxRecipeSourceBytes = 128 * 1024; + private const int MaxExternalRecipesPerFile = 32; + private const int MaxExternalQueriesPerRecipe = 32; + private const int MaxExternalNameLength = 80; + private const int MaxExternalDescriptionLength = 512; + private const int MaxExternalFalsePositiveGuidanceLength = 512; + private const int MaxExternalLabelCount = 16; + private const int MaxExternalLabelLength = 64; + private const int MaxRecipeDiagnosticLength = 512; + + private static readonly List BuiltInRecipes = [ new( "risky-code", @@ -43,15 +57,303 @@ internal static class SearchAuditRecipes ]) ]; - internal static IReadOnlyList All => Recipes; + internal static IReadOnlyList All => Load().Recipes; + + internal static SearchAuditRecipeRegistry Load() + { + var recipes = BuiltInRecipes.ToList(); + var diagnostics = new List(); + var knownNames = new HashSet(recipes.Select(recipe => recipe.Name), StringComparer.OrdinalIgnoreCase); + + foreach (var sourcePath in ReadConfiguredRecipeSourcePaths(diagnostics)) + { + if (!TryLoadExternalRecipes(sourcePath, diagnostics, out var externalRecipes)) + continue; + + foreach (var recipe in externalRecipes) + { + if (!knownNames.Add(recipe.Name)) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' defines duplicate recipe '{recipe.Name}'; keeping the first definition."); + continue; + } + + recipes.Add(recipe); + } + } + + return new SearchAuditRecipeRegistry(recipes, diagnostics); + } internal static bool TryGet(string name, out SearchAuditRecipe recipe) { - recipe = Recipes.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase))!; + var registry = Load(); + recipe = registry.Recipes.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase))!; return recipe != null; } + + private static List ReadConfiguredRecipeSourcePaths(List diagnostics) + { + var raw = Environment.GetEnvironmentVariable(RecipePathsEnvironmentVariable); + if (string.IsNullOrWhiteSpace(raw)) + return []; + + var paths = new List(); + foreach (var part in raw.Split(Path.PathSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (paths.Count >= MaxRecipeSourceFiles) + { + AddDiagnostic(diagnostics, $"{RecipePathsEnvironmentVariable} lists more than {MaxRecipeSourceFiles} recipe sources; extra entries are ignored."); + break; + } + + paths.Add(part); + } + + return paths; + } + + private static bool TryLoadExternalRecipes(string sourcePath, List diagnostics, out List recipes) + { + recipes = []; + string fullPath; + try + { + fullPath = Path.GetFullPath(sourcePath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' is not a valid path: {ex.Message}"); + return false; + } + + if (!File.Exists(fullPath)) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' does not exist."); + return false; + } + + try + { + var info = new FileInfo(fullPath); + if (info.Length > MaxRecipeSourceBytes) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' is too large ({info.Length} bytes; max {MaxRecipeSourceBytes})."); + return false; + } + + var root = JsonNode.Parse( + File.ReadAllText(fullPath), + documentOptions: new JsonDocumentOptions { MaxDepth = 16 }); + var recipeArray = root as JsonArray ?? root?["recipes"] as JsonArray; + if (recipeArray is null) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' must be a JSON array or an object with a 'recipes' array."); + return false; + } + + for (var i = 0; i < recipeArray.Count && i < MaxExternalRecipesPerFile; i++) + { + if (TryParseRecipe(recipeArray[i], sourcePath, i, diagnostics, out var recipe)) + recipes.Add(recipe); + } + + if (recipeArray.Count > MaxExternalRecipesPerFile) + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' has more than {MaxExternalRecipesPerFile} recipes; extra entries are ignored."); + return true; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or InvalidOperationException) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' could not be loaded: {ex.Message}"); + return false; + } + } + + private static bool TryParseRecipe( + JsonNode? node, + string sourcePath, + int recipeIndex, + List diagnostics, + out SearchAuditRecipe recipe) + { + recipe = null!; + if (node is not JsonObject obj) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe #{recipeIndex + 1} must be an object."); + return false; + } + + if (!TryReadRequiredString(obj, "name", MaxExternalNameLength, sourcePath, recipeIndex, diagnostics, out var name) + || !TryReadRequiredString(obj, "description", MaxExternalDescriptionLength, sourcePath, recipeIndex, diagnostics, out var description)) + { + return false; + } + + if (obj["queries"] is not JsonArray queryArray) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{name}' must include a 'queries' array."); + return false; + } + + var queries = new List(); + for (var i = 0; i < queryArray.Count && i < MaxExternalQueriesPerRecipe; i++) + { + if (TryParseRecipeQuery(queryArray[i], sourcePath, name, i, diagnostics, out var query)) + queries.Add(query); + } + + if (queryArray.Count > MaxExternalQueriesPerRecipe) + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{name}' has more than {MaxExternalQueriesPerRecipe} queries; extra entries are ignored."); + if (queries.Count == 0) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{name}' has no valid queries and was ignored."); + return false; + } + + recipe = new SearchAuditRecipe(name, description, queries); + return true; + } + + private static bool TryParseRecipeQuery( + JsonNode? node, + string sourcePath, + string recipeName, + int queryIndex, + List diagnostics, + out SearchAuditRecipeQuery query) + { + query = null!; + if (node is not JsonObject obj) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{recipeName}' query #{queryIndex + 1} must be an object."); + return false; + } + + if (!TryReadRequiredString(obj, "name", MaxExternalNameLength, sourcePath, queryIndex, diagnostics, out var name) + || !TryReadRequiredString(obj, "query", QueryLimits.MaxQueryLength, sourcePath, queryIndex, diagnostics, out var queryText) + || !TryReadRequiredString(obj, "description", MaxExternalDescriptionLength, sourcePath, queryIndex, diagnostics, out var description)) + { + return false; + } + + var labels = ReadLabels(obj, sourcePath, recipeName, name, diagnostics); + var falsePositiveGuidance = TryReadString(obj["falsePositiveGuidance"] ?? obj["false_positive_guidance"], out var guidance) + && !string.IsNullOrWhiteSpace(guidance) + ? guidance.Trim() + : "Review surrounding context before filing an issue."; + if (falsePositiveGuidance.Length > MaxExternalFalsePositiveGuidanceLength) + falsePositiveGuidance = falsePositiveGuidance[..MaxExternalFalsePositiveGuidanceLength].TrimEnd(); + var exactSubstring = TryReadBool(obj["exactSubstring"] ?? obj["exact_substring"], out var exactValue) + ? exactValue + : true; + + query = new SearchAuditRecipeQuery(name, queryText, description, labels, falsePositiveGuidance, exactSubstring); + return true; + } + + private static bool TryReadRequiredString( + JsonObject obj, + string propertyName, + int maxLength, + string sourcePath, + int itemIndex, + List diagnostics, + out string value) + { + value = string.Empty; + if (!TryReadString(obj[propertyName], out var raw) || string.IsNullOrWhiteSpace(raw)) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' item #{itemIndex + 1} must include a non-empty '{propertyName}' string."); + return false; + } + + value = raw.Trim(); + if (value.Length <= maxLength) + return true; + + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' item #{itemIndex + 1} field '{propertyName}' exceeds {maxLength} characters."); + value = string.Empty; + return false; + } + + private static List ReadLabels( + JsonObject obj, + string sourcePath, + string recipeName, + string queryName, + List diagnostics) + { + var labelsNode = obj["recommendedLabels"] ?? obj["recommended_labels"]; + if (labelsNode is null) + return []; + if (labelsNode is not JsonArray labelArray) + { + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{recipeName}' query '{queryName}' labels must be an array."); + return []; + } + + var labels = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < labelArray.Count && i < MaxExternalLabelCount; i++) + { + if (!TryReadString(labelArray[i], out var label) || string.IsNullOrWhiteSpace(label)) + continue; + label = label.Trim(); + if (label.Length > MaxExternalLabelLength) + continue; + if (seen.Add(label)) + labels.Add(label); + } + + if (labelArray.Count > MaxExternalLabelCount) + AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{recipeName}' query '{queryName}' has more than {MaxExternalLabelCount} labels; extra entries are ignored."); + return labels; + } + + private static void AddDiagnostic(List diagnostics, string message) + { + if (message.Length > MaxRecipeDiagnosticLength) + message = message[..MaxRecipeDiagnosticLength].TrimEnd() + " ... [truncated]"; + diagnostics.Add(message); + } + + private static bool TryReadString(JsonNode? node, out string value) + { + value = string.Empty; + if (node is null) + return false; + try + { + value = node.GetValue(); + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static bool TryReadBool(JsonNode? node, out bool value) + { + value = false; + if (node is null) + return false; + try + { + value = node.GetValue(); + return true; + } + catch (InvalidOperationException) + { + return false; + } + } } +internal sealed record SearchAuditRecipeRegistry( + IReadOnlyList Recipes, + IReadOnlyList Diagnostics); + internal sealed record SearchAuditRecipe( string Name, string Description, diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index ff7b35647a..8d45606767 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -14,7 +14,7 @@ public partial class McpServer private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch { - "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, + "search" => new HashSet(StringComparer.Ordinal) { "query", "recipe", "listRecipes", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 929d4003a7..b0b6cfe9bd 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -20,13 +20,15 @@ private JsonNode HandleToolsList(JsonNode? id) { CreateToolDefinition( "search", - "Full-text search across indexed code chunks. Returns match-centered snippets with line metadata plus `result_stable_at` for index-drift checks, `next_cursor` for non-empty paginated responses, and `next_step_suggestion` or `recovery_hint`. Use `prefix` or trailing `*` to widen token matching, `rawQuery` for FTS5 syntax, and `exactSubstring` for case-sensitive text identity. Details and examples: USER_GUIDE.md#search. / インデックス済みコードチャンクの全文検索。レスポンスには index drift 検出用の `result_stable_at`、非空ページ継続用の `next_cursor`、`next_step_suggestion` または `recovery_hint` を含める。`prefix` / 末尾 `*` / `rawQuery` / `exactSubstring` の詳細と例は USER_GUIDE.md#search を参照。", + "Full-text search across indexed code chunks, or list/run named search audit recipes. Returns match-centered snippets with line metadata plus `result_stable_at` for index-drift checks, `next_cursor` for non-empty paginated query responses, and `next_step_suggestion` or `recovery_hint`. Use `listRecipes:true` to list recipes and `recipe:\"name\"` to run one. Use `prefix` or trailing `*` to widen token matching, `rawQuery` for FTS5 syntax, and `exactSubstring` for case-sensitive text identity. Details and examples: USER_GUIDE.md#search. / インデックス済みコードチャンクの全文検索、または名前付き search audit recipe の一覧・実行。レスポンスには index drift 検出用の `result_stable_at`、非空ページ継続用の `next_cursor`、`next_step_suggestion` または `recovery_hint` を含める。`listRecipes:true` で recipe 一覧、`recipe:\"name\"` で実行。`prefix` / 末尾 `*` / `rawQuery` / `exactSubstring` の詳細と例は USER_GUIDE.md#search を参照。", new JsonObject { ["type"] = "object", ["properties"] = new JsonObject { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query text. Append `*` to a token to make that token a prefix phrase (`計算*` matches `計算する`)." }, + ["recipe"] = new JsonObject { ["type"] = "string", ["description"] = "Run a named search audit recipe instead of a single query. Use `listRecipes:true` to discover available recipe names." }, + ["listRecipes"] = new JsonObject { ["type"] = "boolean", ["description"] = "List built-in and configured search audit recipes without running a search.", ["default"] = false }, ["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 }, @@ -50,7 +52,12 @@ private JsonNode HandleToolsList(JsonNode? id) ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without snippets.", ["default"] = "full" } }, - ["required"] = new JsonArray { "query" } + ["anyOf"] = new JsonArray + { + new JsonObject { ["required"] = new JsonArray { "query" } }, + new JsonObject { ["required"] = new JsonArray { "recipe" } }, + new JsonObject { ["required"] = new JsonArray { "listRecipes" } } + } }, ReadOnlyAnnotations()), CreateToolDefinition( diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 1c5a18bbc2..fc55d36001 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -791,11 +791,12 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or "lspCompatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or - "optimize" or "reverse" or "cycles" or "estimateOnly" => "boolean", + "optimize" or "reverse" or "cycles" or "estimateOnly" or "listRecipes" => "boolean", "project" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "solution" or "symbol" or "groupBy" or "category" or "language" or - "bucket" or "minConfidence" or "description" or "context" or "toolInvocationContext" or "db" => "string", + "bucket" or "minConfidence" or "description" or "context" or "toolInvocationContext" or "db" or + "recipe" => "string", "queries" or "evidencePaths" or "evidence_paths" => "array", _ => string.Empty, }; @@ -1375,6 +1376,19 @@ private static bool TryReadReferenceRankMode(JsonNode? args, out ReferenceRankMo private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) { + var listRecipes = args?["listRecipes"]?.GetValue() ?? false; + if (listRecipes) + return ExecuteSearchRecipeList(id); + + var recipeNode = args?["recipe"]; + if (recipeNode is not null) + { + var recipeName = recipeNode.GetValue(); + if (string.IsNullOrWhiteSpace(recipeName)) + return CreateToolErrorResponse(id, "'recipe' must be a non-empty search recipe name."); + return ExecuteSearchRecipe(id, args, recipeName.Trim()); + } + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); if (query.Length > QueryLimits.MaxQueryLength) @@ -1529,6 +1543,155 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) }); } + private JsonNode ExecuteSearchRecipeList(JsonNode? id) + { + var registry = SearchAuditRecipes.Load(); + var payload = new JsonObject + { + ["count"] = registry.Recipes.Count, + ["recipes"] = ToSearchRecipeArray(registry.Recipes) + }; + AddSearchRecipeSourceDiagnostics(payload, registry.Diagnostics); + return CreateToolResult(id, $"Found {registry.Recipes.Count} search recipe(s).", payload); + } + + private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipeName) + { + var registry = SearchAuditRecipes.Load(); + var recipe = registry.Recipes.FirstOrDefault(r => string.Equals(r.Name, recipeName, StringComparison.OrdinalIgnoreCase)); + if (recipe is null) + { + var available = string.Join(", ", registry.Recipes.Select(r => r.Name)); + return CreateToolErrorResponse(id, $"unknown search recipe '{recipeName}'. Available recipes: {available}."); + } + + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var snippetLines = ReadSnippetLines(args, SearchSnippetFormatter.DefaultSnippetLines, adjustments); + if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) + return maxLineWidthError; + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); + var deduplicate = !(args?["noDedup"]?.GetValue() ?? false); + if (!TryResolveSearchExactArgument(args, out var userExact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + var hasExactOverride = args?["exact"] is not null || args?["exactSubstring"] is not null; + if (args?["prefix"]?.GetValue() ?? false) + return CreateToolErrorResponse(id, "'prefix' cannot be combined with recipe execution."); + if (args?["cursor"] is not null) + return CreateToolErrorResponse(id, "'cursor' is not supported for recipe execution."); + if (TryReadSearchGuardFilters(id, args, out var guardFilters) is JsonNode guardError) + return guardError; + var guardWindow = args?["guardWindow"]?.GetValue() ?? DbReader.DefaultSearchGuardWindow; + if (guardWindow < 0 || guardWindow > DbReader.MaxSearchGuardWindow) + return CreateToolErrorResponse(id, $"'guardWindow' must be between 0 and {DbReader.MaxSearchGuardWindow}; got {guardWindow}."); + + return WithDbReader(id, args, reader => + { + var queryResults = new JsonArray(); + var total = 0; + foreach (var recipeQuery in recipe.Queries) + { + var exact = hasExactOverride ? userExact : recipeQuery.ExactSubstring; + List results; + try + { + results = reader.Search( + recipeQuery.Query, + limit, + lang, + false, + pathPatterns, + excludePaths, + excludeTests, + deduplicate, + since, + exact, + false, + guardFilters: guardFilters, + guardWindow: guardWindow); + } + catch (SearchQueryLimitException ex) + { + return CreateToolErrorResponse(id, ex.Message); + } + catch (SearchGuardCandidateLimitException ex) + { + return CreateToolErrorResponse(id, $"guarded search is too broad for recipe '{recipe.Name}' query '{recipeQuery.Name}': {ex.Message} Narrow the search with more specific path/lang filters or guards."); + } + + var queryContext = SearchSnippetFormatter.PrepareQueryContext(recipeQuery.Query); + var compactResults = SearchSnippetFormatter + .ToCompactResults(results, queryContext, snippetLines, exact, maxLineWidth, exposeLiteralHighlights: exact) + .ToList(); + total += compactResults.Count; + queryResults.Add(new JsonObject + { + ["name"] = recipeQuery.Name, + ["query"] = recipeQuery.Query, + ["description"] = recipeQuery.Description, + ["recommended_labels"] = ToJsonArray(recipeQuery.RecommendedLabels), + ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, + ["exact_substring"] = exact, + ["count"] = compactResults.Count, + ["results"] = ToJsonArray(compactResults) + }); + } + + var payload = new JsonObject + { + ["recipe"] = ToSearchRecipeJson(recipe), + ["query_count"] = recipe.Queries.Count, + ["result_count"] = total, + ["limit_per_query"] = limit, + ["snippetLines"] = snippetLines, + ["maxLineWidth"] = maxLineWidth, + ["lang"] = lang, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["queries"] = queryResults + }; + AddFreshnessHint(payload, reader); + AddSearchRecipeSourceDiagnostics(payload, registry.Diagnostics); + adjustments.ApplyTo(payload); + var summary = total == 0 + ? $"Recipe '{recipe.Name}' returned no search results." + : $"Recipe '{recipe.Name}' returned {total} search result(s) across {recipe.Queries.Count} query(ies)."; + return CreateToolResult(id, summary, payload); + }); + } + + private JsonArray ToSearchRecipeArray(IEnumerable recipes) + => new(recipes.Select(recipe => ToSearchRecipeJson(recipe)).ToArray()); + + private JsonObject ToSearchRecipeJson(SearchAuditRecipe recipe) + => new() + { + ["name"] = recipe.Name, + ["description"] = recipe.Description, + ["recommended_labels"] = ToJsonArray(recipe.RecommendedLabels), + ["queries"] = new JsonArray(recipe.Queries.Select(query => new JsonObject + { + ["name"] = query.Name, + ["query"] = query.Query, + ["description"] = query.Description, + ["recommended_labels"] = ToJsonArray(query.RecommendedLabels), + ["false_positive_guidance"] = query.FalsePositiveGuidance, + ["exact_substring"] = query.ExactSubstring + }).ToArray()) + }; + + private static void AddSearchRecipeSourceDiagnostics(JsonObject payload, IReadOnlyList diagnostics) + { + if (diagnostics.Count == 0) + return; + payload["recipe_source_diagnostics"] = new JsonArray(diagnostics.Select(diagnostic => JsonValue.Create(diagnostic)).ToArray()); + } + private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) { var query = args?["query"]?.GetValue(); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 82456a7e5f..e2a9f16f77 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -3723,15 +3723,19 @@ public void ToolsList_EveryDescriptionIncludesLanguageSupportClause() } [Fact] - public void ToolsList_SearchHasRequiredQueryParam() + public void ToolsList_SearchAdvertisesQueryOrRecipeModes_Issue3545() { var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); var searchTool = tools.First(t => t!["name"]!.GetValue() == "search")!; - var required = searchTool["inputSchema"]!["required"]!.AsArray(); - Assert.Contains("query", required.Select(r => r!.GetValue())); + var modes = searchTool["inputSchema"]!["anyOf"]!.AsArray() + .Select(mode => mode!["required"]!.AsArray().Single()!.GetValue()) + .ToArray(); + Assert.Contains("query", modes); + Assert.Contains("recipe", modes); + Assert.Contains("listRecipes", modes); } [Fact] @@ -4432,6 +4436,78 @@ public void ToolsCall_Search_ReturnsResults() Assert.Null(structured["results"]![0]!["content"]); } + [Fact] + public void ToolsCall_Search_ListRecipesReturnsBuiltIns_Issue3545() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_SEARCH_RECIPE_PATHS"); + env.Set("CDIDX_SEARCH_RECIPE_PATHS", null); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"listRecipes":true}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Null(response["error"]); + var structured = response["result"]!["structuredContent"]!; + Assert.True(structured["count"]!.GetValue() >= 1); + var recipes = structured["recipes"]!.AsArray(); + var risky = recipes.Single(recipe => recipe!["name"]!.GetValue() == "risky-code")!; + Assert.Contains(risky["queries"]!.AsArray(), query => query!["name"]!.GetValue() == "unbounded-json-parse"); + } + + [Fact] + public void ToolsCall_Search_RunRecipeReturnsGroupedResults_Issue3545() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_SEARCH_RECIPE_PATHS"); + env.Set("CDIDX_SEARCH_RECIPE_PATHS", null); + InsertIndexedFile("src/json.cs", "csharp", "var doc = JsonDocument.Parse(payload);\n"); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"recipe":"risky-code","limit":5}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Null(response["error"]); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal("risky-code", structured["recipe"]!["name"]!.GetValue()); + Assert.True(structured["result_count"]!.GetValue() >= 1); + var jsonParseQuery = structured["queries"]!.AsArray() + .Single(query => query!["name"]!.GetValue() == "unbounded-json-parse")!; + Assert.Equal(1, jsonParseQuery["count"]!.GetValue()); + Assert.Equal("src/json.cs", jsonParseQuery["results"]![0]!["path"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Search_ListRecipesIncludesConfiguredSources_Issue3545() + { + var recipePath = Path.Combine(_projectRoot, "search-recipes.json"); + File.WriteAllText(recipePath, """ + { + "recipes": [ + { + "name": "local-audit", + "description": "Local audit recipe", + "queries": [ + { + "name": "todo-comments", + "query": "TODO", + "description": "Find local TODO markers", + "recommendedLabels": ["audit"], + "falsePositiveGuidance": "Ignore deliberate test fixtures.", + "exactSubstring": true + } + ] + } + ] + } + """); + using var env = EnvironmentVariableScope.Capture("CDIDX_SEARCH_RECIPE_PATHS"); + env.Set("CDIDX_SEARCH_RECIPE_PATHS", recipePath); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"listRecipes":true}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Null(response["error"]); + var structured = response["result"]!["structuredContent"]!; + var recipes = structured["recipes"]!.AsArray(); + var local = recipes.Single(recipe => recipe!["name"]!.GetValue() == "local-audit")!; + Assert.Equal("todo-comments", local["queries"]![0]!["name"]!.GetValue()); + Assert.Null(structured["recipe_source_diagnostics"]); + } + [Fact] public void ToolsCall_Search_AcceptsScalarExcludePaths_Issue3538() { From cd635b1d3ea13e51596dbcb260d70b3c179489fd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:35:04 +0900 Subject: [PATCH 4/5] Clarify HTTP MCP SSE multi-client behavior (#3522) --- USER_GUIDE.md | 4 +- changelog.d/unreleased/3522.added.md | 18 ++++++ src/CodeIndex/Mcp/HttpMcpTransport.cs | 11 ++-- src/CodeIndex/Mcp/McpServer.cs | 15 +++-- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 56 +++++++++++++++++++ 5 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 changelog.d/unreleased/3522.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0c52f4cfa4..48c928461b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2220,7 +2220,7 @@ For HTTP, `CDIDX_MCP_HTTP_TOKEN` is the preferred bearer secret. If it is unset, HTTP falls back to `CDIDX_MCP_AUTH_TOKEN` as the bearer secret, and clients still authenticate with `Authorization: Bearer `. -Each HTTP `POST /` carries one JSON-RPC frame in the request body, the matching response is returned in the same HTTP body (`200 OK`, `application/json`), and notifications return `204 No Content`. `GET /events` opens a `text/event-stream` channel for server-to-client frames; the server emits no unsolicited JSON-RPC frames unless keep-alive notifications are opted in with `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S`. Accepted keep-alive values are finite seconds from `1` to `300`; invalid or out-of-range values leave keep-alive disabled with a `stderr` warning. The stream is independent and does not block normal POST requests. Non-POST verbs on `/` return `405 Method Not Allowed` with `Allow: POST`. Request bodies are capped at 1,000,000 bytes by default and oversized requests return `413 Payload Too Large`; the pending POST queue and accepted handler tasks are capped at 64 by default, and concurrent `/events` streams are capped at 16. Full queues, handler pools, or stream slots return `429 Too Many Requests` with `Retry-After: 1`. Tune those positive-integer limits with `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`, `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`, `CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS`, and `CDIDX_MCP_HTTP_MAX_EVENT_STREAMS`; accepted ranges are `1..16777216` bytes and `1..1024` for each count limit. Invalid non-positive or non-numeric values fall back to the defaults, while values above those maximums are rejected before the listener starts. Idle event streams receive minimal SSE comment heartbeats so disconnected clients release their stream slots. When the persistent lifecycle log is enabled, HTTP mode also writes one `mcp_http_request` record per request with method, path, status, duration, auth outcome, remote peer, correlation id, and JSON-RPC request id when available. Method, path, remote peer, and request id fields are capped at 256 characters with a `...` marker. Request and response bodies are not logged. +Each HTTP `POST /` carries one JSON-RPC frame in the request body, the matching response is returned in the same HTTP body (`200 OK`, `application/json`), and notifications return `204 No Content`. `GET /events` opens a `text/event-stream` channel for server-to-client frames; multiple concurrent clients can hold `/events`, and server notifications are broadcast to every connected stream. Event responses include `X-Accel-Buffering: no` and a per-stream `X-Cdidx-Mcp-Event-Stream-Id`. The server emits no unsolicited JSON-RPC frames unless keep-alive notifications are opted in with `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S`. Accepted keep-alive values are finite seconds from `1` to `300`; invalid or out-of-range values leave keep-alive disabled with a `stderr` warning. The stream is independent and does not block normal POST requests. Non-POST verbs on `/` return `405 Method Not Allowed` with `Allow: POST`. Request bodies are capped at 1,000,000 bytes by default and oversized requests return `413 Payload Too Large`; the pending POST queue and accepted handler tasks are capped at 64 by default, and concurrent `/events` streams are capped at 16. Full queues, handler pools, or stream slots return `429 Too Many Requests` with `Retry-After: 1`. Tune those positive-integer limits with `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`, `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`, `CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS`, and `CDIDX_MCP_HTTP_MAX_EVENT_STREAMS`; accepted ranges are `1..16777216` bytes and `1..1024` for each count limit. Invalid non-positive or non-numeric values fall back to the defaults, while values above those maximums are rejected before the listener starts. `/healthz` includes `http_event_stream_count`, `http_event_stream_limit`, `http_max_concurrent_handlers`, and `http_queued_request_count` for transport diagnostics. Idle event streams receive minimal SSE comment heartbeats so disconnected clients release their stream slots. When the persistent lifecycle log is enabled, HTTP mode also writes one `mcp_http_request` record per request with method, path, status, duration, auth outcome, remote peer, correlation id, and JSON-RPC request id when available. Method, path, remote peer, and request id fields are capped at 256 characters with a `...` marker. Request and response bodies are not logged. Security defaults: @@ -4566,7 +4566,7 @@ HTTP では `CDIDX_MCP_HTTP_TOKEN` が優先の bearer secret です。未設定 `CDIDX_MCP_AUTH_TOKEN` を bearer secret として fallback し、クライアントは引き続き `Authorization: Bearer ` で認証します。 -HTTP の `POST /` 1 件が JSON-RPC フレーム 1 件に対応し、応答は同じ HTTP レスポンスのボディに `200 OK` / `application/json` で返ります。通知は `204 No Content` です。`GET /events` はサーバー→クライアントフレーム用の `text/event-stream` channel を開きます。server-initiated JSON-RPC frame は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification を opt-in した場合だけ送信されます。受理される値は有限な `1`〜`300` 秒で、不正値や範囲外の値では `stderr` に警告を出して keep-alive を無効のままにします。この stream は独立しており通常の POST リクエストを塞ぎません。`/` への POST 以外は `405 Method Not Allowed`(`Allow: POST` 付き)です。リクエスト本文は既定で 1,000,000 bytes までに制限され、超過時は `413 Payload Too Large` を返します。保留中 POST queue と受理済み handler task は既定で 64 件まで、同時 `/events` stream は既定で 16 件までに制限されます。queue、handler pool、stream slot が満杯の場合は `Retry-After: 1` 付きの `429 Too Many Requests` を返します。正の整数の `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`、`CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`、`CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS`、`CDIDX_MCP_HTTP_MAX_EVENT_STREAMS` で調整でき、受理範囲は本文が `1..16777216` bytes、各件数 limit が `1..1024` 件です。正でない値や数値でない値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否されます。idle event stream には最小限の SSE comment heartbeat を送り、切断済み client の stream slot を解放します。永続 lifecycle log が有効な場合、HTTP mode はリクエストごとに `mcp_http_request` レコードも出力し、method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id を記録します。method、path、remote peer、request id は 256 文字を上限に `...` marker 付きで切り詰めます。リクエスト/レスポンス本文は記録しません。 +HTTP の `POST /` 1 件が JSON-RPC フレーム 1 件に対応し、応答は同じ HTTP レスポンスのボディに `200 OK` / `application/json` で返ります。通知は `204 No Content` です。`GET /events` はサーバー→クライアントフレーム用の `text/event-stream` channel を開きます。複数 client が同時に `/events` を保持でき、server notification は接続中の全 stream に broadcast されます。event response には `X-Accel-Buffering: no` と stream ごとの `X-Cdidx-Mcp-Event-Stream-Id` が付きます。server-initiated JSON-RPC frame は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification を opt-in した場合だけ送信されます。受理される値は有限な `1`〜`300` 秒で、不正値や範囲外の値では `stderr` に警告を出して keep-alive を無効のままにします。この stream は独立しており通常の POST リクエストを塞ぎません。`/` への POST 以外は `405 Method Not Allowed`(`Allow: POST` 付き)です。リクエスト本文は既定で 1,000,000 bytes までに制限され、超過時は `413 Payload Too Large` を返します。保留中 POST queue と受理済み handler task は既定で 64 件まで、同時 `/events` stream は既定で 16 件までに制限されます。queue、handler pool、stream slot が満杯の場合は `Retry-After: 1` 付きの `429 Too Many Requests` を返します。正の整数の `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`、`CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`、`CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS`、`CDIDX_MCP_HTTP_MAX_EVENT_STREAMS` で調整でき、受理範囲は本文が `1..16777216` bytes、各件数 limit が `1..1024` 件です。正でない値や数値でない値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否されます。`/healthz` は transport diagnostic として `http_event_stream_count`、`http_event_stream_limit`、`http_max_concurrent_handlers`、`http_queued_request_count` を含みます。idle event stream には最小限の SSE comment heartbeat を送り、切断済み client の stream slot を解放します。永続 lifecycle log が有効な場合、HTTP mode はリクエストごとに `mcp_http_request` レコードも出力し、method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id を記録します。method、path、remote peer、request id は 256 文字を上限に `...` marker 付きで切り詰めます。リクエスト/レスポンス本文は記録しません。 セキュリティ既定: diff --git a/changelog.d/unreleased/3522.added.md b/changelog.d/unreleased/3522.added.md new file mode 100644 index 0000000000..19ca338892 --- /dev/null +++ b/changelog.d/unreleased/3522.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 3522 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs + - USER_GUIDE.md +--- + +## English + +- **HTTP MCP multi-client SSE fan-out is now observable (#3522)** — event streams include proxy buffering and per-stream id headers, `/healthz` reports HTTP transport counters, and regression coverage verifies progress notifications reach multiple concurrent `/events` clients. + +## 日本語 + +- **HTTP MCP multi-client SSE fan-out を確認しやすくしました (#3522)** — event stream に proxy buffering と stream id header を追加し、`/healthz` が HTTP transport counter を返し、progress notification が複数の同時 `/events` client に届くことを regression test で検証します。 diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index d6796d5e64..3a6953bdfa 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -16,14 +16,13 @@ namespace CodeIndex.Mcp; /// body and the matching JSON-RPC response is returned as the response body (or 204 No Content /// for notifications). The implementation is intentionally single-session — one in-flight request /// at a time — to mirror the existing stdio loop's request/response pairing and to keep the -/// JSON-RPC ordering invariant the rest of the MCP server depends on. SSE / multi-client -/// fan-out is left as a follow-up because the underlying handler today never emits unsolicited -/// server→client messages. +/// JSON-RPC ordering invariant the rest of the MCP server depends on. Server-initiated JSON-RPC +/// notifications are exposed through `/events` as a bounded, multi-client SSE fan-out channel. /// HTTP MCP トランスポート (issue #1558)。HTTP POST 1 件が JSON-RPC リクエスト 1 件と対応し、 /// 応答も同じ HTTP レスポンスのボディに乗せる(通知の場合は 204 No Content)。stdio ループと /// 同様にシングルセッションで「リクエスト 1 件 → レスポンス 1 件」の順序不変条件を維持する。 -/// SSE / マルチクライアント対応は将来作業として切り出す(現サーバーは自発的なサーバー→クライアント -/// メッセージを発生させないため、最小単位として POST/response で十分)。 +/// サーバー起点の JSON-RPC 通知は `/events` で bounded な multi-client SSE fan-out channel +/// として公開する。 /// internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport { @@ -722,6 +721,8 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken context.Response.SendChunked = true; context.Response.AddHeader("Cache-Control", "no-cache"); context.Response.AddHeader("Connection", "keep-alive"); + context.Response.AddHeader("X-Accel-Buffering", "no"); + context.Response.AddHeader("X-Cdidx-Mcp-Event-Stream-Id", streamId.ToString("N", CultureInfo.InvariantCulture)); _eventStreams[streamId] = stream; var prelude = Encoding.UTF8.GetBytes(": cdidx mcp event stream ready\n\n"); diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 8403e97c58..9fe26ac058 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -472,7 +472,7 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella if (transport is HttpMcpTransport httpTransport) { httpTransport.OutOfBandFrameHandler = ProcessFrame; - httpTransport.HealthJsonProvider = BuildHealthJson; + httpTransport.HealthJsonProvider = () => BuildHealthJson(httpTransport); httpTransport.KeepAliveInterval = _keepAliveInterval; httpTransport.KeepAliveFrameProvider = BuildKeepAliveNotificationJson; } @@ -1395,8 +1395,8 @@ private static void AppendMinimalCorrelationData(StringBuilder builder) }).ConfigureAwait(false); } - private string BuildHealthJson() - => BuildHealthResult().ToJsonString(_jsonOptions); + private string BuildHealthJson(HttpMcpTransport? httpTransport = null) + => BuildHealthResult(httpTransport).ToJsonString(_jsonOptions); private string BuildKeepAliveNotificationJson() { @@ -1431,7 +1431,7 @@ private string BuildKeepAliveNotificationJson() return TimeSpan.FromSeconds(seconds); } - private JsonObject BuildHealthResult() + private JsonObject BuildHealthResult(HttpMcpTransport? httpTransport = null) { var now = DateTimeOffset.UtcNow; var dbOpen = ProbeDbHealth(now, out var dbError); @@ -1444,6 +1444,13 @@ private JsonObject BuildHealthResult() ["last_db_check_at"] = _lastDbCheckAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture), ["transport_ready"] = _running, }; + if (httpTransport is not null) + { + result["http_event_stream_count"] = httpTransport.EventStreamCount; + result["http_event_stream_limit"] = httpTransport.MaxEventStreams; + result["http_max_concurrent_handlers"] = httpTransport.MaxConcurrentHandlers; + result["http_queued_request_count"] = httpTransport.QueuedRequestCount; + } if (!string.IsNullOrWhiteSpace(dbError)) result["db_error"] = dbError; return result; diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 6e5193cbde..935174bcb2 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -129,6 +129,10 @@ public async Task HttpTransport_Healthz_ReturnsStructuredHealth() Assert.True(root.GetProperty("transport_ready").GetBoolean()); Assert.True(DateTimeOffset.TryParse(root.GetProperty("last_request_at").GetString(), out _)); Assert.True(DateTimeOffset.TryParse(root.GetProperty("last_db_check_at").GetString(), out _)); + Assert.Equal(0, root.GetProperty("http_event_stream_count").GetInt32()); + Assert.True(root.GetProperty("http_event_stream_limit").GetInt32() >= 1); + Assert.True(root.GetProperty("http_max_concurrent_handlers").GetInt32() >= 1); + Assert.Equal(0, root.GetProperty("http_queued_request_count").GetInt32()); } [Fact] @@ -653,6 +657,9 @@ public async Task HttpTransport_EventsStream_DoesNotBlockPostRequests() Assert.Equal(HttpStatusCode.OK, events.StatusCode); Assert.Equal("text/event-stream", events.Content.Headers.ContentType!.MediaType); + Assert.True(events.Headers.TryGetValues("X-Accel-Buffering", out var bufferingValues)); + Assert.Contains("no", bufferingValues); + Assert.True(events.Headers.Contains("X-Cdidx-Mcp-Event-Stream-Id")); var response = await harness.PostJsonAsync("""{"jsonrpc":"2.0","id":11,"method":"ping"}"""); @@ -760,6 +767,53 @@ public async Task HttpTransport_IndexWithProgressToken_EmitsProgressOnEventsStre } } + [Fact] + public async Task HttpTransport_IndexWithProgressToken_BroadcastsProgressToMultipleEventStreams_Issue3522() + { + var projectRoot = Path.Combine(Directory.GetCurrentDirectory(), $".tmp_mcp_http_multistream_{Guid.NewGuid():N}"); + Directory.CreateDirectory(projectRoot); + try + { + File.WriteAllText(Path.Combine(projectRoot, "one.cs"), "public class One { public void Run() { } }"); + File.WriteAllText(Path.Combine(projectRoot, "two.cs"), "public class Two { public void Run() { } }"); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + await using var harness = await McpHttpHarness.StartAsync(dbPath); + + using var client = new HttpClient(); + using var firstEvents = await client.GetAsync(new Uri(new Uri(harness.Endpoint), "events"), HttpCompletionOption.ResponseHeadersRead); + using var secondEvents = await client.GetAsync(new Uri(new Uri(harness.Endpoint), "events"), HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, firstEvents.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondEvents.StatusCode); + Assert.NotEqual( + firstEvents.Headers.GetValues("X-Cdidx-Mcp-Event-Stream-Id").Single(), + secondEvents.Headers.GetValues("X-Cdidx-Mcp-Event-Stream-Id").Single()); + await WaitUntilAsync(() => harness.EventStreamCount == 2, "two event streams to be registered"); + + await using var firstStream = await firstEvents.Content.ReadAsStreamAsync(); + await using var secondStream = await secondEvents.Content.ReadAsStreamAsync(); + using var firstReader = new StreamReader(firstStream, Encoding.UTF8, leaveOpen: true); + using var secondReader = new StreamReader(secondStream, Encoding.UTF8, leaveOpen: true); + var firstProgressTask = ReadUntilAsync(firstReader, "notifications/progress"); + var secondProgressTask = ReadUntilAsync(secondReader, "notifications/progress"); + + var body = "{\"jsonrpc\":\"2.0\",\"id\":3522,\"method\":\"tools/call\",\"params\":{\"name\":\"index\",\"arguments\":{\"path\":" + + JsonSerializer.Serialize(projectRoot) + + "},\"_meta\":{\"progressToken\":\"http-progress-multi\"}}}"; + using var response = await harness.PostJsonAsync(body); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var firstProgressFrame = await firstProgressTask.WaitAsync(TimeSpan.FromSeconds(5)); + var secondProgressFrame = await secondProgressTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains("\"progressToken\":\"http-progress-multi\"", firstProgressFrame, StringComparison.Ordinal); + Assert.Contains("\"progressToken\":\"http-progress-multi\"", secondProgressFrame, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public async Task HttpTransport_EventsStream_UsesBearerAuth() { @@ -1069,6 +1123,8 @@ private McpHttpHarness(McpServer server, HttpMcpTransport transport, Cancellatio public bool HasEventStreams => _transport.HasEventStreams; + public int EventStreamCount => _transport.EventStreamCount; + public static async Task StartAsync( string dbPath, string? bearerToken = null, From 20c79cbbc8d457a055fb0d99d09d618a90692c73 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 10:30:06 +0900 Subject: [PATCH 5/5] Bound MCP search recipe diagnostics (#3545) --- src/CodeIndex/Cli/SearchAuditRecipes.cs | 8 ++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 7f9eea6f9d..a56c6504d1 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -16,6 +16,7 @@ internal static class SearchAuditRecipes private const int MaxExternalFalsePositiveGuidanceLength = 512; private const int MaxExternalLabelCount = 16; private const int MaxExternalLabelLength = 64; + private const int MaxRecipeDiagnosticCount = 64; private const int MaxRecipeDiagnosticLength = 512; private static readonly List BuiltInRecipes = @@ -312,6 +313,13 @@ private static List ReadLabels( private static void AddDiagnostic(List diagnostics, string message) { + if (diagnostics.Count >= MaxRecipeDiagnosticCount) + { + if (diagnostics.Count == MaxRecipeDiagnosticCount) + diagnostics.Add($"recipe source diagnostics were truncated after {MaxRecipeDiagnosticCount} entries."); + return; + } + if (message.Length > MaxRecipeDiagnosticLength) message = message[..MaxRecipeDiagnosticLength].TrimEnd() + " ... [truncated]"; diagnostics.Add(message); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index e2a9f16f77..792d9d4b46 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4508,6 +4508,29 @@ public void ToolsCall_Search_ListRecipesIncludesConfiguredSources_Issue3545() Assert.Null(structured["recipe_source_diagnostics"]); } + [Fact] + public void ToolsCall_Search_ListRecipesBoundsConfiguredSourceDiagnostics_Issue3545() + { + var recipePaths = new List(); + for (var i = 0; i < 8; i++) + { + var recipePath = Path.Combine(_projectRoot, $"invalid-search-recipes-{i}.json"); + File.WriteAllText(recipePath, "[" + string.Join(",", Enumerable.Repeat("42", 40)) + "]"); + recipePaths.Add(recipePath); + } + + using var env = EnvironmentVariableScope.Capture("CDIDX_SEARCH_RECIPE_PATHS"); + env.Set("CDIDX_SEARCH_RECIPE_PATHS", string.Join(Path.PathSeparator, recipePaths)); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"listRecipes":true}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Null(response["error"]); + var structured = response["result"]!["structuredContent"]!; + var diagnostics = structured["recipe_source_diagnostics"]!.AsArray(); + Assert.True(diagnostics.Count <= 65); + Assert.Contains(diagnostics, diagnostic => diagnostic!.GetValue().Contains("truncated after 64 entries", StringComparison.Ordinal)); + } + [Fact] public void ToolsCall_Search_AcceptsScalarExcludePaths_Issue3538() {