diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6156d72c5b..9b2d28ff39 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1918,6 +1918,10 @@ For read-only deployments or sessions that only need a narrow tool surface, two When both are set, the allowlist wins. `tools/list` only advertises enabled tools, and the `initialize` instructions string no longer recommends tools the gate disabled. A top-level `tools/call` on a disabled known tool returns the structured JSON-RPC error `-32601 Tool not enabled: `. `batch_query` continues to succeed at the envelope, but each disabled-tool slot carries a `code: -32601` field alongside the `error` string so clients can branch on the code instead of substring-matching prose. Unknown names (typos) still surface as `-32602 Unknown tool`, so operator-disabled tools are distinguishable from missing tools. Names are compared case-insensitively. The default is **all tools enabled**, so existing deployments are unaffected unless an operator sets one of these variables. +#### MCP roots and sampling + +`cdidx mcp` advertises roots and sampling support during `initialize`. When the client supports roots, `index` refreshes `roots/list` and rejects paths outside the granted client roots. `suggest_improvement` uses `sampling/createMessage` to extract an optional one-line title and tag list before storing the raw suggestion. Set `CDIDX_MCP_SAMPLING=0` (or `false` / `off`) to disable server-to-client sampling requests. + ### Why cdidx over grep/ripgrep for AI workflows? | | `grep` / `rg` | `cdidx` | @@ -3920,6 +3924,10 @@ stdio トランスポートはバイト単位で挙動が変わらないため 両方指定された場合は allowlist が優先されます。`tools/list` は有効ツールのみ広告し、`initialize` の instructions 文字列も無効化されたツールを推奨しなくなります。トップレベル `tools/call` で無効化された既知ツールを呼び出した場合は、構造化された JSON-RPC エラー `-32601 Tool not enabled: ` を返します。`batch_query` 自体は引き続きエンベロープとして成功しますが、無効化ツールの各 slot に `code: -32601` フィールドが `error` 文字列と並んで載るため、クライアントは prose の部分一致ではなく code で分岐できます。typo などサーバーに元から無い名前は引き続き `-32602 Unknown tool` を返すため、オペレータによる無効化と typo を区別できます。比較は大小文字無視。既定は **全ツール有効** なので、オペレータがこれらの変数を設定しない限り既存デプロイへの影響はありません。 +#### MCP roots と sampling + +`cdidx mcp` は `initialize` で roots と sampling support を広告します。クライアントが roots をサポートする場合、`index` は `roots/list` を更新し、許可された client root の外にある path を拒否します。`suggest_improvement` は raw suggestion を保存する前に `sampling/createMessage` で任意の 1 行タイトルとタグ一覧を抽出します。server-to-client sampling request を無効化するには `CDIDX_MCP_SAMPLING=0`(または `false` / `off`)を設定してください。 + ### AIワークフローで grep/ripgrep より cdidx が優れる理由 | | `grep` / `rg` | `cdidx` | diff --git a/changelog.d/unreleased/1686.added.md b/changelog.d/unreleased/1686.added.md new file mode 100644 index 0000000000..4defe769fd --- /dev/null +++ b/changelog.d/unreleased/1686.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 1686 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Models/SuggestionRecord.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP `suggest_improvement` can use client sampling for triage metadata (#1686)** — when the connected client advertises sampling and `CDIDX_MCP_SAMPLING` is not disabled, cdidx asks `sampling/createMessage` for a short title and tags before storing the suggestion, while preserving the raw submission and falling back when sampling is unavailable. + +## 日本語 + +- **MCP の `suggest_improvement` が triage メタデータ抽出に client sampling を使えるようになりました (#1686)** — 接続クライアントが sampling を advertise し、`CDIDX_MCP_SAMPLING` が無効化されていない場合、cdidx は保存前に `sampling/createMessage` で短いタイトルとタグを抽出し、元の投稿内容を保持したまま sampling 非対応時は従来経路にフォールバックします。 diff --git a/changelog.d/unreleased/1687.fixed.md b/changelog.d/unreleased/1687.fixed.md new file mode 100644 index 0000000000..1566f0d390 --- /dev/null +++ b/changelog.d/unreleased/1687.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1687 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP `index` now respects client roots (#1687)** — cdidx advertises roots support, refreshes `roots/list` when available, and rejects `index` paths outside the granted client roots instead of relying only on the current working directory. + +## 日本語 + +- **MCP の `index` が client roots を尊重するようになりました (#1687)** — cdidx は roots support を advertise し、利用可能な場合は `roots/list` を更新して、許可された client root 外の `index` path を current working directory 判定だけに頼らず拒否します。 diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 8fad322822..9baac60873 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -81,6 +81,8 @@ internal HttpMcpTransport(string prefix, string host, int boundPort, string? bea internal Func? KeepAliveFrameProvider { get; set; } + internal bool HasEventStreams => !_eventStreams.IsEmpty; + /// /// Resolve a `host:port` listen spec into the corresponding HTTP prefix. Ephemeral ports /// (port `0`) are resolved up-front by binding a temporary so the @@ -289,7 +291,7 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT private bool TryHandleOutOfBandFrame(PendingRequest request, string body) { - if (OutOfBandFrameHandler is null || !IsCancellationNotification(body)) + if (OutOfBandFrameHandler is null || (!IsCancellationNotification(body) && !IsJsonRpcResponse(body))) return false; var context = request.Context; @@ -338,6 +340,22 @@ private static bool IsCancellationNotification(string body) } } + private static bool IsJsonRpcResponse(string body) + { + try + { + var node = JsonNode.Parse(body); + return node is JsonObject obj + && obj.ContainsKey("id") + && obj["method"] is null + && (obj.ContainsKey("result") || obj.ContainsKey("error")); + } + catch + { + return false; + } + } + public async Task WriteFrameAsync(string? frame, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index b736e7dcf2..c534e5ff03 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -26,6 +26,7 @@ namespace CodeIndex.Mcp; /// public partial class McpServer : IDisposable { + private static int s_nextClientRequestId; private readonly string _dbPath; private readonly bool _dbPathExplicit; private readonly string _version; @@ -54,6 +55,7 @@ public partial class McpServer : IDisposable // JSON-RPC request id ごとの実行中 CTS。MCP `$/cancelRequest` 通知でサーバー全体ではなく // 対象ツール呼び出しだけを cancel するため (#1418)。 private readonly ConcurrentDictionary _activeRequests = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _pendingClientRequests = new(StringComparer.Ordinal); // Token observed by the currently executing tool call. Set just before // `ProcessFrame` runs and reset afterwards so `WithDbReader` can hand a live // cancellation token to `DbReader` for SQLite work (#1567). @@ -63,11 +65,13 @@ public partial class McpServer : IDisposable private readonly AsyncLocal _currentRequestToken = new(); private readonly AsyncLocal _isolateDbForCurrentRequest = new(); private readonly AsyncLocal?> _currentOutOfBandFrameWriter = new(); + private readonly AsyncLocal _canAwaitClientResponses = new(); private readonly AsyncLocal?> _deferredFrameLogs = new(); private static readonly AsyncLocal CurrentCorrelationContext = new(); private volatile bool _running = true; private bool _initializedNotificationPending; private bool _initializedNotificationSent; + private bool _clientRootsStale = true; // Per-session DbContext reused across MCP tool calls. Holding the connection open // avoids reopening SQLite, reapplying pragmas, and re-registering every SQL function // on each invocation (issue #1494). @@ -158,6 +162,7 @@ public partial class McpServer : IDisposable private const string MaxResponseBytesEnvVar = "CDIDX_MCP_RESPONSE_MAX_BYTES"; private const string KeepAliveIntervalEnvironmentVariable = "CDIDX_MCP_KEEP_ALIVE_INTERVAL_S"; internal const string DebugEnvironmentVariable = "CDIDX_DEBUG"; + private const string SamplingEnabledEnvironmentVariable = "CDIDX_MCP_SAMPLING"; internal const int MaxJsonDepth = 32; internal const int MaxBatchRequestCount = 100; // Stdio buffer for the JSON-RPC loop. Sized to fit typical large MCP payloads (e.g. batch_query) @@ -489,6 +494,8 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella _currentOutOfBandFrameWriter.Value = transport is IOutOfBandMcpTransport outOfBandTransport ? frameToWrite => outOfBandTransport.WriteOutOfBandFrameAsync(frameToWrite, loopToken).GetAwaiter().GetResult() : null; + _canAwaitClientResponses.Value = transport is IOutOfBandMcpTransport + && (transport is not HttpMcpTransport httpResponseTransport || httpResponseTransport.HasEventStreams); BeginDeferredFrameLogs(); response = await ProcessFrameAsync(frame).ConfigureAwait(false); } @@ -496,6 +503,7 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella { _currentRequestToken.Value = CancellationToken.None; _currentOutOfBandFrameWriter.Value = null; + _canAwaitClientResponses.Value = false; _concurrencyGate.Release(); } @@ -590,6 +598,23 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella continue; } + if (IsServerResponseFrame(frame)) + { + BeginDeferredFrameLogs(); + var response = await ProcessFrameAsync(frame).ConfigureAwait(false); + await writeGate.WaitAsync(loopToken).ConfigureAwait(false); + try + { + await WriteFrameSafelyAsync(transport, response, loopToken).ConfigureAwait(false); + FlushDeferredFrameLogs(); + } + finally + { + writeGate.Release(); + } + continue; + } + await _concurrencyGate.WaitAsync(loopToken).ConfigureAwait(false); tasks.Add(Task.Run(async () => { @@ -600,6 +625,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella try { _currentRequestToken.Value = loopToken; + _canAwaitClientResponses.Value = true; _currentOutOfBandFrameWriter.Value = frameToWrite => { writeGate.Wait(loopToken); @@ -618,6 +644,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella finally { _currentRequestToken.Value = CancellationToken.None; + _canAwaitClientResponses.Value = false; _currentOutOfBandFrameWriter.Value = null; normalFrameGate.Release(); } @@ -792,6 +819,22 @@ private async Task EmitInitializedNotificationIfPendingAsync(TextWriter writer) return notification.ToJsonString(_jsonOptions); } + private static bool IsServerResponseFrame(string frame) + { + try + { + var node = JsonNode.Parse(frame); + return node is JsonObject obj + && obj.ContainsKey("id") + && obj["method"] is null + && (obj.ContainsKey("result") || obj.ContainsKey("error")); + } + catch (JsonException) + { + return false; + } + } + private string BuildInvalidUtf8ParseErrorResponse(DecoderFallbackException ex) { DeferFrameLog(BuildInvalidUtf8ErrorLog(ex.Message)); @@ -843,6 +886,9 @@ internal static string BuildInvalidUtf8ErrorLog(string detail) if (request == null) return null; + if (TryCompletePendingClientRequest(request)) + return null; + ExtractResponseId(request, out responseHasId, out responseId); if (responseHasId && CurrentCorrelationContext.Value is null) frameCorrelationScope = BeginRequestCorrelation(responseId); @@ -905,6 +951,76 @@ internal static string BuildInvalidUtf8ErrorLog(string detail) return activity; } + private bool TryCompletePendingClientRequest(JsonNode request) + { + if (request is not JsonObject obj + || !obj.TryGetPropertyValue("id", out var id) + || obj["method"] is not null) + return false; + + var key = id?.ToJsonString(_jsonOptions) ?? "null"; + if (!_pendingClientRequests.TryRemove(key, out var pending)) + return false; + + if (obj.TryGetPropertyValue("error", out var error) && error is not null) + pending.TrySetException(new InvalidOperationException(error.ToJsonString(_jsonOptions))); + else + pending.TrySetResult(obj["result"]?.DeepClone()); + return true; + } + + private async Task SendClientRequestAsync(string method, JsonObject? @params, CancellationToken cancellationToken) + { + if (ClientRequestHandlerForTests is { } handler) + return handler(method, @params)?.DeepClone(); + + var writer = _currentOutOfBandFrameWriter.Value; + if (writer is null || !_canAwaitClientResponses.Value) + return null; + + var id = "cdidx-" + Interlocked.Increment(ref s_nextClientRequestId).ToString(System.Globalization.CultureInfo.InvariantCulture); + var key = JsonSerializer.Serialize(id); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_pendingClientRequests.TryAdd(key, pending)) + return null; + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + }; + if (@params is not null) + request["params"] = @params; + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); + using var cancellationRegistration = timeoutCts.Token.Register(static state => + { + var tuple = ((McpServer server, string key, TaskCompletionSource pending))state!; + if (tuple.server._pendingClientRequests.TryRemove(tuple.key, out var _)) + tuple.pending.TrySetCanceled(); + }, (this, key, pending)); + + try + { + writer(request.ToJsonString(_jsonOptions)); + return await pending.Task.ConfigureAwait(false); + } + catch (InvalidOperationException) + { + return null; + } + catch (OperationCanceledException) + { + return null; + } + finally + { + _pendingClientRequests.TryRemove(key, out var _); + } + } + private static string? TryGetMcpTraceParent(JsonNode request) { if (request is not JsonObject obj || @@ -1111,6 +1227,12 @@ private static void AppendMinimalCorrelationData(StringBuilder builder) if (method == "notifications/initialized") return null; + if (method == "notifications/roots/list_changed") + { + _clientRootsStale = true; + return null; + } + // Graceful shutdown via JSON-RPC notification (#1567). Without this, the only way to // stop a long-lived `cdidx mcp` server was to close the transport (stdin EOF / HTTP // listener stop), which races with in-flight work and forces clients to send SIGINT. @@ -1605,7 +1727,12 @@ private JsonNode HandleInitialize(JsonNode? id, JsonNode? _params) { ["listChanged"] = false }, - ["logging"] = new JsonObject() + ["logging"] = new JsonObject(), + ["roots"] = new JsonObject + { + ["listChanged"] = true + }, + ["sampling"] = new JsonObject() }, ["serverInfo"] = new JsonObject { @@ -1656,6 +1783,7 @@ private void CaptureClientInfo(JsonNode? initializeParams) _clientVersion = null; if (initializeParams is not JsonObject obj) return; + _clientRootsStale = true; if (obj["clientInfo"] is not JsonObject info) return; _clientName = TryReadStringMember(info, "name"); @@ -1698,6 +1826,8 @@ private void CaptureClientSession(JsonNode? initializeParams) internal string McpLogLevelForTests => _mcpLogLevel; + internal Func? ClientRequestHandlerForTests { get; set; } + private static string? TryReadStringMember(JsonObject obj, string key) { if (!obj.TryGetPropertyValue(key, out var node)) @@ -2213,7 +2343,7 @@ private async Task HandleToolsCallAsync(JsonNode? id, JsonNode? callPa "unused_symbols" => ExecuteUnusedSymbols(id, args), "symbol_hotspots" => ExecuteSymbolHotspots(id, args), "ping" => ExecutePing(id), - "index" => ExecuteIndex(id, args, progressToken), + "index" => await ExecuteIndexAsync(id, args, progressToken).ConfigureAwait(false), "backfill_fold" => ExecuteBackfillFold(id, args, progressToken), "suggest_improvement" => await ExecuteSuggestImprovementAsync(id, args).ConfigureAwait(false), _ => CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"Unknown tool: {toolName}", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3c07ae5c59..8f0709443e 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3064,6 +3064,67 @@ private JsonNode ExecuteLanguages(JsonNode? id) } private JsonNode ExecuteIndex(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) + => ExecuteIndexAsync(id, args, progressToken).GetAwaiter().GetResult(); + + private async Task RefreshClientRootsIfNeededAsync() + { + if (!_clientRootsStale || !HasClientCapability("roots")) + return; + + var result = await SendClientRequestAsync("roots/list", null, _currentRequestToken.Value).ConfigureAwait(false); + if (result?["roots"] is not JsonArray roots) + return; + + var refreshed = new JsonArray(); + foreach (var root in roots) + { + var uri = TryReadStringValue(root?["uri"]) ?? TryReadStringValue(root); + if (!string.IsNullOrWhiteSpace(uri)) + refreshed.Add(uri); + } + _clientRoots = refreshed; + _clientRootsStale = false; + } + + private bool IsPathWithinClientRoots(string path) + { + if (!HasClientCapability("roots")) + return true; + + var rootPaths = _clientRoots + .Select(root => TryReadStringValue(root)) + .Select(TryResolveRootPath) + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Cast() + .ToArray(); + if (rootPaths.Length == 0) + return false; + + var fullPath = Path.GetFullPath(path); + return rootPaths.Any(root => IsPathWithinDirectory(root, fullPath)); + } + + private static string? TryResolveRootPath(string? root) + { + if (string.IsNullOrWhiteSpace(root)) + return null; + if (Uri.TryCreate(root, UriKind.Absolute, out var uri)) + { + if (!string.Equals(uri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + return null; + return Path.GetFullPath(Uri.UnescapeDataString(uri.LocalPath)); + } + try + { + return Path.GetFullPath(root); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return null; + } + } + + private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) { if (!TryReadRequiredStringParameter(args, "path", out var path, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); @@ -3092,6 +3153,9 @@ private JsonNode ExecuteIndex(JsonNode? id, JsonNode? args, JsonNode? progressTo var cwd = Path.GetFullPath("."); if (!IsPathWithinDirectory(cwd, projectPath)) return CreateToolErrorResponse(id, "Path must be within the current working directory"); + await RefreshClientRootsIfNeededAsync().ConfigureAwait(false); + if (!IsPathWithinClientRoots(projectPath)) + return CreateToolErrorResponse(id, "Path must be within an MCP client root"); if (!Directory.Exists(projectPath)) return CreateToolErrorResponse(id, "Directory not found"); @@ -3713,6 +3777,8 @@ 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 sampling = await TrySampleSuggestionMetadataAsync(category, language, description, context, toolInvocationContext).ConfigureAwait(false); + // 4. Compute dedup hash / 重複排除ハッシュを計算 var hash = SuggestionStore.ComputeHash(category, language, description); @@ -3751,6 +3817,8 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo McpClientName = _clientName, McpClientVersion = _clientVersion, ToolInvocationContext = toolInvocationContext, + SampledTitle = sampling?.Title, + SampledTags = sampling?.Tags, }; // Build GitHub submission callback (null if no token configured). @@ -3809,9 +3877,128 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo payload["upstream_url"] = result.UpstreamUrl; payload["github_issue_url"] = result.UpstreamUrl; } + if (sampling?.Title != null) + payload["sampled_title"] = sampling.Title; + if (sampling?.Tags is { Length: > 0 }) + payload["sampled_tags"] = new JsonArray(sampling.Tags.Select(tag => JsonValue.Create(tag)).ToArray()); return CreateToolResult(id, "Suggestion recorded. Thank you for the feedback.", payload); } + private sealed record SuggestionSamplingResult(string? Title, string[]? Tags); + + private async Task TrySampleSuggestionMetadataAsync( + string category, + string? language, + string description, + string? context, + string? toolInvocationContext) + { + if (!IsSamplingEnabled() || !HasClientCapability("sampling")) + return null; + + var prompt = new StringBuilder(); + prompt.AppendLine("Extract structured metadata for a cdidx improvement suggestion."); + prompt.AppendLine("Return only compact JSON with keys: title (one line, <=80 chars) and tags (array of 1-6 lowercase identifiers)."); + prompt.AppendLine("Do not include source code."); + prompt.AppendLine($"category: {category}"); + if (!string.IsNullOrWhiteSpace(language)) + prompt.AppendLine($"language: {language}"); + prompt.AppendLine($"description: {description}"); + if (!string.IsNullOrWhiteSpace(context)) + prompt.AppendLine($"context: {context}"); + if (!string.IsNullOrWhiteSpace(toolInvocationContext)) + prompt.AppendLine($"tool_invocation_context: {toolInvocationContext}"); + + var result = await SendClientRequestAsync("sampling/createMessage", new JsonObject + { + ["messages"] = new JsonArray + { + new JsonObject + { + ["role"] = "user", + ["content"] = new JsonObject + { + ["type"] = "text", + ["text"] = prompt.ToString(), + } + } + }, + ["maxTokens"] = 200, + }, _currentRequestToken.Value).ConfigureAwait(false); + + var text = ExtractSamplingText(result); + if (string.IsNullOrWhiteSpace(text)) + return null; + try + { + var parsed = JsonNode.Parse(text); + var title = SanitizeSampledTitle(TryReadStringValue(parsed?["title"])); + var tags = parsed?["tags"] is JsonArray tagArray + ? tagArray.Select(TryReadStringValue) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(SanitizeSampledTag) + .Where(t => t != null) + .Cast() + .Distinct(StringComparer.Ordinal) + .Take(6) + .ToArray() + : null; + if (title == null && (tags == null || tags.Length == 0)) + return null; + return new SuggestionSamplingResult(title, tags is { Length: > 0 } ? tags : null); + } + catch (JsonException) + { + return null; + } + } + + private bool HasClientCapability(string name) + => _clientCapabilities is JsonObject obj + && obj.TryGetPropertyValue(name, out var node) + && node is not null; + + private static bool IsSamplingEnabled() + { + var raw = Environment.GetEnvironmentVariable(SamplingEnabledEnvironmentVariable); + return raw is null || !(raw.Equals("0", StringComparison.OrdinalIgnoreCase) + || raw.Equals("false", StringComparison.OrdinalIgnoreCase) + || raw.Equals("off", StringComparison.OrdinalIgnoreCase)); + } + + private static string? ExtractSamplingText(JsonNode? result) + { + if (result is null) + return null; + if (TryReadStringValue(result["content"]?["text"]) is { Length: > 0 } contentText) + return contentText; + if (result["content"] is JsonArray contentArray) + { + foreach (var item in contentArray) + { + if (TryReadStringValue(item?["text"]) is { Length: > 0 } itemText) + return itemText; + } + } + return TryReadStringValue(result["text"]); + } + + private static string? SanitizeSampledTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + return null; + title = title.Trim(); + return title.Length <= 80 ? title : title[..80]; + } + + private static string? SanitizeSampledTag(string? tag) + { + if (string.IsNullOrWhiteSpace(tag)) + return null; + var normalized = new string(tag.Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_').ToArray()).Trim('_'); + return normalized.Length == 0 ? null : normalized.Length <= 40 ? normalized : normalized[..40]; + } + private static bool TryProbeCdidxDirectoryWritable(string cdidxDir, out string? error) { var probePath = Path.Combine(cdidxDir, $".write_probe.{Guid.NewGuid():N}.tmp"); diff --git a/src/CodeIndex/Models/SuggestionRecord.cs b/src/CodeIndex/Models/SuggestionRecord.cs index ed00c97a80..c1bb7103c1 100644 --- a/src/CodeIndex/Models/SuggestionRecord.cs +++ b/src/CodeIndex/Models/SuggestionRecord.cs @@ -89,6 +89,12 @@ public class SuggestionRecord /// Optional natural-language invocation context supplied by the caller / 呼び出し元が渡す任意の自然言語コンテキスト public string? ToolInvocationContext { get; set; } + /// One-line title extracted by MCP sampling, when available / MCP sampling で抽出された1行タイトル(取得可能な場合) + public string? SampledTitle { get; set; } + + /// Structured tags extracted by MCP sampling, when available / MCP sampling で抽出された構造化タグ(取得可能な場合) + public string[]? SampledTags { get; set; } + /// Upstream GitHub Issue number when known / 判明している場合の upstream GitHub Issue 番号 public int? UpstreamIssueNumber { get; set; } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index bd028c52d8..7cddb83c13 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -2,6 +2,8 @@ using System.Text.Json.Nodes; using System.Text.Json; using System.Diagnostics; +using System.Net; +using System.Net.Sockets; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Indexer; @@ -527,7 +529,8 @@ public void Initialize_AdvertisesResourcesAndPrompts() Assert.False(capabilities["resources"]!["listChanged"]!.GetValue()); Assert.False(capabilities["prompts"]!["listChanged"]!.GetValue()); Assert.NotNull(capabilities["logging"]); - Assert.Null(capabilities["sampling"]); + Assert.True(capabilities["roots"]!["listChanged"]!.GetValue()); + Assert.NotNull(capabilities["sampling"]); } [Fact] @@ -2384,6 +2387,21 @@ public async Task RunAsync_StdioTransportWriteFailure_DoesNotThrow() Assert.Equal(1, transport.WriteCount); } + [Fact] + public async Task HttpTransport_WriteOutOfBandFrameAsync_WithoutEventStream_IsBestEffort() + { + var port = AllocateLoopbackPort(); + await using var transport = new HttpMcpTransport( + $"http://127.0.0.1:{port}/", + "127.0.0.1", + port, + bearerToken: null); + + await transport.WriteOutOfBandFrameAsync("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", CancellationToken.None); + + Assert.False(transport.HasEventStreams); + } + [Fact] public void Ping_ReturnsEmptyResult() { @@ -9154,6 +9172,155 @@ public void SuggestImprovement_RecordsClientAttributionFromInitialize() Assert.Equal("Investigating suggestion triage", stored.ToolInvocationContext); } + [Fact] + public void SuggestImprovement_WhenSamplingAvailable_StoresSampledMetadata() + { + _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"sampling":{}}}}""")!); + _server.ClientRequestHandlerForTests = (method, _) => + { + Assert.Equal("sampling/createMessage", method); + return new JsonObject + { + ["content"] = new JsonObject + { + ["type"] = "text", + ["text"] = """{"title":"Improve TypeScript arrow symbol extraction","tags":["symbol_extraction","typescript","ranking"]}""" + } + }; + }; + var uniqueDesc = $"TypeScript arrow symbols need clearer extraction {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"] = "symbol_extraction", + ["language"] = "typescript", + ["description"] = uniqueDesc, + } + } + }; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + 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() + .Single(s => s.Description == uniqueDesc); + Assert.Equal("Improve TypeScript arrow symbol extraction", stored.SampledTitle); + Assert.Contains("symbol_extraction", stored.SampledTags!); + } + + [Fact] + public void SuggestImprovement_WhenSamplingDisabled_DoesNotCallClientSampling() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_SAMPLING"); + env.Set("CDIDX_MCP_SAMPLING", "0"); + _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 opt-out 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)!; + + Assert.False(called); + Assert.Equal("recorded", response["result"]!["structuredContent"]!["status"]!.GetValue()); + Assert.Null(response["result"]!["structuredContent"]!["sampled_title"]); + } + + [Fact] + public void Index_WhenClientRootsExcludePath_ReturnsError() + { + var requestedMethods = new List(); + _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"roots":{}}}}""")!); + _server.ClientRequestHandlerForTests = (method, _) => + { + requestedMethods.Add(method); + return new JsonObject + { + ["roots"] = new JsonArray(new JsonObject { ["uri"] = "file:///tmp/cdidx-not-this-workspace" }) + }; + }; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"index","arguments":{"path":"."}}}""")!; + + var response = _server.HandleMessage(request)!; + + Assert.Contains("roots/list", requestedMethods); + Assert.True(response["result"]!["isError"]!.GetValue()); + Assert.Contains("MCP client root", response["result"]!["content"]![0]!["text"]!.GetValue()); + } + + [Fact] + public void Index_WhenClientRootsAreEmpty_ReturnsError() + { + var requestedMethods = new List(); + _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"roots":{}}}}""")!); + _server.ClientRequestHandlerForTests = (method, _) => + { + requestedMethods.Add(method); + return new JsonObject { ["roots"] = new JsonArray() }; + }; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"index","arguments":{"path":"."}}}""")!; + + var response = _server.HandleMessage(request)!; + + Assert.Contains("roots/list", requestedMethods); + Assert.True(response["result"]!["isError"]!.GetValue()); + Assert.Contains("MCP client root", response["result"]!["content"]![0]!["text"]!.GetValue()); + } + + [Fact] + public void Index_WhenClientRootsHaveNoFileRoots_ReturnsError() + { + var requestedMethods = new List(); + _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"roots":{}}}}""")!); + _server.ClientRequestHandlerForTests = (method, _) => + { + requestedMethods.Add(method); + return new JsonObject + { + ["roots"] = new JsonArray(new JsonObject { ["uri"] = "https://example.com/workspace" }) + }; + }; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"index","arguments":{"path":"."}}}""")!; + + var response = _server.HandleMessage(request)!; + + Assert.Contains("roots/list", requestedMethods); + Assert.True(response["result"]!["isError"]!.GetValue()); + Assert.Contains("MCP client root", response["result"]!["content"]![0]!["text"]!.GetValue()); + } + [Fact] public void SuggestImprovement_DuplicateSubmission_ReturnsDuplicate() { @@ -10457,6 +10624,20 @@ private string CallToolAndReadErrorMessage(string toolName, JsonObject arguments return response["result"]!["content"]!.AsArray()[0]!["text"]!.GetValue(); } + private static int AllocateLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + private static void RaiseConsoleCancelKeyPress() { // Console.CancelKeyPress is exposed as a public event but its backing delegate field is