From 5933bd5cc720fd181d7956ec75946ad171761aba Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 22:14:40 +0900 Subject: [PATCH] Fix MCP progress notifications for issue #1684 --- changelog.d/unreleased/1684.fixed.md | 21 +++++ src/CodeIndex/Mcp/HttpMcpTransport.cs | 54 +++++++++++- src/CodeIndex/Mcp/IMcpTransport.cs | 5 ++ src/CodeIndex/Mcp/McpServer.cs | 53 +++++++++++- src/CodeIndex/Mcp/McpToolDefinitions.cs | 4 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 11 ++- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 59 ++++++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 85 +++++++++++++++++++ 8 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/1684.fixed.md diff --git a/changelog.d/unreleased/1684.fixed.md b/changelog.d/unreleased/1684.fixed.md new file mode 100644 index 0000000000..e81fc664f8 --- /dev/null +++ b/changelog.d/unreleased/1684.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 1684 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Mcp/IMcpTransport.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP write tools now emit progress notifications (#1684)** — `index` and `backfill_fold` now honor `tools/call.params._meta.progressToken` and send `notifications/progress` during long-running work over stdio and HTTP `/events`, so clients can keep connections alive without changing the final tool result shape. + +## 日本語 + +- **MCP の書き込みツールが progress notification を送るようになりました (#1684)** — `index` と `backfill_fold` は `tools/call.params._meta.progressToken` を受け取り、stdio と HTTP `/events` で長時間処理中に `notifications/progress` を送るため、最終 tool result の形を変えずにクライアントが接続を維持しやすくなりました。 diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index e9b17f82c4..8c68b0cc76 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -24,12 +24,13 @@ namespace CodeIndex.Mcp; /// SSE / マルチクライアント対応は将来作業として切り出す(現サーバーは自発的なサーバー→クライアント /// メッセージを発生させないため、最小単位として POST/response で十分)。 /// -internal sealed class HttpMcpTransport : IMcpTransport +internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport { private readonly HttpListener _listener; private readonly string _endpoint; private readonly Action? _requestLogger; private readonly object _requestLoggerGate = new(); + private readonly ConcurrentDictionary _eventStreams = new(); private readonly ConcurrentBag _sseStreams = new(); private readonly CancellationTokenSource _acceptCts = new(); private readonly Channel _requestQueue = Channel.CreateUnbounded(); @@ -350,6 +351,25 @@ public async Task WriteFrameAsync(string? frame, CancellationToken cancellationT } } + public async Task WriteOutOfBandFrameAsync(string frame, CancellationToken cancellationToken) + { + if (_eventStreams.IsEmpty) + return; + + foreach (var (id, stream) in _eventStreams) + { + try + { + await stream.WriteJsonRpcEventAsync(frame, cancellationToken).ConfigureAwait(false); + } + catch + { + _eventStreams.TryRemove(id, out _); + try { stream.Response.Abort(); } catch { /* ignore */ } + } + } + } + private async Task TryAuthorizeAsync(PendingRequest request) { var context = request.Context; @@ -418,6 +438,8 @@ private static bool IsEventsPath(string? path) private async Task RunEventStreamAsync(PendingRequest request, CancellationToken cancellationToken) { var context = request.Context; + var streamId = Guid.NewGuid(); + var stream = new EventStream(context.Response); try { context.Response.StatusCode = (int)HttpStatusCode.OK; @@ -425,6 +447,7 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken context.Response.SendChunked = true; context.Response.AddHeader("Cache-Control", "no-cache"); context.Response.AddHeader("Connection", "keep-alive"); + _eventStreams[streamId] = stream; var prelude = Encoding.UTF8.GetBytes(": cdidx mcp event stream ready\n\n"); await context.Response.OutputStream.WriteAsync(prelude.AsMemory(), cancellationToken).ConfigureAwait(false); @@ -445,11 +468,40 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken } finally { + _eventStreams.TryRemove(streamId, out _); LogRequest(request, (int)HttpStatusCode.OK); try { context.Response.Close(); } catch { /* ignore */ } } } + private sealed class EventStream(HttpListenerResponse response) + { + private readonly SemaphoreSlim _writeGate = new(1, 1); + + public HttpListenerResponse Response { get; } = response; + + public async Task WriteJsonRpcEventAsync(string frame, CancellationToken cancellationToken) + { + var builder = new StringBuilder(); + builder.Append("event: message\n"); + foreach (var line in frame.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) + builder.Append("data: ").Append(line).Append('\n'); + builder.Append('\n'); + var bytes = Encoding.UTF8.GetBytes(builder.ToString()); + + await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await Response.OutputStream.WriteAsync(bytes.AsMemory(), cancellationToken).ConfigureAwait(false); + await Response.OutputStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _writeGate.Release(); + } + } + } + private bool HashEqualsConfiguredToken(string provided) { // Hash only the attacker-supplied input and compare to the pre-computed configured-token diff --git a/src/CodeIndex/Mcp/IMcpTransport.cs b/src/CodeIndex/Mcp/IMcpTransport.cs index d1c7c0b1ce..47176ad8b0 100644 --- a/src/CodeIndex/Mcp/IMcpTransport.cs +++ b/src/CodeIndex/Mcp/IMcpTransport.cs @@ -34,3 +34,8 @@ internal interface IMcpTransport : IAsyncDisposable /// Task WriteFrameAsync(string? frame, CancellationToken cancellationToken); } + +internal interface IOutOfBandMcpTransport +{ + Task WriteOutOfBandFrameAsync(string frame, CancellationToken cancellationToken); +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index e8a45b1461..9346b530bf 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -59,6 +59,7 @@ public partial class McpServer : IDisposable // 直後にリセットする。`WithDbReader` が `DbReader` にライブな cancellation token // を渡せるようにするため (#1567)。 private readonly AsyncLocal _currentRequestToken = new(); + private readonly AsyncLocal?> _currentOutOfBandFrameWriter = new(); private bool _running = true; // Per-session DbContext reused across MCP tool calls. Holding the connection open // avoids reopening SQLite, reapplying pragmas, and re-registering every SQL function @@ -433,11 +434,15 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella // ツールが起動する SQLite 作業が shutdown / 切断を観測できるよう per-request // token を `WithDbReader` に渡す (#1567)。 _currentRequestToken.Value = loopToken; + _currentOutOfBandFrameWriter.Value = transport is IOutOfBandMcpTransport outOfBandTransport + ? frameToWrite => outOfBandTransport.WriteOutOfBandFrameAsync(frameToWrite, loopToken).GetAwaiter().GetResult() + : null; response = ProcessFrame(frame); } finally { _currentRequestToken.Value = CancellationToken.None; + _currentOutOfBandFrameWriter.Value = null; _concurrencyGate.Release(); } @@ -529,11 +534,24 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella try { _currentRequestToken.Value = loopToken; + _currentOutOfBandFrameWriter.Value = frameToWrite => + { + writeGate.Wait(loopToken); + try + { + transport.WriteFrameAsync(frameToWrite, loopToken).GetAwaiter().GetResult(); + } + finally + { + writeGate.Release(); + } + }; response = ProcessFrame(frame); } finally { _currentRequestToken.Value = CancellationToken.None; + _currentOutOfBandFrameWriter.Value = null; normalFrameGate.Release(); } @@ -1458,6 +1476,7 @@ private JsonNode HandleToolsCall(JsonNode? id, JsonNode? callParams) { var toolName = callParams?["name"]?.GetValue(); var args = callParams?["arguments"]; + var progressToken = TryReadProgressToken(callParams); if (toolName == null) { @@ -1548,8 +1567,8 @@ private JsonNode HandleToolsCall(JsonNode? id, JsonNode? callParams) "unused_symbols" => ExecuteUnusedSymbols(id, args), "symbol_hotspots" => ExecuteSymbolHotspots(id, args), "ping" => ExecutePing(id), - "index" => ExecuteIndex(id, args), - "backfill_fold" => ExecuteBackfillFold(id), + "index" => ExecuteIndex(id, args, progressToken), + "backfill_fold" => ExecuteBackfillFold(id, progressToken), "suggest_improvement" => ExecuteSuggestImprovement(id, args), _ => CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"Unknown tool: {toolName}", category: McpErrorEnvelope.CategoryToolUnknown, @@ -1618,6 +1637,36 @@ private JsonNode HandleToolsCall(JsonNode? id, JsonNode? callParams) return response; } + private static JsonNode? TryReadProgressToken(JsonNode? callParams) + { + var token = callParams?["_meta"]?["progressToken"]; + return token is null ? null : JsonNode.Parse(token.ToJsonString()); + } + + private void EmitProgressNotification(JsonNode? progressToken, long progress, long? total, string? message = null) + { + if (progressToken is null || _currentOutOfBandFrameWriter.Value is not { } writer) + return; + + var parameters = new JsonObject + { + ["progressToken"] = JsonNode.Parse(progressToken.ToJsonString()), + ["progress"] = progress, + }; + if (total.HasValue) + parameters["total"] = total.Value; + if (!string.IsNullOrWhiteSpace(message)) + parameters["message"] = message; + + var notification = new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "notifications/progress", + ["params"] = parameters, + }; + writer(notification.ToJsonString(_jsonOptions)); + } + /// /// Emit a single audit record for the just-executed tool call. Inspects the wire /// response to derive the result count and error code so the audit trail matches what diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 4272845acd..da399aca27 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -385,7 +385,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "index", - "Index or re-index a project directory. Scans source files, extracts symbols, and builds FTS5 search index. / プロジェクトディレクトリをインデックス(再インデックス)。ソースファイルをスキャンし、シンボルを抽出してFTS5検索インデックスを構築。", + "Index or re-index a project directory. Scans source files, extracts symbols, and builds FTS5 search index. On transports that can carry out-of-band server messages (stdio, and HTTP clients connected to `/events`), when the tools/call request includes `_meta.progressToken`, this tool emits `notifications/progress` with that token while scanning, indexing, and finalizing. / プロジェクトディレクトリをインデックス(再インデックス)。ソースファイルをスキャンし、シンボルを抽出してFTS5検索インデックスを構築。out-of-band のサーバーメッセージを送れる transport(stdio、および `/events` に接続した HTTP クライアント)では、tools/call リクエストに `_meta.progressToken` が含まれる場合、スキャン・インデックス・finalize 中に同じ token の `notifications/progress` を送信する。", new JsonObject { ["type"] = "object", @@ -400,7 +400,7 @@ private JsonNode HandleToolsList(JsonNode? id) IndexAnnotations()), CreateToolDefinition( "backfill_fold", - "Upgrade folded-name keys in an existing CodeIndex DB without reparsing source files. Rejects missing or blank targets instead of creating a fresh DB. Fills missing `name_folded` columns (or rewrites all keys after fold metadata drift such as version/fingerprint mismatch) and stamps FoldReady on success. / ソース再解析なしで既存の CodeIndex DB の folded-name key を更新する。欠落したDBや空のDBを新規作成せず拒否し、欠損 `name_folded` 列を埋めるか、fold metadata の drift(version / fingerprint 不一致など)時は全 key を再生成し、成功時に FoldReady を stamp する。", + "Upgrade folded-name keys in an existing CodeIndex DB without reparsing source files. Rejects missing or blank targets instead of creating a fresh DB. Fills missing `name_folded` columns (or rewrites all keys after fold metadata drift such as version/fingerprint mismatch) and stamps FoldReady on success. On transports that can carry out-of-band server messages (stdio, and HTTP clients connected to `/events`), when the tools/call request includes `_meta.progressToken`, this tool emits `notifications/progress` with that token during backfill and verification. / ソース再解析なしで既存の CodeIndex DB の folded-name key を更新する。欠落したDBや空のDBを新規作成せず拒否し、欠損 `name_folded` 列を埋めるか、fold metadata の drift(version / fingerprint 不一致など)時は全 key を再生成し、成功時に FoldReady を stamp する。out-of-band のサーバーメッセージを送れる transport(stdio、および `/events` に接続した HTTP クライアント)では、tools/call リクエストに `_meta.progressToken` が含まれる場合、backfill と検証中に同じ token の `notifications/progress` を送信する。", new JsonObject { ["type"] = "object", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 1733141188..955cbd1217 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2284,7 +2284,7 @@ private JsonNode ExecuteLanguages(JsonNode? id) return CreateToolResult(id, summary, payload); } - private JsonNode ExecuteIndex(JsonNode? id, JsonNode? args) + private JsonNode ExecuteIndex(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) { if (!TryReadRequiredStringParameter(args, "path", out var path, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); @@ -2411,6 +2411,7 @@ void WriteProjectRootOnce() // Scan and index / スキャン・インデックス var scanResult = indexer.ScanFilesDetailed(); var files = scanResult.Files; + EmitProgressNotification(progressToken, 0, files.Count, "Index scan complete; indexing files."); var csharpWorkspace = BuildMcpCSharpStaticInterfaceWorkspaceSymbols(writer, indexer, projectPath, files); if (purged > 0 && hadCSharpStaticInterfaceContractsBeforePurge) csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = true }; @@ -2496,6 +2497,7 @@ void WriteProjectRootOnce() errors++; } processed++; + EmitProgressNotification(progressToken, processed, files.Count); } writer.OptimizeFts(); @@ -2512,6 +2514,7 @@ void WriteProjectRootOnce() _ = priorMetadataTargetCsharp; if (errors == 0) { + EmitProgressNotification(progressToken, processed, files.Count, "Finalizing index metadata."); writer.MarkBatchInProgress(); using var readinessTxn = writer.BeginTransaction(); writer.MarkGraphReady(); @@ -2628,6 +2631,7 @@ void WriteProjectRootOnce() readinessTxn.Commit(); } var (totalFiles, totalChunks, totalSymbols, totalReferences) = writer.GetCounts(); + EmitProgressNotification(progressToken, files.Count, files.Count, errors == 0 ? "Indexing complete." : "Indexing completed with errors."); var structured = new JsonObject { @@ -2673,7 +2677,7 @@ void WriteProjectRootOnce() structured); } - private JsonNode ExecuteBackfillFold(JsonNode? id) + private JsonNode ExecuteBackfillFold(JsonNode? id, JsonNode? progressToken = null) { if (!DbContext.TryValidateExistingCodeIndexDb(_dbPath, out var validationMessage, out var isNotFound)) { @@ -2701,7 +2705,9 @@ private JsonNode ExecuteBackfillFold(JsonNode? id) var storedFoldFingerprint = db.GetMetaString("fold_key_fingerprint"); var rewriteAll = storedFoldVersion != currentFoldVersion || storedFoldFingerprint != currentFoldFingerprint; + EmitProgressNotification(progressToken, 0, null, "Backfilling folded-name keys."); var (symbols, symbolReferences) = writer.BackfillFoldedColumns(rewriteAll); + EmitProgressNotification(progressToken, symbols + symbolReferences, null, "Verifying folded-name keys."); // MarkFoldReady wraps its own re-verification in BEGIN IMMEDIATE, so a concurrent // writer cannot insert NULL-folded rows between the verify and the stamp. Issue #1535. // MarkFoldReady は BEGIN IMMEDIATE 内で再検証するため、concurrent writer による @@ -2711,6 +2717,7 @@ private JsonNode ExecuteBackfillFold(JsonNode? id) return CreateToolErrorResponse(id, "Folded-name backfill verification failed: some rows still have NULL folded values. Re-run backfill_fold."); var userVersionAfter = db.GetUserVersion(); + EmitProgressNotification(progressToken, symbols + symbolReferences, symbols + symbolReferences, "Folded-name backfill complete."); var payload = new JsonObject { diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 37124595a3..a3679ba946 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -210,6 +210,47 @@ public async Task HttpTransport_EventsStream_DoesNotBlockPostRequests() Assert.Equal(11, doc.RootElement.GetProperty("id").GetInt32()); } + [Fact] + public async Task HttpTransport_IndexWithProgressToken_EmitsProgressOnEventsStreamAndReturnsResult() + { + var projectRoot = Path.Combine(Directory.GetCurrentDirectory(), $".tmp_mcp_http_progress_{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 events = await client.GetAsync(new Uri(new Uri(harness.Endpoint), "events"), HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, events.StatusCode); + + await using var eventStream = await events.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(eventStream, Encoding.UTF8, leaveOpen: true); + var progressTask = ReadUntilAsync(reader, "notifications/progress"); + + var body = "{\"jsonrpc\":\"2.0\",\"id\":1684,\"method\":\"tools/call\",\"params\":{\"name\":\"index\",\"arguments\":{\"path\":" + + JsonSerializer.Serialize(projectRoot) + + "},\"_meta\":{\"progressToken\":\"http-progress\"}}}"; + using var response = await harness.PostJsonAsync(body); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseBody = await response.Content.ReadAsStringAsync(); + using var responseDoc = JsonDocument.Parse(responseBody); + Assert.Equal(1684, responseDoc.RootElement.GetProperty("id").GetInt32()); + + var progressFrame = await progressTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains("\"method\":\"notifications/progress\"", progressFrame, StringComparison.Ordinal); + Assert.Contains("\"progressToken\":\"http-progress\"", progressFrame, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public async Task HttpTransport_EventsStream_UsesBearerAuth() { @@ -382,9 +423,25 @@ public void Dispose() return snapshot; } + private static async Task ReadUntilAsync(StreamReader reader, string expected) + { + var builder = new StringBuilder(); + while (true) + { + var line = await reader.ReadLineAsync(); + if (line == null) + break; + builder.AppendLine(line); + if (line.Contains(expected, StringComparison.Ordinal)) + return builder.ToString(); + } + + return builder.ToString(); + } + private sealed class McpHttpHarness : IAsyncDisposable { - private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30); private readonly McpServer _server; private readonly HttpMcpTransport _transport; private readonly CancellationTokenSource _cts; diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 265711bb14..5d0a9adca0 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1392,6 +1392,91 @@ public async Task RunAsync_StdioCancellationNotification_CancelsActiveRequest() Assert.Contains(transport.WrittenFrames, frame => frame?.Contains("\"request_cancelled\"", StringComparison.Ordinal) == true); } + [Fact] + public async Task RunAsync_IndexWithProgressToken_EmitsProgressNotificationBeforeResult() + { + var projectRoot = Path.Combine(Directory.GetCurrentDirectory(), $".tmp_mcp_progress_{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)!); + using var server = new McpServer(dbPath, "test", dbPathExplicit: true); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1684, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = new JsonObject { ["path"] = projectRoot }, + ["_meta"] = new JsonObject { ["progressToken"] = "issue-1684" }, + }, + }; + var transport = new ShutdownProbeTransport("stdio", (Action?)null, request.ToJsonString()); + + await server.RunAsync(transport, CancellationToken.None); + + var progressFrameIndex = transport.WrittenFrames.FindIndex(frame => + frame?.Contains("\"method\":\"notifications/progress\"", StringComparison.Ordinal) == true); + var resultFrameIndex = transport.WrittenFrames.FindIndex(frame => + frame?.Contains("\"id\":1684", StringComparison.Ordinal) == true); + Assert.True(progressFrameIndex >= 0); + Assert.True(resultFrameIndex >= 0); + Assert.True(progressFrameIndex < resultFrameIndex); + + var progress = JsonNode.Parse(transport.WrittenFrames[progressFrameIndex]!)!; + Assert.Equal("issue-1684", progress["params"]!["progressToken"]!.GetValue()); + Assert.Equal(2, progress["params"]!["total"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public async Task RunAsync_NonStreamingIndexWithProgressToken_ReturnsFinalResultWithoutProgress() + { + var projectRoot = Path.Combine(Directory.GetCurrentDirectory(), $".tmp_mcp_progress_{Guid.NewGuid():N}"); + Directory.CreateDirectory(projectRoot); + try + { + File.WriteAllText(Path.Combine(projectRoot, "one.cs"), "public class One { public void Run() { } }"); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + using var server = new McpServer(dbPath, "test", dbPathExplicit: true); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1685, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = new JsonObject { ["path"] = projectRoot }, + ["_meta"] = new JsonObject { ["progressToken"] = "non-streaming" }, + }, + }; + var transport = new ShutdownProbeTransport("http-like", (Action?)null, request.ToJsonString()); + + await server.RunAsync(transport, CancellationToken.None); + + Assert.DoesNotContain(transport.WrittenFrames, frame => + frame?.Contains("\"method\":\"notifications/progress\"", StringComparison.Ordinal) == true); + Assert.Contains(transport.WrittenFrames, frame => + frame?.Contains("\"id\":1685", StringComparison.Ordinal) == true + && frame.Contains("\"structuredContent\"", StringComparison.Ordinal)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void MaxConcurrency_DefaultExposesIssueBound() {