From 75bb59eb2a874e94882fb0f37595e91f9cee2e7d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:05:11 +0900 Subject: [PATCH 1/7] Cap MCP auth token lengths (#3096) --- changelog.d/unreleased/3096.security.md | 18 ++++++++ src/CodeIndex/Mcp/HttpMcpTransport.cs | 22 +++++++-- src/CodeIndex/Mcp/McpAuthentication.cs | 45 +++++++++++++------ .../CodeIndex.Tests/HttpMcpTransportTests.cs | 22 +++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 26 +++++++++++ 5 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 changelog.d/unreleased/3096.security.md diff --git a/changelog.d/unreleased/3096.security.md b/changelog.d/unreleased/3096.security.md new file mode 100644 index 0000000000..d225a2cd9e --- /dev/null +++ b/changelog.d/unreleased/3096.security.md @@ -0,0 +1,18 @@ +--- +category: security +issues: + - 3096 +affected: + - src/CodeIndex/Mcp/McpAuthentication.cs + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **MCP token authentication now rejects oversized tokens before hashing (#3096)** — stdio auth tokens and HTTP bearer tokens now share a fixed length cap so hostile clients cannot force unbounded token hashing work. + +## 日本語 + +- **MCP token 認証が oversized token を hash 前に拒否するようになりました (#3096)** — stdio auth token と HTTP bearer token に共通の長さ上限を設け、悪意あるクライアントが無制限の token hash 処理を強制できないようにしました。 diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 8f414ddd40..25bbb50abc 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -30,6 +30,7 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport internal const int DefaultMaxQueuedRequests = 64; internal const string MaxRequestBodyBytesEnvVar = "CDIDX_MCP_HTTP_MAX_REQUEST_BYTES"; internal const string MaxQueueDepthEnvVar = "CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH"; + private const string BearerPrefix = "Bearer "; private static readonly JsonDocumentOptions HttpProbeJsonDocumentOptions = new() { @@ -84,6 +85,8 @@ internal HttpMcpTransport( FullMode = BoundedChannelFullMode.Wait, AllowSynchronousContinuations = false, }); + if (McpAuthenticationLimits.IsTokenOversized(bearerToken)) + throw new ArgumentException($"Token must not exceed {McpAuthenticationLimits.MaxTokenCharacters.ToString(CultureInfo.InvariantCulture)} characters.", nameof(bearerToken)); _listener = new HttpListener(); _listener.Prefixes.Add(prefix); _listener.Start(); @@ -523,10 +526,9 @@ private async Task TryAuthorizeAsync(PendingRequest request) { request.AuthOutcome = "missing"; } - else if (header.Length >= "Bearer ".Length && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + else if (TryExtractBearerToken(header, out var provided)) { - var provided = header.Substring("Bearer ".Length).Trim(); - if (HashEqualsConfiguredToken(provided)) + if (provided is not null && HashEqualsConfiguredToken(provided)) { request.AuthOutcome = "ok"; return true; @@ -550,6 +552,20 @@ private async Task TryAuthorizeAsync(PendingRequest request) return false; } + private static bool TryExtractBearerToken(string header, out string? token) + { + token = null; + if (header.Length < BearerPrefix.Length || !header.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) + return false; + + var candidate = header.AsSpan(BearerPrefix.Length).Trim(); + if (candidate.Length > McpAuthenticationLimits.MaxTokenCharacters) + return true; + + token = candidate.ToString(); + return true; + } + private static async Task RespondAsync(HttpListenerContext context, int statusCode, string body) { try diff --git a/src/CodeIndex/Mcp/McpAuthentication.cs b/src/CodeIndex/Mcp/McpAuthentication.cs index 65ffc990d2..9a6610c5b4 100644 --- a/src/CodeIndex/Mcp/McpAuthentication.cs +++ b/src/CodeIndex/Mcp/McpAuthentication.cs @@ -44,6 +44,15 @@ public sealed record McpAuthenticationResult(McpCallerIdentity? Identity, string public static McpAuthenticationResult Deny(string reason) => new(null, reason); } +internal static class McpAuthenticationLimits +{ + internal const int MaxTokenCharacters = 4096; + internal const string OversizedTokenFailureReason = "auth token mismatch"; + + internal static bool IsTokenOversized(string? token) + => token is { Length: > MaxTokenCharacters }; +} + /// /// Per-request authentication strategy for the MCP server. Implementations look at the /// incoming JSON-RPC request envelope and produce an identity or a failure reason. The @@ -97,19 +106,22 @@ public McpAuthenticationResult Authenticate(JsonNode request) /// ; /// equal-length inputs keep the compare on its constant-time path and the hash step erases /// the length-leak that the raw-byte form would otherwise have (FixedTimeEquals short-circuits -/// on length mismatch). The hash-and-compare runs unconditionally — even on a missing token — -/// so callers cannot distinguish "missing", "wrong length", and "wrong value" by timing. -/// Identity is stdio-token / token. Notifications skip the check upstream because -/// they produce no response and cannot be acted on. +/// on length mismatch). Tokens over the shared cap are denied before hashing so hostile +/// clients cannot force unbounded hashing work. For in-cap inputs, the hash-and-compare runs +/// unconditionally — even on a missing token — so callers cannot distinguish "missing", +/// "wrong length", and "wrong value" by timing. Identity is stdio-token / +/// token. Notifications skip the check upstream because they produce no response and +/// cannot be acted on. /// トークン認証 authenticator。各 JSON-RPC リクエストは params.auth.token に /// 一致するトークンを含む必要がある。期待トークンは固定長 (SHA-256 32 バイト) のダイジェスト /// として保持し、提示トークンも同じ長さにハッシュしてから /// /// で比較する。これにより比較は常に等長で定数時間パスに留まり、生バイト比較なら漏れる長さ情報も -/// ハッシュ段階で隠れる (FixedTimeEquals は長さ不一致で即 return する)。ハッシュ+比較は -/// トークン未提示でも必ず走らせるため、呼び出し元は「未提示」「長さ違い」「値違い」を時間差で -/// 区別できない。アイデンティティは stdio-token / token。通知は応答が -/// 無く副作用も持たないので呼び出し側でチェック前にスキップされる。 +/// ハッシュ段階で隠れる (FixedTimeEquals は長さ不一致で即 return する)。共通上限を超える token は +/// hash 前に拒否し、悪意あるクライアントが無制限の hash 処理を強制できないようにする。上限内の +/// 入力では、トークン未提示でも必ずハッシュ+比較を走らせるため、呼び出し元は「未提示」 +/// 「長さ違い」「値違い」を時間差で区別できない。アイデンティティは stdio-token / +/// token。通知は応答が無く副作用も持たないので呼び出し側でチェック前にスキップされる。 /// public sealed class TokenMcpAuthenticator : IMcpAuthenticator { @@ -124,6 +136,8 @@ public TokenMcpAuthenticator(string expectedToken) { if (string.IsNullOrEmpty(expectedToken)) throw new ArgumentException("Token must not be empty", nameof(expectedToken)); + if (McpAuthenticationLimits.IsTokenOversized(expectedToken)) + throw new ArgumentException($"Token must not exceed {McpAuthenticationLimits.MaxTokenCharacters} characters.", nameof(expectedToken)); _expectedTokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(expectedToken)); } @@ -147,12 +161,15 @@ public McpAuthenticationResult Authenticate(JsonNode request) presented = null; } - // Always hash and constant-time compare — even when no token was presented — so the - // missing / wrong-length / wrong-value branches all take the same path. Choosing the - // stderr `Deny` reason after the compare runs keeps the timing uniform; the wire - // response is "Unauthorized" either way. - // トークン未提示でも必ずハッシュ+定数時間比較を走らせる。Deny 理由の選択は比較後に - // 行い、stderr 用文字列だけ分岐させる(ワイヤ応答は常に "Unauthorized")。 + if (McpAuthenticationLimits.IsTokenOversized(presented)) + return McpAuthenticationResult.Deny(McpAuthenticationLimits.OversizedTokenFailureReason); + + // For in-cap inputs, always hash and constant-time compare — even when no token was + // presented — so the missing / wrong-length / wrong-value branches all take the same + // path. Choosing the stderr `Deny` reason after the compare runs keeps the timing + // uniform; the wire response is "Unauthorized" either way. + // 上限内の入力では、トークン未提示でも必ずハッシュ+定数時間比較を走らせる。Deny 理由の + // 選択は比較後に行い、stderr 用文字列だけ分岐させる(ワイヤ応答は常に "Unauthorized")。 var presentedHash = SHA256.HashData(Encoding.UTF8.GetBytes(presented ?? string.Empty)); var matches = CryptographicOperations.FixedTimeEquals(presentedHash, _expectedTokenHash); diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index d49a60b997..71df90eee1 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -534,6 +534,28 @@ public async Task HttpTransport_BearerToken_RejectsWrongToken() Assert.Contains(response.Headers.WwwAuthenticate, h => h.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task HttpTransport_BearerToken_RejectsOversizedHeaderBeforeHashing() + { + var records = new ConcurrentQueue(); + await using var harness = await McpHttpHarness.StartAsync(_dbPath, bearerToken: "token", requestLogger: records.Enqueue); + + using var client = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, harness.Endpoint) + { + Content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"ping"}""", Encoding.UTF8, "application/json"), + }; + request.Headers.TryAddWithoutValidation( + "Authorization", + "Bearer " + new string('x', McpAuthenticationLimits.MaxTokenCharacters + 1)); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var record = Assert.Single(await WaitForRequestLogRecordsAsync(records, 1)); + Assert.Equal("wrong-token", record.AuthOutcome); + } + [Fact] public async Task HttpTransport_BearerToken_RejectsSameLengthWrongToken() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 95eb0e1fef..c05b5ffab4 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1645,6 +1645,14 @@ public void TokenAuthenticator_EmptyTokenInCtor_Rejected() Assert.Throws(() => new TokenMcpAuthenticator(string.Empty)); } + [Fact] + public void TokenAuthenticator_OversizedTokenInCtor_RejectedBeforeHashing() + { + var oversized = new string('x', McpAuthenticationLimits.MaxTokenCharacters + 1); + + Assert.Throws(() => new TokenMcpAuthenticator(oversized)); + } + [Fact] public void McpAuthenticatorFactory_NoEnv_ReturnsLocalStdio() { @@ -1785,6 +1793,24 @@ public void TokenAuthenticator_WrongLengthToken_UniformWireResponse() Assert.Equal(-32001, shortResp["error"]!["code"]!.GetValue()); } + [Fact] + public void TokenAuthenticator_OversizedPresentedToken_ReturnsUnauthorized() + { + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion(), false, + new TokenMcpAuthenticator("s3cret")); + var oversized = new string('x', McpAuthenticationLimits.MaxTokenCharacters + 1); + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"ping","params":{"auth":{"token":""" + + JsonSerializer.Serialize(oversized) + + "}}}")!; + + var response = server.HandleMessage(request)!; + + Assert.Null(response["result"]); + Assert.Equal(-32001, response["error"]!["code"]!.GetValue()); + Assert.Equal("Unauthorized", response["error"]!["message"]!.GetValue()); + } + [Fact] public void McpAuthenticatorFactory_TokenSet_ReturnsTokenAuthenticator() { From 95f8b241ffba16d7c5bc918e41b3ca2041434430 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:14:50 +0900 Subject: [PATCH 2/7] Cap HTTP MCP request log metadata (#3178) --- changelog.d/unreleased/3178.security.md | 17 ++++++++++++ src/CodeIndex/Cli/ProgramRunner.cs | 5 ++-- src/CodeIndex/Mcp/HttpMcpTransport.cs | 17 +++++++++--- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 27 +++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/3178.security.md diff --git a/changelog.d/unreleased/3178.security.md b/changelog.d/unreleased/3178.security.md new file mode 100644 index 0000000000..401ba4fac1 --- /dev/null +++ b/changelog.d/unreleased/3178.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3178 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP request logs now cap request metadata fields (#3178)** — method, path, and remote peer values are truncated with a marker before persistent request logging so oversized client metadata cannot inflate tool logs. + +## 日本語 + +- **HTTP MCP request log が request metadata field を上限付きで記録するようになりました (#3178)** — method、path、remote peer を永続 request log に書く前に marker 付きで切り詰め、巨大な client metadata が tool log を肥大化させないようにしました。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 8425d7d039..f92ada5a95 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -2529,10 +2529,11 @@ private static void LogHttpMcpRequest(HttpMcpTransport.HttpRequestLogRecord reco private static string FormatLogValue(string? value) { - if (string.IsNullOrEmpty(value)) + var limited = HttpMcpTransport.LimitRequestLogField(value); + if (string.IsNullOrEmpty(limited)) return "-"; - return value + return limited .Replace('\\', '/') .Replace('\r', '_') .Replace('\n', '_') diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 25bbb50abc..c6a9c66363 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -28,6 +28,8 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport { internal const int DefaultMaxRequestBodyBytes = 1_000_000; internal const int DefaultMaxQueuedRequests = 64; + internal const int MaxRequestLogFieldCharacters = 256; + internal const string RequestLogTruncationMarker = "..."; internal const string MaxRequestBodyBytesEnvVar = "CDIDX_MCP_HTTP_MAX_REQUEST_BYTES"; internal const string MaxQueueDepthEnvVar = "CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH"; private const string BearerPrefix = "Bearer "; @@ -759,9 +761,18 @@ private PendingRequest BeginRequest(HttpListenerContext context) return new PendingRequest( context, Guid.NewGuid().ToString("N"), - remotePeer, - context.Request.HttpMethod, - context.Request.Url?.AbsolutePath ?? "/"); + LimitRequestLogField(remotePeer) ?? "", + LimitRequestLogField(context.Request.HttpMethod) ?? string.Empty, + LimitRequestLogField(context.Request.Url?.AbsolutePath ?? "/") ?? "/"); + } + + internal static string? LimitRequestLogField(string? value) + { + if (value is null || value.Length <= MaxRequestLogFieldCharacters) + return value; + + return value.Substring(0, MaxRequestLogFieldCharacters - RequestLogTruncationMarker.Length) + + RequestLogTruncationMarker; } private void LogRequest(PendingRequest request, int statusCode) diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 71df90eee1..e0176a7a42 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -191,6 +191,33 @@ public async Task HttpTransport_RequestLogger_RecordsMethodStatusDurationAndAuth Assert.Equal((int)HttpStatusCode.OK, okPost.StatusCode); } + [Fact] + public void HttpTransport_RequestLogFieldLimiter_CapsMetadataFields() + { + var longValue = new string('x', HttpMcpTransport.MaxRequestLogFieldCharacters + 100); + + var limited = HttpMcpTransport.LimitRequestLogField(longValue); + + Assert.NotNull(limited); + Assert.Equal(HttpMcpTransport.MaxRequestLogFieldCharacters, limited.Length); + Assert.EndsWith(HttpMcpTransport.RequestLogTruncationMarker, limited, StringComparison.Ordinal); + } + + [Fact] + public async Task HttpTransport_RequestLogger_CapsLongPathBeforeLogging() + { + var records = new ConcurrentQueue(); + await using var harness = await McpHttpHarness.StartAsync(_dbPath, requestLogger: records.Enqueue); + + using var client = new HttpClient(); + using var response = await client.GetAsync(new Uri(new Uri(harness.Endpoint), new string('p', HttpMcpTransport.MaxRequestLogFieldCharacters + 100))); + + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + var record = Assert.Single(await WaitForRequestLogRecordsAsync(records, 1)); + Assert.Equal(HttpMcpTransport.MaxRequestLogFieldCharacters, record.Path.Length); + Assert.EndsWith(HttpMcpTransport.RequestLogTruncationMarker, record.Path, StringComparison.Ordinal); + } + [Fact] public async Task HttpTransport_TwoSequentialRequests_ShareWarmServer() { From d8154b5889cc642cee84b85823235e1c63cfb9c4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:23:41 +0900 Subject: [PATCH 3/7] Cap HTTP MCP request IDs in logs (#3114) --- changelog.d/unreleased/3114.security.md | 16 ++++++++++++++++ src/CodeIndex/Mcp/HttpMcpTransport.cs | 3 ++- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3114.security.md diff --git a/changelog.d/unreleased/3114.security.md b/changelog.d/unreleased/3114.security.md new file mode 100644 index 0000000000..bcb6c633ff --- /dev/null +++ b/changelog.d/unreleased/3114.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3114 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP request logging now caps extracted JSON-RPC ids (#3114)** — request ids are truncated with a marker before they are stored in request log records, preventing oversized ids from inflating persistent logs. + +## 日本語 + +- **HTTP MCP request logging が抽出した JSON-RPC id を上限付きで保持するようになりました (#3114)** — request id を request log record に保存する前に marker 付きで切り詰め、巨大な id が永続 log を肥大化させないようにしました。 diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index c6a9c66363..742df97c94 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -811,12 +811,13 @@ private void LogRequest(PendingRequest request, int statusCode) if (!doc.RootElement.TryGetProperty("id", out var id)) return null; - return id.ValueKind switch + var requestId = id.ValueKind switch { JsonValueKind.String => id.GetString(), JsonValueKind.Number => id.GetRawText(), _ => id.GetRawText(), }; + return LimitRequestLogField(requestId); } catch (JsonException) { diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index e0176a7a42..dfbf34dbb6 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -218,6 +218,25 @@ public async Task HttpTransport_RequestLogger_CapsLongPathBeforeLogging() Assert.EndsWith(HttpMcpTransport.RequestLogTruncationMarker, record.Path, StringComparison.Ordinal); } + [Fact] + public async Task HttpTransport_RequestLogger_CapsLongJsonRpcIdBeforeLogging() + { + var records = new ConcurrentQueue(); + await using var harness = await McpHttpHarness.StartAsync(_dbPath, requestLogger: records.Enqueue); + var oversizedId = new string('i', HttpMcpTransport.MaxRequestLogFieldCharacters + 100); + var body = """{"jsonrpc":"2.0","id":""" + + JsonSerializer.Serialize(oversizedId) + + ""","method":"ping"}"""; + + using var response = await harness.PostJsonAsync(body); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var record = Assert.Single(await WaitForRequestLogRecordsAsync(records, 1)); + Assert.NotNull(record.RequestId); + Assert.Equal(HttpMcpTransport.MaxRequestLogFieldCharacters, record.RequestId.Length); + Assert.EndsWith(HttpMcpTransport.RequestLogTruncationMarker, record.RequestId, StringComparison.Ordinal); + } + [Fact] public async Task HttpTransport_TwoSequentialRequests_ShareWarmServer() { From 388ea836c74ad301acf6199466d34f3f0984328d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:29:31 +0900 Subject: [PATCH 4/7] Bound HTTP MCP handler concurrency (#3003) --- changelog.d/unreleased/3003.security.md | 17 ++++++++ src/CodeIndex/Cli/ProgramRunner.cs | 2 +- src/CodeIndex/Mcp/HttpMcpTransport.cs | 42 +++++++++++++++++-- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 24 +++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/3003.security.md diff --git a/changelog.d/unreleased/3003.security.md b/changelog.d/unreleased/3003.security.md new file mode 100644 index 0000000000..3b43fa248e --- /dev/null +++ b/changelog.d/unreleased/3003.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3003 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP now bounds concurrent request handlers (#3003)** — accepted HTTP contexts are gated by a configurable handler semaphore, and extra requests receive 429/Retry-After instead of creating unbounded handler tasks. + +## 日本語 + +- **HTTP MCP が同時 request handler 数を制限するようになりました (#3003)** — accepted HTTP context を設定可能な handler semaphore で制御し、上限超過時は無制限に handler task を作らず 429/Retry-After を返します。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index f92ada5a95..d8eca3ecce 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -2545,7 +2545,7 @@ private static void PrintMcpUsage() { Console.Error.WriteLine("Usage: cdidx mcp [--db ] [--transport stdio|http] [--http-listen ] [--audit-log ] [--audit-log-include-values] [--audit-log-max-bytes ] [--suggestion-dedup-threshold <0..1>]"); Console.Error.WriteLine("Note: --json is not supported; MCP requests and responses are JSON-RPC over the selected transport."); - Console.Error.WriteLine($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)})."); + Console.Error.WriteLine($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxConcurrentHandlersEnvVar}= (default {HttpMcpTransport.DefaultMaxConcurrentHandlers.ToString(CultureInfo.InvariantCulture)})."); } internal static bool TryConsumeSuggestionDedupThresholdFlag(ref string[] args, out string error) diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 742df97c94..6b0b11962f 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -28,10 +28,12 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport { internal const int DefaultMaxRequestBodyBytes = 1_000_000; internal const int DefaultMaxQueuedRequests = 64; + internal const int DefaultMaxConcurrentHandlers = 64; internal const int MaxRequestLogFieldCharacters = 256; internal const string RequestLogTruncationMarker = "..."; internal const string MaxRequestBodyBytesEnvVar = "CDIDX_MCP_HTTP_MAX_REQUEST_BYTES"; internal const string MaxQueueDepthEnvVar = "CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH"; + internal const string MaxConcurrentHandlersEnvVar = "CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS"; private const string BearerPrefix = "Bearer "; private static readonly JsonDocumentOptions HttpProbeJsonDocumentOptions = new() @@ -46,8 +48,10 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport private readonly ConcurrentDictionary _eventStreams = new(); private readonly CancellationTokenSource _acceptCts = new(); private readonly Channel _requestQueue; + private readonly SemaphoreSlim _handlerSemaphore; private readonly int _maxRequestBodyBytes; private readonly int _maxQueuedRequests; + private readonly int _maxConcurrentHandlers; private readonly Task _acceptLoop; // The configured bearer token's SHA-256 digest, precomputed once at construction so the // per-request auth path never hashes the secret. Storing the digest (not the token) keeps the @@ -76,10 +80,12 @@ internal HttpMcpTransport( string? bearerToken, Action? requestLogger = null, int? maxRequestBodyBytes = null, - int? maxQueuedRequests = null) + int? maxQueuedRequests = null, + int? maxConcurrentHandlers = null) { _maxRequestBodyBytes = ResolvePositiveIntOption(maxRequestBodyBytes, MaxRequestBodyBytesEnvVar, DefaultMaxRequestBodyBytes); _maxQueuedRequests = ResolvePositiveIntOption(maxQueuedRequests, MaxQueueDepthEnvVar, DefaultMaxQueuedRequests); + _maxConcurrentHandlers = ResolvePositiveIntOption(maxConcurrentHandlers, MaxConcurrentHandlersEnvVar, DefaultMaxConcurrentHandlers); _requestQueue = Channel.CreateBounded(new BoundedChannelOptions(_maxQueuedRequests) { SingleReader = true, @@ -89,6 +95,7 @@ internal HttpMcpTransport( }); if (McpAuthenticationLimits.IsTokenOversized(bearerToken)) throw new ArgumentException($"Token must not exceed {McpAuthenticationLimits.MaxTokenCharacters.ToString(CultureInfo.InvariantCulture)} characters.", nameof(bearerToken)); + _handlerSemaphore = new SemaphoreSlim(_maxConcurrentHandlers, _maxConcurrentHandlers); _listener = new HttpListener(); _listener.Prefixes.Add(prefix); _listener.Start(); @@ -120,6 +127,8 @@ internal HttpMcpTransport( internal int MaxQueuedRequests => _maxQueuedRequests; + internal int MaxConcurrentHandlers => _maxConcurrentHandlers; + internal int QueuedRequestCount => Volatile.Read(ref _queuedRequestCount); /// @@ -269,7 +278,13 @@ private async Task AcceptLoopAsync(CancellationToken cancellationToken) break; } - _ = Task.Run(() => HandleContextAsync(context, cancellationToken), CancellationToken.None); + if (!_handlerSemaphore.Wait(0)) + { + await RejectHandlerLimitAsync(context).ConfigureAwait(false); + continue; + } + + _ = Task.Run(() => RunHandlerAsync(context, cancellationToken), CancellationToken.None); } } finally @@ -278,6 +293,27 @@ private async Task AcceptLoopAsync(CancellationToken cancellationToken) } } + private async Task RunHandlerAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + try + { + await HandleContextAsync(context, cancellationToken).ConfigureAwait(false); + } + finally + { + _handlerSemaphore.Release(); + } + } + + private async Task RejectHandlerLimitAsync(HttpListenerContext context) + { + var request = BeginRequest(context); + request.AuthOutcome = "not-checked"; + context.Response.AddHeader("Retry-After", "1"); + await RespondAsync(context, (int)HttpStatusCode.TooManyRequests, "MCP HTTP concurrent handler limit is full.\n").ConfigureAwait(false); + LogRequest(request, (int)HttpStatusCode.TooManyRequests); + } + private async Task HandleContextAsync(HttpListenerContext context, CancellationToken cancellationToken) { var request = BeginRequest(context); @@ -311,7 +347,7 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT return; } - _ = Task.Run(() => RunEventStreamAsync(request, cancellationToken), CancellationToken.None); + await RunEventStreamAsync(request, cancellationToken).ConfigureAwait(false); return; } diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index dfbf34dbb6..380e941dce 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -412,6 +412,30 @@ public async Task HttpTransport_RequestQueueFull_Returns429() Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); } + [Fact] + public async Task HttpTransport_ConcurrentHandlerLimit_Returns429() + { + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + await using var transport = new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null, + maxConcurrentHandlers: 1); + + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var events = await client.GetAsync(new Uri(new Uri(listen.Prefix), "events"), HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, events.StatusCode); + await WaitUntilAsync(() => transport.HasEventStreams, "the first event stream to occupy the only handler slot"); + + using var second = await client.PostAsync( + listen.Prefix, + new StringContent("""{"jsonrpc":"2.0","id":2,"method":"ping"}""", Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode); + Assert.Contains(second.Headers, header => header.Key == "Retry-After"); + } + [Fact] public async Task HttpTransport_EventsStream_DoesNotBlockPostRequests() { From ccf2d508c4258a54d5e0222cf9745e15f1028618 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:34:34 +0900 Subject: [PATCH 5/7] Cap HTTP MCP event streams (#3158) --- changelog.d/unreleased/3158.security.md | 17 +++++++++++++ src/CodeIndex/Cli/ProgramRunner.cs | 2 +- src/CodeIndex/Mcp/HttpMcpTransport.cs | 24 +++++++++++++++++-- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 22 +++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/3158.security.md diff --git a/changelog.d/unreleased/3158.security.md b/changelog.d/unreleased/3158.security.md new file mode 100644 index 0000000000..2fffb810f8 --- /dev/null +++ b/changelog.d/unreleased/3158.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3158 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP `/events` streams now have a concurrent stream cap (#3158)** — extra SSE clients are rejected with 429/Retry-After once the configured stream limit is reached. + +## 日本語 + +- **HTTP MCP `/events` stream に同時接続上限を追加しました (#3158)** — 設定された stream 上限に達した後の追加 SSE client は 429/Retry-After で拒否されます。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index d8eca3ecce..70624c1064 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -2545,7 +2545,7 @@ private static void PrintMcpUsage() { Console.Error.WriteLine("Usage: cdidx mcp [--db ] [--transport stdio|http] [--http-listen ] [--audit-log ] [--audit-log-include-values] [--audit-log-max-bytes ] [--suggestion-dedup-threshold <0..1>]"); Console.Error.WriteLine("Note: --json is not supported; MCP requests and responses are JSON-RPC over the selected transport."); - Console.Error.WriteLine($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxConcurrentHandlersEnvVar}= (default {HttpMcpTransport.DefaultMaxConcurrentHandlers.ToString(CultureInfo.InvariantCulture)})."); + Console.Error.WriteLine($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxConcurrentHandlersEnvVar}= (default {HttpMcpTransport.DefaultMaxConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxEventStreamsEnvVar}= (default {HttpMcpTransport.DefaultMaxEventStreams.ToString(CultureInfo.InvariantCulture)})."); } internal static bool TryConsumeSuggestionDedupThresholdFlag(ref string[] args, out string error) diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 6b0b11962f..eaf7046265 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -29,11 +29,13 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport internal const int DefaultMaxRequestBodyBytes = 1_000_000; internal const int DefaultMaxQueuedRequests = 64; internal const int DefaultMaxConcurrentHandlers = 64; + internal const int DefaultMaxEventStreams = 16; internal const int MaxRequestLogFieldCharacters = 256; internal const string RequestLogTruncationMarker = "..."; internal const string MaxRequestBodyBytesEnvVar = "CDIDX_MCP_HTTP_MAX_REQUEST_BYTES"; internal const string MaxQueueDepthEnvVar = "CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH"; internal const string MaxConcurrentHandlersEnvVar = "CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS"; + internal const string MaxEventStreamsEnvVar = "CDIDX_MCP_HTTP_MAX_EVENT_STREAMS"; private const string BearerPrefix = "Bearer "; private static readonly JsonDocumentOptions HttpProbeJsonDocumentOptions = new() @@ -52,6 +54,7 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport private readonly int _maxRequestBodyBytes; private readonly int _maxQueuedRequests; private readonly int _maxConcurrentHandlers; + private readonly int _maxEventStreams; private readonly Task _acceptLoop; // The configured bearer token's SHA-256 digest, precomputed once at construction so the // per-request auth path never hashes the secret. Storing the digest (not the token) keeps the @@ -62,6 +65,7 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport private readonly byte[]? _bearerTokenHash; private PendingRequest? _pendingRequest; private int _queuedRequestCount; + private int _eventStreamCount; private bool _disposed; /// @@ -81,11 +85,13 @@ internal HttpMcpTransport( Action? requestLogger = null, int? maxRequestBodyBytes = null, int? maxQueuedRequests = null, - int? maxConcurrentHandlers = null) + int? maxConcurrentHandlers = null, + int? maxEventStreams = null) { _maxRequestBodyBytes = ResolvePositiveIntOption(maxRequestBodyBytes, MaxRequestBodyBytesEnvVar, DefaultMaxRequestBodyBytes); _maxQueuedRequests = ResolvePositiveIntOption(maxQueuedRequests, MaxQueueDepthEnvVar, DefaultMaxQueuedRequests); _maxConcurrentHandlers = ResolvePositiveIntOption(maxConcurrentHandlers, MaxConcurrentHandlersEnvVar, DefaultMaxConcurrentHandlers); + _maxEventStreams = ResolvePositiveIntOption(maxEventStreams, MaxEventStreamsEnvVar, DefaultMaxEventStreams); _requestQueue = Channel.CreateBounded(new BoundedChannelOptions(_maxQueuedRequests) { SingleReader = true, @@ -121,7 +127,7 @@ internal HttpMcpTransport( internal Func? KeepAliveFrameProvider { get; set; } - internal bool HasEventStreams => !_eventStreams.IsEmpty; + internal bool HasEventStreams => EventStreamCount > 0; internal int MaxRequestBodyBytes => _maxRequestBodyBytes; @@ -129,8 +135,12 @@ internal HttpMcpTransport( internal int MaxConcurrentHandlers => _maxConcurrentHandlers; + internal int MaxEventStreams => _maxEventStreams; + internal int QueuedRequestCount => Volatile.Read(ref _queuedRequestCount); + internal int EventStreamCount => Volatile.Read(ref _eventStreamCount); + /// /// 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 @@ -647,6 +657,15 @@ private static bool IsHealthPath(string? path) private async Task RunEventStreamAsync(PendingRequest request, CancellationToken cancellationToken) { var context = request.Context; + if (Interlocked.Increment(ref _eventStreamCount) > _maxEventStreams) + { + Interlocked.Decrement(ref _eventStreamCount); + context.Response.AddHeader("Retry-After", "1"); + await RespondAsync(context, (int)HttpStatusCode.TooManyRequests, "MCP HTTP event stream limit is full.\n").ConfigureAwait(false); + LogRequest(request, (int)HttpStatusCode.TooManyRequests); + return; + } + var streamId = Guid.NewGuid(); var stream = new EventStream(context.Response); try @@ -671,6 +690,7 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken finally { _eventStreams.TryRemove(streamId, out _); + Interlocked.Decrement(ref _eventStreamCount); LogRequest(request, (int)HttpStatusCode.OK); try { context.Response.Close(); } catch { /* ignore */ } } diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 380e941dce..58b333d082 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -455,6 +455,28 @@ public async Task HttpTransport_EventsStream_DoesNotBlockPostRequests() Assert.Equal(11, doc.RootElement.GetProperty("id").GetInt32()); } + [Fact] + public async Task HttpTransport_EventsStreamLimit_Returns429() + { + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + await using var transport = new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null, + maxEventStreams: 1); + + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var first = await client.GetAsync(new Uri(new Uri(listen.Prefix), "events"), HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + await WaitUntilAsync(() => transport.EventStreamCount == 1, "the first event stream to fill the stream limit"); + + using var second = await client.GetAsync(new Uri(new Uri(listen.Prefix), "events"), HttpCompletionOption.ResponseHeadersRead); + + Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode); + Assert.Contains(second.Headers, header => header.Key == "Retry-After"); + } + [Fact] public async Task HttpTransport_EventsStream_EmitsOptInKeepAliveNotifications() { From 3e61a6a86b0b5ff2a80bff5dbdb1428fed6af320 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:41:05 +0900 Subject: [PATCH 6/7] Document HTTP MCP safety limits (#3003 #3158 #3096 #3178 #3114) --- DEVELOPER_GUIDE.md | 27 +++++++++++++++++---------- USER_GUIDE.md | 8 ++++---- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 9c32f790ca..131eb1b526 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1814,9 +1814,14 @@ return `-32600`. bodies return `413 Payload Too Large` before they are fully buffered. The pending request queue is bounded by `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH` (default: 64); full queues return `429 Too Many Requests` with - `Retry-After: 1` instead of retaining unbounded work. -- SSE stream lifetime is represented by the active stream registry only; - completed stream tasks are not retained after that registry entry is removed. + `Retry-After: 1` instead of retaining unbounded work. Accepted context + handler tasks are bounded by `CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS` + (default: 64), and concurrent `/events` streams are bounded by + `CDIDX_MCP_HTTP_MAX_EVENT_STREAMS` (default: 16); saturated limits return + `429 Too Many Requests` with `Retry-After: 1`. +- SSE stream lifetime is represented by the active stream registry and a + bounded active-stream counter only; completed stream tasks are not retained + after that registry entry is removed. - `ResolveListenSpec("host:port")` resolves the prefix up-front so the CLI can log the bound port to stderr (`Listening on http://...`). Port `0` is resolved by probing a temporary `TcpListener`; the @@ -1827,13 +1832,15 @@ return `-32600`. listener requires `Authorization: Bearer ` on every request and compares the token in constant time. The CLI refuses to bind to a non-loopback host without a token to keep the MCP catalog off the - LAN by default. + LAN by default. Configured and supplied tokens over 4096 characters are + rejected before hashing. - Optional request-loop logging: `ProgramRunner` connects `HttpMcpTransport` to `GlobalToolLog`, so persistent logging records one `mcp_http_request` line per HTTP request when the lifecycle log is enabled. The record includes method, path, status, duration, auth outcome, remote peer, correlation id, - and JSON-RPC request id when available; it never includes request or response - bodies. + and JSON-RPC request id when available; caller-controlled method, path, + remote peer, and request id values are capped at 256 characters with a + `...` marker, and it never includes request or response bodies. - Cancellation hooks the `CancellationToken` into `_listener.Stop()` so `GetContextAsync()` unblocks on shutdown; `HttpListenerException` / `ObjectDisposedException` are treated as @@ -3476,11 +3483,11 @@ MCP は独立したシリアライズ戦略(オブジェクトを JSON など `HttpMcpTransport`(同じく #1558)は `System.Net.HttpListener` をラップする: -- HTTP POST 1 件 = JSON-RPC フレーム 1 件で、対応する応答は HTTP レスポンスのボディ(`200 OK` / `application/json; charset=utf-8`)に乗る。通知は `204 No Content`。`GET /events` は将来のサーバー→クライアント frame 用に独立した `text/event-stream` subscription を開く。サーバーは `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification が opt-in された場合を除き、自発的な frame を送信しない。長寿命の event stream は通常の POST リクエストを塞がない。`/` への POST 以外は `405 Method Not Allowed`。空 / 空白のみのボディは stdio の空行と同じ扱いで `204 No Content` を返し、ループは殺さない — クライアントの誤動作で junk フレームに引っかからないため。リクエスト本文は `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`(既定: 1,000,000 bytes)で制限し、超過時は全量を buffer する前に `413 Payload Too Large` を返す。保留中 request queue は `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`(既定: 64)で制限し、満杯時は無制限に work を保持せず `Retry-After: 1` 付きの `429 Too Many Requests` を返す。 -- SSE stream lifetime は active stream registry だけで表現し、その registry entry が削除された後に完了済み stream task を保持しない。 +- HTTP POST 1 件 = JSON-RPC フレーム 1 件で、対応する応答は HTTP レスポンスのボディ(`200 OK` / `application/json; charset=utf-8`)に乗る。通知は `204 No Content`。`GET /events` は将来のサーバー→クライアント frame 用に独立した `text/event-stream` subscription を開く。サーバーは `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification が opt-in された場合を除き、自発的な frame を送信しない。長寿命の event stream は通常の POST リクエストを塞がない。`/` への POST 以外は `405 Method Not Allowed`。空 / 空白のみのボディは stdio の空行と同じ扱いで `204 No Content` を返し、ループは殺さない — クライアントの誤動作で junk フレームに引っかからないため。リクエスト本文は `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`(既定: 1,000,000 bytes)で制限し、超過時は全量を buffer する前に `413 Payload Too Large` を返す。保留中 request queue は `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`(既定: 64)、受理済み context handler task は `CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS`(既定: 64)、同時 `/events` stream は `CDIDX_MCP_HTTP_MAX_EVENT_STREAMS`(既定: 16)で制限し、満杯時は無制限に work を保持せず `Retry-After: 1` 付きの `429 Too Many Requests` を返す。 +- SSE stream lifetime は active stream registry と上限付き active-stream counter だけで表現し、その registry entry が削除された後に完了済み stream task を保持しない。 - `ResolveListenSpec("host:port")` は prefix を事前に解決するため、CLI が stderr に `Listening on http://...` を出せる。ポート `0` は一時 `TcpListener` を probe して空きポートを取得する。probe から `HttpListener.Start()` までの TOCTOU window は、本トランスポートが local-only / single-tenant 想定であるため許容する。ワイルドカードホスト `+` / `*` はパース時点で拒否する。 -- 任意の共有秘密による認証: `CDIDX_MCP_HTTP_TOKEN` が設定されていれば、listener はすべてのリクエストに `Authorization: Bearer ` を要求し、定数時間で比較する。トークン未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。 -- 任意のリクエストループログ: `ProgramRunner` は `HttpMcpTransport` を `GlobalToolLog` に接続するため、lifecycle log が有効な場合は HTTP リクエストごとに `mcp_http_request` 行を 1 件記録する。記録内容は method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id で、リクエスト/レスポンス本文は含めない。 +- 任意の共有秘密による認証: `CDIDX_MCP_HTTP_TOKEN` が設定されていれば、listener はすべてのリクエストに `Authorization: Bearer ` を要求し、定数時間で比較する。トークン未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。設定 token と受信 token は 4096 文字を超える場合、hash 前に拒否する。 +- 任意のリクエストループログ: `ProgramRunner` は `HttpMcpTransport` を `GlobalToolLog` に接続するため、lifecycle log が有効な場合は HTTP リクエストごとに `mcp_http_request` 行を 1 件記録する。記録内容は method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id で、caller-controlled な method、path、remote peer、request id は 256 文字を上限に `...` marker 付きで切り詰める。リクエスト/レスポンス本文は含めない。 - キャンセルは `_listener.Stop()` に接続するため、シャットダウン時に `GetContextAsync()` が unblock する。`HttpListenerException` / `ObjectDisposedException` は EOS と同じ扱いで MCP ループを stdin クローズと同じ経路で終了させる。 ワイヤー選択は `ProgramRunner.RunMcp` で行う。`--transport stdio|http` と `--http-listen ` は下流の引数解析より前に取り除かれ、bearer token は `CDIDX_MCP_HTTP_TOKEN` から読み、ディスパッチは旧来の stdio 経路または `RunMcpHttp` に着地する。プラガブルなシームは JSON-RPC 順序不変条件を両トランスポートで同一に保つので、既存の McpServer テスト群(`ProcessLineAsync` を叩く)は引き続きメソッド単位の挙動をカバーし、新トランスポートのワイヤーレベル契約は `HttpMcpTransportTests` がカバーする。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e7596afe89..da526b083a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2065,13 +2065,13 @@ CDIDX_MCP_HTTP_TOKEN=s3cret cdidx mcp \ --transport http --http-listen 0.0.0.0:9000 # LAN bind; bearer token is mandatory ``` -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 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 is capped at 64 requests by default and full queues return `429 Too Many Requests` with `Retry-After: 1`. Tune those positive-integer limits with `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` and `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`; invalid values fall back to the defaults. 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. 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; the server emits no unsolicited 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`; invalid values fall back to the defaults. 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: - The listener binds to a loopback address (`127.0.0.1`) by default, and the wildcard hosts `+` / `*` are rejected outright. - Binding to a non-loopback host (e.g. `0.0.0.0:9000`) is refused unless you set `CDIDX_MCP_HTTP_TOKEN` to a shared secret; when set, every request must carry `Authorization: Bearer ` or the listener returns `401 Unauthorized` with `WWW-Authenticate: Bearer realm="cdidx-mcp"`. -- The configured token's SHA-256 digest is precomputed at start-up; per-request authentication only hashes the supplied input and compares against the stored digest in constant time, so neither the configured token's length nor its bytes leak through timing. +- The configured token's SHA-256 digest is precomputed at start-up; per-request authentication only hashes the supplied input and compares against the stored digest in constant time, so neither the configured token's length nor its bytes leak through timing. Configured and supplied tokens longer than 4096 characters are rejected before hashing. The stdio transport stays byte-for-byte unchanged, so existing client configs keep working without modification. @@ -4237,13 +4237,13 @@ CDIDX_MCP_HTTP_TOKEN=s3cret cdidx mcp \ --transport http --http-listen 0.0.0.0:9000 # LAN 公開時は bearer token が必須 ``` -HTTP の `POST /` 1 件が JSON-RPC フレーム 1 件に対応し、応答は同じ HTTP レスポンスのボディに `200 OK` / `application/json` で返ります。通知は `204 No Content` です。`GET /events` はサーバー→クライアントフレーム用の `text/event-stream` channel を開きます。server-initiated 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 は既定で 64 件までに制限され、満杯時は `Retry-After: 1` 付きの `429 Too Many Requests` を返します。正の整数の `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` と `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH` で調整でき、不正値は既定にフォールバックします。永続 lifecycle log が有効な場合、HTTP mode はリクエストごとに `mcp_http_request` レコードも出力し、method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id を記録します。リクエスト/レスポンス本文は記録しません。 +HTTP の `POST /` 1 件が JSON-RPC フレーム 1 件に対応し、応答は同じ HTTP レスポンスのボディに `200 OK` / `application/json` で返ります。通知は `204 No Content` です。`GET /events` はサーバー→クライアントフレーム用の `text/event-stream` channel を開きます。server-initiated 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` で調整でき、不正値は既定にフォールバックします。永続 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 付きで切り詰めます。リクエスト/レスポンス本文は記録しません。 セキュリティ既定: - listener は既定で loopback アドレス(`127.0.0.1`)のみに bind し、ワイルドカード `+` / `*` は最初から拒否します。 - 非 loopback ホスト(例: `0.0.0.0:9000`)に bind するには `CDIDX_MCP_HTTP_TOKEN` で共有秘密を指定する必要があります。指定時はすべてのリクエストに `Authorization: Bearer ` ヘッダーが必要で、欠落・不一致は `401 Unauthorized`(`WWW-Authenticate: Bearer realm="cdidx-mcp"` 付き)です。 -- 設定トークンの SHA-256 digest はサーバー起動時に一度だけ計算してメモリ保持し、リクエスト毎の認証では受信トークンのみハッシュ計算して FixedTimeEquals で比較します。設定トークン側はリクエスト毎にハッシュしないため、長さやバイト列が timing から漏れません。 +- 設定トークンの SHA-256 digest はサーバー起動時に一度だけ計算してメモリ保持し、リクエスト毎の認証では受信トークンのみハッシュ計算して FixedTimeEquals で比較します。設定トークン側はリクエスト毎にハッシュしないため、長さやバイト列が timing から漏れません。設定 token と受信 token は 4096 文字を超える場合、hash 前に拒否します。 stdio トランスポートはバイト単位で挙動が変わらないため、既存クライアント設定はそのまま動作します。 From c2b6caa7d14440168ae0ad724f7cb62bda148586 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 12:03:22 +0900 Subject: [PATCH 7/7] Fix Windows HTTP MCP long path test (#3178) --- tests/CodeIndex.Tests/HttpMcpTransportTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 022942bb5d..7da0ba78f8 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -211,7 +211,8 @@ public async Task HttpTransport_RequestLogger_CapsLongPathBeforeLogging() await using var harness = await McpHttpHarness.StartAsync(_dbPath, requestLogger: records.Enqueue); using var client = new HttpClient(); - using var response = await client.GetAsync(new Uri(new Uri(harness.Endpoint), new string('p', HttpMcpTransport.MaxRequestLogFieldCharacters + 100))); + var longPath = string.Join('/', Enumerable.Repeat("segment", 50)); + using var response = await client.GetAsync(new Uri(new Uri(harness.Endpoint), longPath)); Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); var record = Assert.Single(await WaitForRequestLogRecordsAsync(records, 1));