Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <name>`. `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` |
Expand Down Expand Up @@ -3920,6 +3924,10 @@ stdio トランスポートはバイト単位で挙動が変わらないため

両方指定された場合は allowlist が優先されます。`tools/list` は有効ツールのみ広告し、`initialize` の instructions 文字列も無効化されたツールを推奨しなくなります。トップレベル `tools/call` で無効化された既知ツールを呼び出した場合は、構造化された JSON-RPC エラー `-32601 Tool not enabled: <name>` を返します。`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` |
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/unreleased/1686.added.md
Original file line number Diff line number Diff line change
@@ -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 非対応時は従来経路にフォールバックします。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1687.fixed.md
Original file line number Diff line number Diff line change
@@ -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 判定だけに頼らず拒否します。
20 changes: 19 additions & 1 deletion src/CodeIndex/Mcp/HttpMcpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ internal HttpMcpTransport(string prefix, string host, int boundPort, string? bea

internal Func<string>? KeepAliveFrameProvider { get; set; }

internal bool HasEventStreams => !_eventStreams.IsEmpty;

/// <summary>
/// Resolve a `host:port` listen spec into the corresponding HTTP prefix. Ephemeral ports
/// (port `0`) are resolved up-front by binding a temporary <see cref="TcpListener"/> so the
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
134 changes: 132 additions & 2 deletions src/CodeIndex/Mcp/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ namespace CodeIndex.Mcp;
/// </summary>
public partial class McpServer : IDisposable
{
private static int s_nextClientRequestId;
private readonly string _dbPath;
private readonly bool _dbPathExplicit;
private readonly string _version;
Expand Down Expand Up @@ -54,6 +55,7 @@ public partial class McpServer : IDisposable
// JSON-RPC request id ごとの実行中 CTS。MCP `$/cancelRequest` 通知でサーバー全体ではなく
// 対象ツール呼び出しだけを cancel するため (#1418)。
private readonly ConcurrentDictionary<string, CancellationTokenSource> _activeRequests = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, TaskCompletionSource<JsonNode?>> _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).
Expand All @@ -63,11 +65,13 @@ public partial class McpServer : IDisposable
private readonly AsyncLocal<CancellationToken> _currentRequestToken = new();
private readonly AsyncLocal<bool> _isolateDbForCurrentRequest = new();
private readonly AsyncLocal<Action<string>?> _currentOutOfBandFrameWriter = new();
private readonly AsyncLocal<bool> _canAwaitClientResponses = new();
private readonly AsyncLocal<List<Action>?> _deferredFrameLogs = new();
private static readonly AsyncLocal<RequestCorrelationContext?> 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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -489,13 +494,16 @@ 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);
}
finally
{
_currentRequestToken.Value = CancellationToken.None;
_currentOutOfBandFrameWriter.Value = null;
_canAwaitClientResponses.Value = false;
_concurrencyGate.Release();
}

Expand Down Expand Up @@ -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 () =>
{
Expand All @@ -600,6 +625,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella
try
{
_currentRequestToken.Value = loopToken;
_canAwaitClientResponses.Value = true;
_currentOutOfBandFrameWriter.Value = frameToWrite =>
{
writeGate.Wait(loopToken);
Expand All @@ -618,6 +644,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella
finally
{
_currentRequestToken.Value = CancellationToken.None;
_canAwaitClientResponses.Value = false;
_currentOutOfBandFrameWriter.Value = null;
normalFrameGate.Release();
}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<JsonNode?> 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<JsonNode?>(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<JsonNode?> 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 ||
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -1698,6 +1826,8 @@ private void CaptureClientSession(JsonNode? initializeParams)

internal string McpLogLevelForTests => _mcpLogLevel;

internal Func<string, JsonObject?, JsonNode?>? ClientRequestHandlerForTests { get; set; }

private static string? TryReadStringMember(JsonObject obj, string key)
{
if (!obj.TryGetPropertyValue(key, out var node))
Expand Down Expand Up @@ -2213,7 +2343,7 @@ private async Task<JsonNode> 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}",
Expand Down
Loading
Loading