From 3374dbe6a49da98f9f24b023ffb5b31f1d9d972e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:33:49 +0900 Subject: [PATCH 1/8] Fix URI-decoded MCP and DB path boundaries (#3789) --- changelog.d/unreleased/3789.fixed.md | 19 +++ src/CodeIndex/Cli/DbPathResolver.cs | 46 +----- src/CodeIndex/Mcp/McpServer.cs | 30 +++- src/CodeIndex/PathUriNormalizer.cs | 157 +++++++++++++++++++ tests/CodeIndex.Tests/DbPathResolverTests.cs | 26 +++ tests/CodeIndex.Tests/McpServerTests.cs | 37 +++++ 6 files changed, 265 insertions(+), 50 deletions(-) create mode 100644 changelog.d/unreleased/3789.fixed.md create mode 100644 src/CodeIndex/PathUriNormalizer.cs diff --git a/changelog.d/unreleased/3789.fixed.md b/changelog.d/unreleased/3789.fixed.md new file mode 100644 index 0000000000..2e5bf2e7c6 --- /dev/null +++ b/changelog.d/unreleased/3789.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3789 +affected: + - src/CodeIndex/PathUriNormalizer.cs + - src/CodeIndex/Cli/DbPathResolver.cs + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/DbPathResolverTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **URI-decoded MCP and DB paths now share encoded-boundary rejection (#3789)** — `resources/read` and SQLite `file:` DB paths reject encoded separators and traversal markers before decode/normalization while still accepting already-decoded spaces. + +## 日本語 + +- **URI decode される MCP / DB path が encoded boundary 拒否を共有するようになりました (#3789)** — `resources/read` と SQLite `file:` DB path は decode / 正規化前に encoded separator と traversal marker を拒否し、すでに decode 済みの空白は引き続き受け付けます。 diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index 835ffe4f9e..77357ce03a 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -243,28 +243,9 @@ internal static bool TryNormalizeDbPath(string dbPath, out string normalizedDbPa if (!SqliteFileUri.TryGetPathBeforeQuery(dbPath, out var trimmed, out var boundsError)) throw boundsError ?? new FormatException("Invalid SQLite file URI."); - if (ContainsInvalidPercentEscape(trimmed)) - throw new FormatException("Invalid percent escape in SQLite file URI."); - - if (!trimmed.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) - { - var relativePath = Uri.UnescapeDataString(trimmed["file:".Length..]); - normalizedDbPath = string.IsNullOrWhiteSpace(relativePath) - ? dbPath - : Path.GetFullPath(relativePath); - return true; - } - - var uri = new Uri(trimmed); - if (!uri.IsFile) - return true; - - var localPath = uri.LocalPath; - normalizedDbPath = string.IsNullOrWhiteSpace(localPath) - ? dbPath - : Path.IsPathRooted(localPath) - ? localPath - : Path.GetFullPath(localPath); + if (!PathUriNormalizer.TryNormalizeFileUriPath(trimmed, out var normalizedUriPath, out var uriPathError)) + throw new FormatException(uriPathError ?? "Invalid SQLite file URI path."); + normalizedDbPath = normalizedUriPath; return true; } catch (Exception ex) when (ex is ArgumentException or FormatException or UriFormatException) @@ -457,27 +438,6 @@ private static string QuoteLogValue(string value) private static bool IsUnderDirectory(string parentDirectory, string candidatePath) => PathCasing.IsPathEqualOrParent(parentDirectory, candidatePath); - private static bool ContainsInvalidPercentEscape(string value) - { - for (var i = 0; i < value.Length; i++) - { - if (value[i] != '%') - continue; - - if (i + 2 >= value.Length || !IsHexDigit(value[i + 1]) || !IsHexDigit(value[i + 2])) - return true; - - i += 2; - } - - return false; - } - - private static bool IsHexDigit(char value) => - value is >= '0' and <= '9' - or >= 'a' and <= 'f' - or >= 'A' and <= 'F'; - private static List TryReadIndexedFileSamples(string dbPath) { try diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 02b7f820f7..546f183106 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -2536,22 +2536,38 @@ private static bool TryParseResourceUri(string uri, out string path) if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed) || !string.Equals(parsed.Scheme, "cdidx", StringComparison.OrdinalIgnoreCase) || !string.Equals(parsed.Host, "file", StringComparison.OrdinalIgnoreCase) - || string.IsNullOrWhiteSpace(parsed.AbsolutePath)) + || !TryExtractRawResourcePath(uri, out var rawPath)) { return false; } - var decoded = Uri.UnescapeDataString(parsed.AbsolutePath.TrimStart('/')); - if (decoded.Length == 0 - || Path.IsPathRooted(decoded) - || decoded.Split('/').Any(segment => segment.Length == 0 || segment is "." or "..")) - { + if (!PathUriNormalizer.TryDecodeRelativeUriPath(rawPath, allowBackslash: false, out var decoded)) return false; - } + path = decoded; return true; } + private static bool TryExtractRawResourcePath(string uri, out string rawPath) + { + rawPath = string.Empty; + var schemeSeparator = uri.IndexOf("://", StringComparison.Ordinal); + if (schemeSeparator < 0) + return false; + + var hostStart = schemeSeparator + 3; + var pathStart = uri.IndexOf('/', hostStart); + if (pathStart < 0 || pathStart == uri.Length - 1) + return false; + + rawPath = uri[(pathStart + 1)..]; + var terminator = rawPath.IndexOfAny(['?', '#']); + if (terminator >= 0) + rawPath = rawPath[..terminator]; + + return !string.IsNullOrWhiteSpace(rawPath); + } + private static string? TryReadStringValue(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : null; diff --git a/src/CodeIndex/PathUriNormalizer.cs b/src/CodeIndex/PathUriNormalizer.cs new file mode 100644 index 0000000000..592e5fefe9 --- /dev/null +++ b/src/CodeIndex/PathUriNormalizer.cs @@ -0,0 +1,157 @@ +namespace CodeIndex; + +/// +/// Shared URI path decoding guard for file-like paths. It rejects malformed percent escapes +/// and encoded path-boundary characters before any caller normalizes the decoded path. +/// file-like path 用の URI path decode guard。decode 済み path を正規化する前に、不正な +/// percent escape と encoded path boundary 文字を拒否する。 +/// +internal static class PathUriNormalizer +{ + internal static bool TryDecodeRelativeUriPath(string encodedPath, bool allowBackslash, out string decodedPath) + { + decodedPath = string.Empty; + if (string.IsNullOrWhiteSpace(encodedPath) + || ContainsInvalidPercentEscape(encodedPath) + || ContainsEncodedPathBoundary(encodedPath)) + { + return false; + } + + var decoded = Uri.UnescapeDataString(encodedPath); + if (!allowBackslash && decoded.Contains('\\', StringComparison.Ordinal)) + return false; + + var normalized = decoded.Replace('\\', '/'); + if (normalized.Length == 0 + || Path.IsPathRooted(normalized) + || HasWindowsDrivePrefix(normalized) + || normalized.Split('/').Any(segment => segment.Length == 0 || segment is "." or "..")) + { + return false; + } + + decodedPath = normalized; + return true; + } + + internal static bool TryNormalizeFileUriPath(string fileUri, out string normalizedPath, out string? error) + { + normalizedPath = fileUri; + error = null; + if (!fileUri.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + return true; + + var pathText = StripQuery(fileUri); + var pathPayload = pathText["file:".Length..]; + if (ContainsInvalidPercentEscape(pathText)) + { + error = "Invalid percent escape in file URI."; + return false; + } + if (ContainsEncodedPathBoundary(pathPayload)) + { + error = "Encoded path separators or traversal markers are not allowed in file URI paths."; + return false; + } + + try + { + if (!pathText.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + var relativePath = Uri.UnescapeDataString(pathText["file:".Length..]); + if (string.IsNullOrWhiteSpace(relativePath)) + return true; + normalizedPath = Path.GetFullPath(relativePath); + return true; + } + + var uri = new Uri(pathText); + if (!uri.IsFile) + return true; + + var localPath = uri.LocalPath; + if (string.IsNullOrWhiteSpace(localPath)) + return true; + + normalizedPath = Path.IsPathRooted(localPath) + ? localPath + : Path.GetFullPath(localPath); + return true; + } + catch (Exception ex) when (ex is ArgumentException or FormatException or UriFormatException) + { + error = ex.Message; + return false; + } + } + + internal static bool ContainsInvalidPercentEscape(string text) + { + for (var i = 0; i < text.Length; i++) + { + if (text[i] != '%') + continue; + + if (i + 2 >= text.Length || !IsHexDigit(text[i + 1]) || !IsHexDigit(text[i + 2])) + return true; + i += 2; + } + + return false; + } + + internal static bool HasWindowsDrivePrefix(string path) + => path.Length >= 2 + && path[1] == ':' + && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')); + + private static string StripQuery(string uri) + { + var query = uri.IndexOf('?'); + return query >= 0 ? uri[..query] : uri; + } + + private static bool ContainsEncodedPathBoundary(string text) + { + foreach (var segment in text.Split('/', '\\')) + { + if (segment.Length == 0) + continue; + + var decodedSegment = Uri.UnescapeDataString(segment); + if (decodedSegment is "." or "..") + return true; + } + + for (var i = 0; i + 2 < text.Length; i++) + { + if (text[i] != '%') + continue; + + var decoded = DecodeAsciiHex(text[i + 1], text[i + 2]); + if (decoded is '/' or '\\') + return true; + i += 2; + } + + return false; + } + + private static char DecodeAsciiHex(char high, char low) + => (char)((HexValue(high) << 4) | HexValue(low)); + + private static int HexValue(char ch) + => ch switch + { + >= '0' and <= '9' => ch - '0', + >= 'A' and <= 'F' => ch - 'A' + 10, + >= 'a' and <= 'f' => ch - 'a' + 10, + _ => 0, + }; + + private static bool IsHexDigit(char ch) + => (ch >= '0' && ch <= '9') || + (ch >= 'A' && ch <= 'F') || + (ch >= 'a' && ch <= 'f'); +} diff --git a/tests/CodeIndex.Tests/DbPathResolverTests.cs b/tests/CodeIndex.Tests/DbPathResolverTests.cs index bb95bc2e01..9b44bd74af 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -288,6 +288,32 @@ public void TryNormalizeDbPath_MalformedFileUri_ReturnsParseErrorWithoutChanging Assert.NotNull(parseError); } + [Theory] + [InlineData("file:sub%2fdir/codeindex.db")] + [InlineData("file:sub%5cdir/codeindex.db")] + [InlineData("file:%2e%2e/codeindex.db")] + public void TryNormalizeDbPath_RejectsEncodedPathBoundaries_Issue3789(string dbUri) + { + var resolved = DbPathResolver.TryNormalizeDbPath(dbUri, out var normalized, out var parseError); + + Assert.False(resolved); + Assert.Equal(dbUri, normalized); + Assert.NotNull(parseError); + } + + [Fact] + public void TryNormalizeDbPath_FileUriWithDecodedSpace_NormalizesOnce_Issue3789() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx decoded space {Guid.NewGuid():N}.db"); + var decodedSpaceUri = new Uri(dbPath).AbsoluteUri.Replace("%20", " ", StringComparison.Ordinal); + + var resolved = DbPathResolver.TryNormalizeDbPath(decodedSpaceUri, out var normalized, out var parseError); + + Assert.True(resolved); + Assert.Null(parseError); + Assert.Equal(Path.GetFullPath(dbPath), normalized); + } + [Fact] public void TryNormalizeDbPath_OversizedFileUri_ReturnsParseErrorWithoutChangingValue() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index a9eae188f0..d8b5ce7199 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1638,6 +1638,43 @@ public void ResourcesRead_ReturnsIndexedFileContent() Assert.Contains("public class App", content["text"]!.GetValue()); } + [Fact] + public void ResourcesRead_DecodesSpaceOnce_Issue3789() + { + InsertIndexedFile("src/space file.cs", "csharp", "public class SpaceFile { }"); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"cdidx://file/src/space%20file.cs"}}""")!; + + var response = _server.HandleMessage(request)!; + + var content = response["result"]!["contents"]!.AsArray().Single()!; + Assert.Equal("cdidx://file/src/space%20file.cs", content["uri"]!.GetValue()); + Assert.Contains("SpaceFile", content["text"]!.GetValue()); + } + + [Theory] + [InlineData("cdidx://file/src%2fapp.cs")] + [InlineData("cdidx://file/src%5capp.cs")] + [InlineData("cdidx://file/src/%2e%2e/app.cs")] + public void ResourcesRead_RejectsEncodedPathBoundaries_Issue3789(string uri) + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "resources/read", + ["params"] = new JsonObject + { + ["uri"] = uri, + }, + }; + + var response = _server.HandleMessage(request)!; + + Assert.Equal(-32602, response["error"]!["code"]!.GetValue()); + Assert.Equal("invalid_argument", response["error"]!["data"]!["category"]!.GetValue()); + Assert.StartsWith("Invalid resource uri:", response["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + [Fact] public void ResourcesRead_NonStringUri_ReturnsInvalidParams() { From b597bca2cd5f8bccd539c96e994224b45ef43cd3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:38:38 +0900 Subject: [PATCH 2/8] Centralize MCP path boundary checks (#3753) --- changelog.d/unreleased/3753.fixed.md | 17 ++++ src/CodeIndex/Mcp/McpPathBoundary.cs | 111 ++++++++++++++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 107 +---------------------- tests/CodeIndex.Tests/McpServerTests.cs | 29 +++++++ 4 files changed, 161 insertions(+), 103 deletions(-) create mode 100644 changelog.d/unreleased/3753.fixed.md create mode 100644 src/CodeIndex/Mcp/McpPathBoundary.cs diff --git a/changelog.d/unreleased/3753.fixed.md b/changelog.d/unreleased/3753.fixed.md new file mode 100644 index 0000000000..d64638c0cc --- /dev/null +++ b/changelog.d/unreleased/3753.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3753 +affected: + - src/CodeIndex/Mcp/McpPathBoundary.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP filesystem path containment now routes through one boundary helper (#3753)** — required workspace-relative paths, client-root file URIs, and `index` containment checks now share symlink-aware normalization before deciding whether a path remains inside the allowed directory. + +## 日本語 + +- **MCP filesystem path の containment が単一の boundary helper を通るようになりました (#3753)** — 必須の workspace-relative path、client root の file URI、`index` の containment 判定は、許可ディレクトリ内に留まるか判断する前に symlink-aware な正規化を共有します。 diff --git a/src/CodeIndex/Mcp/McpPathBoundary.cs b/src/CodeIndex/Mcp/McpPathBoundary.cs new file mode 100644 index 0000000000..bb4dba89a4 --- /dev/null +++ b/src/CodeIndex/Mcp/McpPathBoundary.cs @@ -0,0 +1,111 @@ +using CodeIndex.Cli; + +namespace CodeIndex.Mcp; + +/// +/// Central MCP filesystem boundary checks for paths that may reach local IO. +/// ローカル IO に届き得る MCP path の境界検証を集約する。 +/// +internal static class McpPathBoundary +{ + internal static bool TryValidateWorkspaceRelativePath(string value, int maxLength, string propertyName, out string? error) + { + if (value.Length > maxLength) + { + error = $"Parameter \"{propertyName}\" must be no longer than {maxLength} characters."; + return false; + } + + var normalized = value.Replace("\\", "/", StringComparison.Ordinal); + if (value.IndexOf("\0", StringComparison.Ordinal) >= 0 + || normalized.StartsWith("/", StringComparison.Ordinal) + || PathUriNormalizer.HasWindowsDrivePrefix(normalized) + || normalized.Split(new[] { '/' }, StringSplitOptions.None).Any(segment => segment == "..")) + { + error = $"Parameter \"{propertyName}\" must be workspace-relative and must not contain NUL bytes or `..` path traversal segments."; + return false; + } + + error = null; + return true; + } + + internal static bool IsPathWithinDirectory(string parentPath, string childPath) + { + var parent = NormalizeDirectoryBoundaryPath(ResolveExistingDirectoryPath(parentPath)); + var child = NormalizeDirectoryBoundaryPath(ResolveExistingDirectoryPath(childPath)); + + return PathCasing.IsPathEqualOrParent(parent, child); + } + + internal 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 PathUriNormalizer.TryNormalizeFileUriPath(root, out var normalized, out _) + ? NormalizeDirectoryBoundaryPath(normalized) + : null; + } + try + { + return NormalizeDirectoryBoundaryPath(root); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return null; + } + } + + private static string ResolveExistingDirectoryPath(string path) + { + var fullPath = Path.GetFullPath(path); + if (!Directory.Exists(fullPath)) + return fullPath; + + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + return fullPath; + + var current = root; + var relativePath = Path.GetRelativePath(root, fullPath); + if (relativePath == ".") + return fullPath; + + foreach (var segment in relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + { + if (string.IsNullOrEmpty(segment) || segment == ".") + continue; + + current = Path.Combine(current, segment); + try + { + var target = new DirectoryInfo(current).ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + current = target.FullName; + } + catch (IOException) + { + return Path.GetFullPath(current); + } + catch (UnauthorizedAccessException) + { + return Path.GetFullPath(current); + } + } + + return Path.GetFullPath(current); + } + + private static string NormalizeDirectoryBoundaryPath(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (!string.IsNullOrEmpty(root) && string.Equals(fullPath, root, StringComparison.Ordinal)) + return fullPath; + return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 33aba34117..ea04168546 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1590,24 +1590,7 @@ private static bool TryReadRequiredPathParameter(JsonNode? args, string property if (!TryReadRequiredStringParameter(args, propertyName, out value, out error)) return false; - if (value.Length > MaxMcpArrayFilterStringLength) - { - error = $"Parameter \"{propertyName}\" must be no longer than {MaxMcpArrayFilterStringLength} characters."; - return false; - } - - var normalized = value.Replace("\\", "/", StringComparison.Ordinal); - if (value.IndexOf("\0", StringComparison.Ordinal) >= 0 - || normalized.StartsWith("/", StringComparison.Ordinal) - || HasWindowsDrivePrefix(normalized) - || normalized.Split(new[] { '/' }, StringSplitOptions.None).Any(segment => segment == "..")) - { - error = $"Parameter \"{propertyName}\" must be workspace-relative and must not contain NUL bytes or `..` path traversal segments."; - return false; - } - - error = null; - return true; + return McpPathBoundary.TryValidateWorkspaceRelativePath(value, MaxMcpArrayFilterStringLength, propertyName, out error); } private static bool TryReadRequiredIndexPathParameter(JsonNode? args, string propertyName, out string value, out string? error) @@ -1631,11 +1614,6 @@ private static bool TryReadRequiredIndexPathParameter(JsonNode? args, string pro return true; } - private static bool HasWindowsDrivePrefix(string path) - => path.Length >= 2 - && path[1] == ':' - && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')); - private static bool HasBlankPathFilter(JsonNode? args) { var node = args?["path"]; @@ -2968,63 +2946,6 @@ private static bool IsBareVerbatimQueryToken(string value) return trimmed.Length > 0 && trimmed.All(ch => ch == '@'); } - private static bool IsPathWithinDirectory(string parentPath, string childPath) - { - var parent = NormalizeDirectoryBoundaryPath(ResolveExistingDirectoryPath(parentPath)); - var child = NormalizeDirectoryBoundaryPath(ResolveExistingDirectoryPath(childPath)); - - return PathCasing.IsPathEqualOrParent(parent, child); - } - - private static string ResolveExistingDirectoryPath(string path) - { - var fullPath = Path.GetFullPath(path); - if (!Directory.Exists(fullPath)) - return fullPath; - - var root = Path.GetPathRoot(fullPath); - if (string.IsNullOrEmpty(root)) - return fullPath; - - var current = root; - var relativePath = Path.GetRelativePath(root, fullPath); - if (relativePath == ".") - return fullPath; - - foreach (var segment in relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) - { - if (string.IsNullOrEmpty(segment) || segment == ".") - continue; - - current = Path.Combine(current, segment); - try - { - var target = new DirectoryInfo(current).ResolveLinkTarget(returnFinalTarget: true); - if (target != null) - current = target.FullName; - } - catch (IOException) - { - return Path.GetFullPath(current); - } - catch (UnauthorizedAccessException) - { - return Path.GetFullPath(current); - } - } - - return Path.GetFullPath(current); - } - - private static string NormalizeDirectoryBoundaryPath(string path) - { - var fullPath = Path.GetFullPath(path); - var root = Path.GetPathRoot(fullPath); - if (!string.IsNullOrEmpty(root) && string.Equals(fullPath, root, StringComparison.Ordinal)) - return fullPath; - return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - } - private static Dictionary GetHotspotFamilyMetaSnapshot(DbContext db, Func keyFactory) { var values = new Dictionary(StringComparer.Ordinal); @@ -5610,7 +5531,7 @@ private bool IsPathWithinClientRoots(string path) var rootPaths = _clientRoots .Select(root => TryReadStringValue(root)) - .Select(TryResolveRootPath) + .Select(McpPathBoundary.TryResolveRootPath) .Where(root => !string.IsNullOrWhiteSpace(root)) .Cast() .ToArray(); @@ -5618,27 +5539,7 @@ private bool IsPathWithinClientRoots(string path) 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; - } + return rootPaths.Any(root => McpPathBoundary.IsPathWithinDirectory(root, fullPath)); } private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) @@ -5702,7 +5603,7 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso // Prevent path traversal — only allow indexing within current working directory // パストラバーサル防止 — カレントディレクトリ配下のみインデックスを許可 var cwd = Path.GetFullPath("."); - if (!IsPathWithinDirectory(cwd, projectPath)) + if (!McpPathBoundary.IsPathWithinDirectory(cwd, projectPath)) return CreateToolErrorResponse(id, "Path must be within the current working directory"); await RefreshClientRootsIfNeededAsync().ConfigureAwait(false); if (!IsPathWithinClientRoots(projectPath)) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index d8b5ce7199..a61466a4c1 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -2159,6 +2159,35 @@ public void ToolCall_RequiredPath_RejectsInvalidPathValues_Issue3186( Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, result["structuredContent"]!["category"]!.GetValue()); } + [Fact] + public void McpPathBoundary_ResolvesSymlinkTargetsBeforeContainment_Issue3753() + { + if (OperatingSystem.IsWindows()) + return; + + var root = TestProjectHelper.CreateTempProject("cdidx_mcp_boundary_root"); + var outside = TestProjectHelper.CreateTempProject("cdidx_mcp_boundary_outside"); + try + { + var link = Path.Combine(root, "link-outside"); + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + Assert.False(McpPathBoundary.IsPathWithinDirectory(root, link)); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(outside); + } + } + [Fact] public void ToolsList_IndexPathSchemaReflectsProjectPathContract_Issue3186() { From 1910847ad13b2d0244ac89774c2631a228483303 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:39:34 +0900 Subject: [PATCH 3/8] Expose MCP string-list bounds (#3752) --- changelog.d/unreleased/3752.fixed.md | 16 +++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 9 ++- tests/CodeIndex.Tests/McpServerTests.cs | 90 +++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3752.fixed.md diff --git a/changelog.d/unreleased/3752.fixed.md b/changelog.d/unreleased/3752.fixed.md new file mode 100644 index 0000000000..2534609b3f --- /dev/null +++ b/changelog.d/unreleased/3752.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3752 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP string-list arguments now expose shared count and length bounds consistently (#3752)** — list validation rejects oversized `names`, path, and filter arrays before handler execution and returns structured `max_count` / `actual_count` or `max_length` / `actual_length` metadata in top-level and batch errors. + +## 日本語 + +- **MCP の string-list 引数が共有の件数・長さ上限を一貫して返すようになりました (#3752)** — `names`、path、filter 配列の過大入力は handler 実行前に拒否され、top-level と batch のエラーに `max_count` / `actual_count` または `max_length` / `actual_length` metadata を返します。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index ea04168546..ef457bb1e7 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -956,10 +956,13 @@ private static string DescribeJsonType(JsonNode? node) { ["message"] = $"{propertyName} must contain at most {MaxMcpArrayFilterCount} entries.", ["invalid_count"] = array.Count - MaxMcpArrayFilterCount, + ["max_count"] = MaxMcpArrayFilterCount, + ["actual_count"] = array.Count, }; var invalidCount = 0; var invalidSamples = new JsonArray(); + var hasTooLongEntry = false; for (var i = 0; i < array.Count; i++) { var element = array[i]; @@ -974,17 +977,19 @@ private static string DescribeJsonType(JsonNode? node) if (text.Length > MaxMcpArrayFilterStringLength) { invalidCount++; + hasTooLongEntry = true; if (invalidSamples.Count < 3) invalidSamples.Add($"[{i}] length {text.Length}"); } } - if (invalidCount > 0 && !(propertyName == "names" && invalidCount == array.Count)) + if (invalidCount > 0 && (propertyName != "names" || invalidCount != array.Count || hasTooLongEntry)) return new JsonObject { ["message"] = $"{propertyName} contains {invalidCount} invalid entr{(invalidCount == 1 ? "y" : "ies")}. Entries must be non-empty strings no longer than {MaxMcpArrayFilterStringLength} characters.", ["invalid_count"] = invalidCount, ["invalid_samples"] = invalidSamples, + ["max_length"] = MaxMcpArrayFilterStringLength, }; return null; } @@ -1011,6 +1016,8 @@ private static string DescribeJsonType(JsonNode? node) ["message"] = $"{propertyName} must be no longer than {MaxMcpArrayFilterStringLength} characters.", ["invalid_count"] = 1, ["invalid_samples"] = new JsonArray { $"length {scalarText.Length}" }, + ["max_length"] = MaxMcpArrayFilterStringLength, + ["actual_length"] = scalarText.Length, }; return null; } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index a61466a4c1..b3ad76e6fe 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -10169,6 +10169,96 @@ void AssertListError(string toolName, JsonObject arguments, string expectedText, } } + [Fact] + public void ToolsCall_StringListArgumentsExposeSharedBounds_Issue3752() + { + var names = new JsonArray(); + for (var i = 0; i < McpServer.MaxMcpArrayFilterCount + 1; i++) + names.Add($"App{i}"); + var tooManyResponse = _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "symbols", + ["arguments"] = new JsonObject + { + ["names"] = names, + }, + }, + })!; + + var tooManyStructured = tooManyResponse["result"]!["structuredContent"]!; + Assert.True(tooManyResponse["result"]!["isError"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpArrayFilterCount, tooManyStructured["max_count"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpArrayFilterCount + 1, tooManyStructured["actual_count"]!.GetValue()); + + var longExclude = new string('x', McpServer.MaxMcpArrayFilterStringLength + 1); + var tooLongResponse = _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 2, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "search", + ["arguments"] = new JsonObject + { + ["query"] = "App", + ["excludePaths"] = longExclude, + }, + }, + })!; + + var tooLongStructured = tooLongResponse["result"]!["structuredContent"]!; + Assert.True(tooLongResponse["result"]!["isError"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpArrayFilterStringLength, tooLongStructured["max_length"]!.GetValue()); + Assert.Equal(longExclude.Length, tooLongStructured["actual_length"]!.GetValue()); + } + + [Fact] + public void ToolsCall_BatchQuery_StringListArgumentErrorsCarryBounds_Issue3752() + { + var paths = new JsonArray(); + for (var i = 0; i < McpServer.MaxMcpArrayFilterCount + 1; i++) + paths.Add($"src/{i}.cs"); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "batch_query", + ["arguments"] = new JsonObject + { + ["queries"] = new JsonArray + { + new JsonObject + { + ["tool"] = "search", + ["arguments"] = new JsonObject + { + ["query"] = "App", + ["path"] = paths, + }, + }, + }, + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + var slot = response["result"]!["structuredContent"]!["results"]!.AsArray().Single()!; + Assert.False(slot["ok"]!.GetValue()); + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, slot["category"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpArrayFilterCount, slot["max_count"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpArrayFilterCount + 1, slot["actual_count"]!.GetValue()); + } + [Fact] public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() { From cdaed1fd07246ed2748ac3ba41dc69f5a388ac24 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:39:59 +0900 Subject: [PATCH 4/8] Fail closed on invalid MCP tool denylists (#3829) --- changelog.d/unreleased/3829.fixed.md | 16 +++++++++ src/CodeIndex/Mcp/McpToolFilter.cs | 8 ++++- tests/CodeIndex.Tests/McpServerTests.cs | 47 ++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3829.fixed.md diff --git a/changelog.d/unreleased/3829.fixed.md b/changelog.d/unreleased/3829.fixed.md new file mode 100644 index 0000000000..57eb6f4a4f --- /dev/null +++ b/changelog.d/unreleased/3829.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3829 +affected: + - src/CodeIndex/Mcp/McpToolFilter.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP tool filtering now fails closed for invalid denylists (#3829)** — overlong or over-entry `CDIDX_MCP_TOOLS_DENY` values disable all tools with bounded warnings instead of silently preserving the default catalog, and tests now lock `KnownToolNames` to the advertised `tools/list` catalog. + +## 日本語 + +- **MCP tool filter が不正な denylist で fail-closed するようになりました (#3829)** — 過長または項目数超過の `CDIDX_MCP_TOOLS_DENY` は既定 catalog を黙って維持せず、bounded warning とともに全ツールを無効化し、`KnownToolNames` と `tools/list` catalog の同期もテストで固定します。 diff --git a/src/CodeIndex/Mcp/McpToolFilter.cs b/src/CodeIndex/Mcp/McpToolFilter.cs index 9cf1718b08..b821f7a37b 100644 --- a/src/CodeIndex/Mcp/McpToolFilter.cs +++ b/src/CodeIndex/Mcp/McpToolFilter.cs @@ -107,8 +107,14 @@ internal static McpToolFilter Parse(string? allowValue, string? denyValue) var enabled = new HashSet(KnownToolNames, StringComparer.OrdinalIgnoreCase); var deny = SplitCsv(denyValue, DenyEnvVarName, out var denySpecified, out var denyInvalid); - if (denySpecified && !denyInvalid) + if (denySpecified) { + if (denyInvalid) + { + McpEnvironment.WriteWarning(DenyEnvVarName, "was rejected; failing closed with no tools enabled."); + return new McpToolFilter(new HashSet(StringComparer.OrdinalIgnoreCase)); + } + WarnUnknownNames(DenyEnvVarName, deny); foreach (var name in deny) enabled.Remove(name); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index b3ad76e6fe..09719d2008 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4777,10 +4777,38 @@ public void McpToolFilter_Parse_TooManyDenyEntriesAreRejected_Issue2905() var tooMany = string.Join(',', Enumerable.Repeat("index", McpToolFilter.MaxToolFilterCsvEntries + 1)); var filter = McpToolFilter.Parse(null, tooMany); - Assert.True(filter.IsEnabled("index")); + foreach (var name in McpToolFilter.KnownToolNames) + Assert.False(filter.IsEnabled(name), $"{name} should be disabled when an invalid denylist is supplied"); Assert.Contains(McpToolFilter.DenyEnvVarName, stderr.ToString()); Assert.Contains("accepts at most", stderr.ToString()); Assert.Contains("was rejected", stderr.ToString()); + Assert.Contains("failing closed", stderr.ToString()); + } + finally + { + Console.SetError(originalError); + } + } + } + + [Fact] + public void McpToolFilter_Parse_OverlongDenyListFailsClosed_Issue3829() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + using var stderr = new StringWriter(); + try + { + Console.SetError(stderr); + var filter = McpToolFilter.Parse(null, new string('d', McpToolFilter.MaxToolFilterCsvLength + 1)); + + foreach (var name in McpToolFilter.KnownToolNames) + Assert.False(filter.IsEnabled(name), $"{name} should be disabled when an overlong denylist is supplied"); + var warning = stderr.ToString(); + Assert.Contains(McpToolFilter.DenyEnvVarName, warning); + Assert.Contains("was rejected", warning); + Assert.Contains("failing closed", warning); } finally { @@ -4971,6 +4999,23 @@ public void ToolsList_FilteredByAllowList_HidesDisabledTools() Assert.Equal(new[] { "references", "search" }, names); } + [Fact] + public void ToolsList_KnownToolNamesMatchAdvertisedTools_Issue3829() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var response = _server.HandleMessage(request)!; + + var advertised = response["result"]!["tools"]!.AsArray() + .Select(tool => tool!["name"]!.GetValue()) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + var known = McpToolFilter.KnownToolNames + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(known, advertised); + } + [Fact] public void ToolsList_FilteredByDenyList_HidesDeniedTools() { From 31f982d57b7ae8488aae47263ed8d834c4d3553d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:40:53 +0900 Subject: [PATCH 5/8] Cap MCP rate limiter bucket cardinality (#3780) --- AGENT_GUIDE.md | 4 +-- DEVELOPER_GUIDE.md | 4 +-- README.md | 4 +-- changelog.d/unreleased/3780.fixed.md | 17 +++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 3 +++ src/CodeIndex/Mcp/RateLimiter.cs | 20 +++++++++++++-- .../DocumentationStatusContractTests.cs | 2 ++ tests/CodeIndex.Tests/RateLimiterTests.cs | 25 +++++++++++++++++++ 8 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/3780.fixed.md diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 0ae1579cfa..662450782c 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -131,7 +131,7 @@ CI watching must be bounded. Do not loop indefinitely. ## Status Contract -- `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`, `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. +- `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`, `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, `rate_limit.bucket_limit`, `rate_limit.bucket_limit_rejection_count`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. - When any readiness field is degraded, the CLI adds `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, and `readiness_degradations[]`. `degraded_root_cause` is the primary stable machine code; `readiness_degradations[]` lists every degraded field with `root_cause`, human reason, and remediation strings. - `hotspot_family_degraded_reason` currently uses `hotspot_family_support_not_indexed`, `hotspot_family_metadata_stale`, `hotspot_family_disabled_at_index_time`, `partial_family_key_population`, and `hotspot_family_marker_fingerprint_incomplete`; the incomplete marker fingerprint code means marker traversal hit safety caps and should stay synchronized with README / developer-guide recovery notes. - `issues_table_available` reports physical `file_issues` table presence only. `file_issues_data_current` reports whether the table is also stamped current for the active index generation. @@ -145,7 +145,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status` also surfaces filesystem case-sensitivity via `path_case_sensitive`, stamped on every successful `cdidx index` run (full scan AND partial update, plus MCP-driven indexes) from `core.ignorecase` + a live filesystem probe. `true` means the volume is case-sensitive (`Foo.cs` and `foo.cs` are distinct); `false` means case-insensitive. Omitted on legacy DBs that predate the stamp. Use it to audit path-equality decisions on case-sensitive APFS, WSL NTFS / dev-drive, and ReFS mounts where the prior OS-keyed heuristic could mis-classify the workspace (#1546). - `status` also surfaces Linux mandatory-access-control context via `mac_profile` when `/proc/self/attr/current` or `/proc/self/attr/exec` indicates an AppArmor or SELinux profile. If proc attribute reads fail on Linux, `mac_profile_diagnostics[]` reports bounded `path`, `category`, and `message` entries so users can distinguish "no profile" from "profile detection failed" (#1768, #3480). - `status` also surfaces DB/WAL size, per-language symbol-kind histograms, current process heap/GC/working-set metrics, and the last successful index run metadata. `process` is captured at status-call time; `last_index_run` is persisted at the end of successful CLI and MCP index runs and can include a peak-memory summary when CLI `--memory-trace` was used. `last_index_run.bytes_read_skipped_file_count` and `bytes_read_incomplete` report whether unreadable files were omitted from the `bytes_read` total. `last_index_run.diagnostics`, `diagnostic_count`, and `diagnostics_truncated` carry bounded warnings for best-effort index metadata writes that failed after the index data itself was successfully written. `last_workspace_freshened_at` is the latest successful index/update timestamp and can be newer than `indexed_at` when a partial or no-op update confirms freshness without rewriting indexed file rows. -- MCP `status` also surfaces session diagnostics via `mcp_session`. It is not persisted DB state; it includes the current `log_level`, bounded captured `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. +- MCP `status` also surfaces session diagnostics via `mcp_session` and rate limiter bucket cap diagnostics via `rate_limit.bucket_limit` / `rate_limit.bucket_limit_rejection_count`. `mcp_session` is not persisted DB state; it includes the current `log_level`, bounded captured `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. `rate_limit.bucket_limit` is the process-local `(tool, caller)` bucket cap, and `rate_limit.bucket_limit_rejection_count` counts calls denied because creating a new bucket would exceed that cap. - Keep `README.md`, `DEVELOPER_GUIDE.md`, and this file synchronized if this contract changes. ## Reference Extraction diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 43523f6b86..b1a76fe24a 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1063,7 +1063,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int | Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_failed_or_partial_index_run`. | | Database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`. | | Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | -| MCP-only session diagnostics | `mcp_session`, which is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. | +| MCP-only session diagnostics | `mcp_session`, `rate_limit.bucket_limit`, and `rate_limit.bucket_limit_rejection_count`. `mcp_session` is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. `rate_limit.bucket_limit` is the configured process-local `(tool, caller)` bucket cap, and `rate_limit.bucket_limit_rejection_count` counts calls denied because creating a new bucket would exceed that cap. | | Documentation sync | Keep this list synchronized with `README.md` and `AGENT_GUIDE.md`; `DocumentationStatusContractTests` fails when any required field is missing from one of those docs. | Runtime diagnostic subcontracts: @@ -3296,7 +3296,7 @@ AI エージェント向け検索ルールのテンプレートについては | unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_failed_or_partial_index_run`。 | | database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`。 | | remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`。 | -| MCP-only session diagnostics | `mcp_session`。これは persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、上限付きの任意の `client_capabilities` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。client capabilities が切り詰められた場合は `client_capabilities_truncated`、`client_capabilities_truncation_reason`、`client_capabilities_serialized_bytes`、`client_capabilities_byte_limit`、`client_capabilities_depth_limit` が保持された診断 subset を示します。 | +| MCP-only session diagnostics | `mcp_session`, `rate_limit.bucket_limit`, `rate_limit.bucket_limit_rejection_count`。`mcp_session` は persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、上限付きの任意の `client_capabilities` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。client capabilities が切り詰められた場合は `client_capabilities_truncated`、`client_capabilities_truncation_reason`、`client_capabilities_serialized_bytes`、`client_capabilities_byte_limit`、`client_capabilities_depth_limit` が保持された診断 subset を示します。`rate_limit.bucket_limit` は process-local な `(tool, caller)` bucket 上限、`rate_limit.bucket_limit_rejection_count` は新規 bucket 作成がその上限を超えるため拒否された呼び出し数です。 | | documentation sync | この一覧は `README.md` と `AGENT_GUIDE.md` と同期してください。必須 field がそれらの docs から欠けると `DocumentationStatusContractTests` が失敗します。 | runtime diagnostic subcontract: diff --git a/README.md b/README.md index aefa79fa45..5a1c62352a 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ names visible so documentation and tests stay synchronized. | Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_failed_or_partial_index_run`. | | Database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`. | | Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | -| MCP-only session diagnostics | `mcp_session`. | +| MCP-only session diagnostics | `mcp_session`, `rate_limit.bucket_limit`, `rate_limit.bucket_limit_rejection_count`. | `worktree_head_changed` compares the runtime HEAD with the latest successful index stamp from `indexed_head_sha` when available, and falls back to the older @@ -325,7 +325,7 @@ freshness、compatibility、remediation field を返します。詳細な意味 | unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_failed_or_partial_index_run`。 | | database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`。 | | remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`。 | -| MCP-only session diagnostics | `mcp_session`。 | +| MCP-only session diagnostics | `mcp_session`, `rate_limit.bucket_limit`, `rate_limit.bucket_limit_rejection_count`。 | `worktree_head_changed` は、利用可能な場合は最新の成功 index stamp である `indexed_head_sha` と runtime HEAD を比較し、legacy DB だけで従来の diff --git a/changelog.d/unreleased/3780.fixed.md b/changelog.d/unreleased/3780.fixed.md new file mode 100644 index 0000000000..1036b2414f --- /dev/null +++ b/changelog.d/unreleased/3780.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3780 +affected: + - src/CodeIndex/Mcp/RateLimiter.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/RateLimiterTests.cs +--- + +## English + +- **MCP rate limiting now caps token-bucket cardinality (#3780)** — new `(tool, caller)` buckets stop at a fixed process-local limit, rejected high-cardinality callers receive a bounded retry decision, and `status` reports the bucket limit plus rejection count. + +## 日本語 + +- **MCP rate limiter が token bucket の種類数を上限管理するようになりました (#3780)** — 新しい `(tool, caller)` bucket はプロセス内の固定上限で停止し、高 cardinality の caller は bounded な retry 判定で拒否され、`status` は bucket 上限と拒否回数を報告します。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index ef457bb1e7..4b4cbb3a5a 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3150,6 +3150,7 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) ["keep_alive_max_interval_s"] = MaxKeepAliveIntervalSeconds, ["rate_limit_max_rps"] = RateLimiterOptions.MaxRefillTokensPerSecond, ["rate_limit_max_burst"] = RateLimiterOptions.MaxBurstCapacity, + ["rate_limit_max_buckets"] = RateLimiterOptions.DefaultMaxBucketCount, }, ["rate_limit"] = new JsonObject { @@ -3157,6 +3158,8 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) ["rps"] = RateLimiter.Options.RefillTokensPerSecond, ["burst"] = RateLimiter.Options.BurstCapacity, ["bucket_count"] = rateLimitDiagnostics.BucketCount, + ["bucket_limit"] = rateLimitDiagnostics.MaxBucketCount, + ["bucket_limit_rejection_count"] = rateLimitDiagnostics.BucketLimitRejectionCount, ["bucket_idle_ttl_seconds"] = rateLimitDiagnostics.BucketIdleTtlSeconds, ["next_prune_in_ms"] = rateLimitDiagnostics.NextPruneInMs, ["last_prune_age_ms"] = rateLimitDiagnostics.LastPruneAgeMs.HasValue ? JsonValue.Create(rateLimitDiagnostics.LastPruneAgeMs.Value) : null, diff --git a/src/CodeIndex/Mcp/RateLimiter.cs b/src/CodeIndex/Mcp/RateLimiter.cs index 067bb7efc4..687cfc6196 100644 --- a/src/CodeIndex/Mcp/RateLimiter.cs +++ b/src/CodeIndex/Mcp/RateLimiter.cs @@ -19,10 +19,13 @@ internal sealed class RateLimiter private DateTimeOffset _nextPruneAt = DateTimeOffset.MinValue; private DateTimeOffset? _lastPruneAt; private int _lastPrunedBucketCount; + private int _bucketLimitRejectionCount; public RateLimiter(RateLimiterOptions options, Func? clock = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); + if (_options.MaxBucketCount < 1) + throw new ArgumentOutOfRangeException(nameof(options), _options.MaxBucketCount, "Rate limiter bucket cap must be at least 1."); _clock = clock ?? (() => DateTimeOffset.UtcNow); } @@ -61,6 +64,12 @@ public RateLimiterDecision TryAcquire(string tool, string caller) PruneIdleBuckets(now); if (!_buckets.TryGetValue(key, out var bucket)) { + if (_buckets.Count >= _options.MaxBucketCount) + { + _bucketLimitRejectionCount++; + return RateLimiterDecision.Deny(RateLimiterOptions.BucketLimitRetryAfterMs); + } + bucket = new TokenBucket(_options.BurstCapacity, now); _buckets[key] = bucket; } @@ -76,6 +85,8 @@ internal RateLimiterDiagnostics SnapshotDiagnostics() return new RateLimiterDiagnostics( BucketCount: _buckets.Count, BucketIdleTtlSeconds: Math.Ceiling(_options.BucketIdleTtl.TotalSeconds), + MaxBucketCount: _options.MaxBucketCount, + BucketLimitRejectionCount: _bucketLimitRejectionCount, NextPruneInMs: ComputeNextPruneInMilliseconds(now, _nextPruneAt), LastPruneAgeMs: _lastPruneAt.HasValue ? ComputeElapsedMilliseconds(now, _lastPruneAt.Value) : null, LastPrunedBucketCount: _lastPrunedBucketCount); @@ -232,6 +243,8 @@ public RateLimiterDecision TryAcquire(DateTimeOffset now, double refillRate, dou internal readonly record struct RateLimiterDiagnostics( int BucketCount, double BucketIdleTtlSeconds, + int MaxBucketCount, + int BucketLimitRejectionCount, long NextPruneInMs, long? LastPruneAgeMs, int LastPrunedBucketCount); @@ -254,6 +267,8 @@ internal readonly record struct RateLimiterDecision(bool Allowed, long RetryAfte internal sealed class RateLimiterOptions { internal static readonly TimeSpan DefaultBucketIdleTtl = TimeSpan.FromMinutes(15); + internal const int DefaultMaxBucketCount = 4096; + internal const long BucketLimitRetryAfterMs = 1000; internal const string RpsEnvVar = "CDIDX_MCP_RATE_LIMIT_RPS"; internal const string BurstEnvVar = "CDIDX_MCP_RATE_LIMIT_BURST"; internal const string BucketIdleSecondsEnvVar = "CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS"; @@ -263,9 +278,10 @@ internal sealed class RateLimiterOptions public double RefillTokensPerSecond { get; init; } public double BurstCapacity { get; init; } public TimeSpan BucketIdleTtl { get; init; } = DefaultBucketIdleTtl; + public int MaxBucketCount { get; init; } = DefaultMaxBucketCount; public bool IsEnabled => RefillTokensPerSecond > 0 && BurstCapacity > 0; - public static RateLimiterOptions Disabled { get; } = new() { RefillTokensPerSecond = 0, BurstCapacity = 0, BucketIdleTtl = DefaultBucketIdleTtl }; + public static RateLimiterOptions Disabled { get; } = new() { RefillTokensPerSecond = 0, BurstCapacity = 0, BucketIdleTtl = DefaultBucketIdleTtl, MaxBucketCount = DefaultMaxBucketCount }; public static RateLimiterOptions FromEnvironment(Func? envReader = null, Action? warningSink = null) { @@ -313,7 +329,7 @@ public static RateLimiterOptions FromEnvironment(Func? envReade if (!string.IsNullOrWhiteSpace(bucketIdleRaw) && !TryParsePositiveTimeSpanSeconds(bucketIdleRaw, out bucketIdleTtl)) warningSink($"[cdidx-mcp] Ignoring invalid {BucketIdleSecondsEnvVar}='{FormatEnvironmentValue(bucketIdleRaw)}'. Expected a positive finite number of seconds. Falling back to the default bucket idle TTL."); - return new RateLimiterOptions { RefillTokensPerSecond = rps, BurstCapacity = burst, BucketIdleTtl = bucketIdleTtl }; + return new RateLimiterOptions { RefillTokensPerSecond = rps, BurstCapacity = burst, BucketIdleTtl = bucketIdleTtl, MaxBucketCount = DefaultMaxBucketCount }; } private static string FormatEnvironmentValue(string value) => ConsoleUi.FormatBoundedValue(value); diff --git a/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs b/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs index 550c3a30b6..7052ff9e8a 100644 --- a/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs +++ b/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs @@ -42,6 +42,8 @@ public class DocumentationStatusContractTests "recommended_action", "alternative_action", "repair_commands", + "rate_limit.bucket_limit", + "rate_limit.bucket_limit_rejection_count", ]; [Theory] diff --git a/tests/CodeIndex.Tests/RateLimiterTests.cs b/tests/CodeIndex.Tests/RateLimiterTests.cs index 609e2725cd..54a99db78f 100644 --- a/tests/CodeIndex.Tests/RateLimiterTests.cs +++ b/tests/CodeIndex.Tests/RateLimiterTests.cs @@ -102,6 +102,31 @@ public void StructuralBucketKey_DoesNotCollideOnDelimiter_Issue3816() Assert.Equal(2, limiter.BucketCount); } + [Fact] + public void HighCardinalityCallers_DoNotGrowBeyondBucketLimit_Issue3780() + { + var clock = new TestClock(); + var options = new RateLimiterOptions + { + RefillTokensPerSecond = 1.0, + BurstCapacity = 1.0, + MaxBucketCount = 2, + }; + var limiter = new RateLimiter(options, clock.Read); + + Assert.True(limiter.TryAcquire("search", "client-a").Allowed); + Assert.True(limiter.TryAcquire("search", "client-b").Allowed); + + var denied = limiter.TryAcquire("search", "client-c"); + + Assert.False(denied.Allowed); + Assert.Equal(RateLimiterOptions.BucketLimitRetryAfterMs, denied.RetryAfterMs); + Assert.Equal(2, limiter.BucketCount); + var diagnostics = limiter.SnapshotDiagnostics(); + Assert.Equal(2, diagnostics.MaxBucketCount); + Assert.Equal(1, diagnostics.BucketLimitRejectionCount); + } + [Fact] public void RetryAfterMs_ApproximatesTimeUntilNextToken() { From 75f9787cd4b3c0cee47a1153843927d6935b4867 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:42:56 +0900 Subject: [PATCH 6/8] Harden HTTP MCP health and SSE paths (#3815) --- changelog.d/unreleased/3815.fixed.md | 16 +++++ src/CodeIndex/Mcp/HttpMcpTransport.cs | 65 ++++++++++++++++--- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 60 +++++++++++++++++ 3 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 changelog.d/unreleased/3815.fixed.md diff --git a/changelog.d/unreleased/3815.fixed.md b/changelog.d/unreleased/3815.fixed.md new file mode 100644 index 0000000000..11e8ec1c57 --- /dev/null +++ b/changelog.d/unreleased/3815.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3815 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP transport hardening now covers queue slots, health JSON, and SSE frames (#3815)** — queued POST accounting reserves bounded slots before enqueue, `/healthz` replaces invalid or oversized provider JSON with a bounded degraded payload, and SSE event frames are size-capped while fan-out writes start independently per stream. + +## 日本語 + +- **HTTP MCP transport の queue slot、health JSON、SSE frame を強化しました (#3815)** — 保留 POST は enqueue 前に上限付き slot を予約し、`/healthz` は不正または過大な provider JSON を bounded な degraded payload に置き換え、SSE event frame はサイズ上限付きで各 stream への fan-out 書き込みを独立に開始します。 diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index b3d0c469ee..00b82fc033 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -36,6 +36,8 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport internal const int DefaultMaxEventStreams = 16; internal const int MaxConfiguredEventStreams = 1024; internal const int MaxRequestLogFieldCharacters = 256; + internal const int MaxHealthJsonBytes = 64 * 1024; + internal const int MaxSseEventFrameBytes = 64 * 1024; internal const string RequestLogTruncationMarker = "..."; internal const string MaxRequestBodyBytesEnvVar = "CDIDX_MCP_HTTP_MAX_REQUEST_BYTES"; internal const string MaxQueueDepthEnvVar = "CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH"; @@ -47,7 +49,10 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport internal const string EventStreamLimitRejection = "event_stream_limit"; internal const string LoopbackAuthDisabledWarning = "HTTP MCP is running on a loopback listener without bearer authentication; local processes can connect."; private const string BearerPrefix = "Bearer "; + private const string DefaultStartingHealthJson = """{"status":"starting","db_open":false}"""; + private const string InvalidHealthJson = """{"status":"degraded","db_open":false,"error":"health_provider_invalid"}"""; private static readonly TimeSpan EventStreamDisconnectProbeInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan EventStreamWriteTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan DisposeAcceptLoopTimeout = TimeSpan.FromSeconds(5); private readonly HttpListener _listener; @@ -57,6 +62,7 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport private readonly ConcurrentDictionary _eventStreams = new(); private readonly CancellationTokenSource _acceptCts = new(); private readonly Channel _requestQueue; + private readonly SemaphoreSlim _queueSlots; private readonly SemaphoreSlim _handlerSemaphore; private readonly int _maxRequestBodyBytes; private readonly int _maxQueuedRequests; @@ -137,6 +143,7 @@ internal HttpMcpTransport( FullMode = BoundedChannelFullMode.Wait, AllowSynchronousContinuations = false, }); + _queueSlots = new SemaphoreSlim(_maxQueuedRequests, _maxQueuedRequests); if (bearerToken is { Length: > 0 } && !McpAuthenticationLimits.IsTokenShapeValid(bearerToken)) throw new ArgumentException(McpAuthenticationLimits.FormatTokenShapeError("Token"), nameof(bearerToken)); IsLoopbackBind = IsLoopbackHost(host); @@ -363,6 +370,7 @@ private static int ResolvePositiveIntOption( { var request = await _requestQueue.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); Interlocked.Decrement(ref _queuedRequestCount); + _queueSlots.Release(); _pendingRequest = request; return request.Body; } @@ -450,7 +458,7 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT return; } - var healthJson = HealthJsonProvider?.Invoke() ?? """{"status":"starting","db_open":false}"""; + var healthJson = ResolveHealthJson(HealthJsonProvider); await RespondJsonAsync(context, (int)HttpStatusCode.OK, healthJson).ConfigureAwait(false); LogRequest(request, (int)HttpStatusCode.OK); return; @@ -538,11 +546,15 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT private bool TryQueueRequest(PendingRequest request) { + if (!_queueSlots.Wait(0)) + return false; + Interlocked.Increment(ref _queuedRequestCount); if (_requestQueue.Writer.TryWrite(request)) return true; Interlocked.Decrement(ref _queuedRequestCount); + _queueSlots.Release(); return false; } @@ -649,16 +661,23 @@ public async Task WriteOutOfBandFrameAsync(string frame, CancellationToken cance if (_eventStreams.IsEmpty) return; + var writes = new List(_eventStreams.Count); foreach (var (id, stream) in _eventStreams) + writes.Add(WriteOutOfBandFrameToStreamAsync(id, stream, frame, cancellationToken)); + await Task.WhenAll(writes).ConfigureAwait(false); + } + + private async Task WriteOutOfBandFrameToStreamAsync(Guid id, EventStream stream, string frame, CancellationToken cancellationToken) + { + using var writeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + writeCts.CancelAfter(EventStreamWriteTimeout); + try { - try - { - await stream.WriteJsonRpcEventAsync(frame, cancellationToken).ConfigureAwait(false); - } - catch - { - RemoveEventStream(id, stream); - } + await stream.WriteJsonRpcEventAsync(frame, writeCts.Token).ConfigureAwait(false); + } + catch + { + RemoveEventStream(id, stream); } } @@ -757,6 +776,32 @@ private async Task RespondJsonAsync(HttpListenerContext context, int statusCode, } } + private static string ResolveHealthJson(Func? provider) + { + if (provider is null) + return DefaultStartingHealthJson; + + string candidate; + try + { + candidate = provider(); + } + catch + { + return InvalidHealthJson; + } + + if (string.IsNullOrWhiteSpace(candidate) + || Encoding.UTF8.GetByteCount(candidate) > MaxHealthJsonBytes + || !JsonFrameParser.TryParseNode(candidate, McpServer.MaxJsonDepth, out var node, out _) + || node is not JsonObject) + { + return InvalidHealthJson; + } + + return candidate; + } + private static bool IsEventsPath(string? path) => string.Equals(path, "/events", StringComparison.Ordinal); @@ -871,6 +916,8 @@ public async Task WriteJsonRpcEventAsync(string frame, CancellationToken cancell builder.Append("data: ").Append(line).Append('\n'); builder.Append('\n'); var bytes = Encoding.UTF8.GetBytes(builder.ToString()); + if (bytes.Length > MaxSseEventFrameBytes) + throw new InvalidDataException($"SSE event frame exceeds {MaxSseEventFrameBytes.ToString(CultureInfo.InvariantCulture)} bytes."); await WriteSseBytesAsync(bytes, cancellationToken).ConfigureAwait(false); } diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 82a3fe5fdd..1c4e2000d2 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -169,6 +169,40 @@ public async Task HttpTransport_Healthz_ReportsResponseCleanupFailures_Issue3452 Assert.Equal("test close cleanup:invalid_operation:InvalidOperationException", root.GetProperty("http_response_close_cleanup_last_error").GetString()); } + [Fact] + public async Task HttpTransport_Healthz_ReplacesInvalidProviderJson_Issue3815() + { + await using var harness = await McpHttpHarness.StartAsync(_dbPath); + harness.SetHealthJsonProvider(() => "not-json"); + + using var client = new HttpClient(); + using var response = await client.GetAsync(new Uri(new Uri(harness.Endpoint), "healthz")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + Assert.Equal("degraded", root.GetProperty("status").GetString()); + Assert.Equal("health_provider_invalid", root.GetProperty("error").GetString()); + } + + [Fact] + public async Task HttpTransport_Healthz_ReplacesOversizedProviderJson_Issue3815() + { + var oversizedJson = $$"""{"status":"{{new string('x', HttpMcpTransport.MaxHealthJsonBytes)}}","db_open":true}"""; + await using var harness = await McpHttpHarness.StartAsync(_dbPath); + harness.SetHealthJsonProvider(() => oversizedJson); + + using var client = new HttpClient(); + using var response = await client.GetAsync(new Uri(new Uri(harness.Endpoint), "healthz")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain(new string('x', 128), body, StringComparison.Ordinal); + using var document = JsonDocument.Parse(body); + Assert.Equal("health_provider_invalid", document.RootElement.GetProperty("error").GetString()); + } + [Fact] public async Task HttpTransport_RequestLogger_RecordsMethodStatusDurationAndAuthOutcome() { @@ -699,10 +733,12 @@ public async Task HttpTransport_RequestQueueFull_Returns429() AssertTooManyRequests(second, HttpMcpTransport.RequestQueueLimitRejection); Assert.Equal(1, transport.RequestQueueLimitRejectionCount); + Assert.Equal(1, transport.QueuedRequestCount); var frame = await transport.ReadFrameAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); Assert.NotNull(frame); Assert.Contains("\"id\":1", frame, StringComparison.Ordinal); + Assert.Equal(0, transport.QueuedRequestCount); await transport.WriteFrameAsync("""{"jsonrpc":"2.0","id":1,"result":{}}""", CancellationToken.None); using var firstResponse = await first.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); @@ -796,6 +832,19 @@ public async Task HttpTransport_EventsStream_EmitsOptInKeepAliveNotifications() Assert.Contains("\"uptime_s\":", frame, StringComparison.Ordinal); } + [Fact] + public async Task HttpTransport_EventsStream_OversizedKeepAliveDisconnectsStream_Issue3815() + { + await using var harness = await McpHttpHarness.StartAsync(_dbPath); + harness.SetKeepAlive(TimeSpan.FromMilliseconds(10), () => new string('x', HttpMcpTransport.MaxSseEventFrameBytes)); + + using var client = new HttpClient(); + using var events = await client.GetAsync(new Uri(new Uri(harness.Endpoint), "events"), HttpCompletionOption.ResponseHeadersRead); + + Assert.Equal(HttpStatusCode.OK, events.StatusCode); + await WaitUntilAsync(() => harness.EventStreamCount == 0, "oversized keep-alive frame to close the event stream"); + } + [Fact] public async Task HttpTransport_EventsStream_RemovesDisconnectedStreams() { @@ -1306,6 +1355,15 @@ private McpHttpHarness(McpServer server, HttpMcpTransport transport, Cancellatio public void RecordResponseCleanupFailure(string kind, string operation, Exception exception) => _transport.RecordResponseCleanupFailure(kind, operation, exception); + public void SetHealthJsonProvider(Func provider) + => _transport.HealthJsonProvider = provider; + + public void SetKeepAlive(TimeSpan interval, Func provider) + { + _transport.KeepAliveInterval = interval; + _transport.KeepAliveFrameProvider = provider; + } + public static async Task StartAsync( string dbPath, string? bearerToken = null, @@ -1332,6 +1390,8 @@ public static async Task StartAsync( // background task may not have entered GetContextAsync yet by the time the test posts. // listener が GetContextAsync に入る前に POST が来ないよう、ごく短い待機を挟む。 await Task.Yield(); + for (var i = 0; i < 100 && transport.HealthJsonProvider is null && !loopTask.IsCompleted; i++) + await Task.Delay(10); if (loopTask.IsCompleted) await loopTask.ConfigureAwait(false); return new McpHttpHarness(server, transport, cts, loopTask, listen.Prefix); From 935fc736dde09a3457a6c246c945387274261a0f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:43:27 +0900 Subject: [PATCH 7/8] Reject ambiguous HTTP MCP Authorization headers (#3756) --- DEVELOPER_GUIDE.md | 4 +- USER_GUIDE.md | 4 +- changelog.d/unreleased/3756.fixed.md | 16 +++++++ src/CodeIndex/Mcp/HttpMcpTransport.cs | 29 +++++++++-- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 48 +++++++++++++++++++ 5 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/3756.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index b1a76fe24a..7a2abe8cc0 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1860,6 +1860,8 @@ Piping `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` into via `CryptographicOperations.FixedTimeEquals`. Unset or empty configured tokens keep the stdio gate disabled, while configured tokens must be 1-4096 characters and cannot contain whitespace or control characters (#3505). + HTTP bearer tokens additionally reject commas at startup because commas + are reserved for rejecting ambiguous `Authorization` headers (#3756). HTTP does not also use this body-token gate: `ProgramRunner` resolves a bearer secret for the HTTP transport from `CDIDX_MCP_HTTP_TOKEN`, falling back to `CDIDX_MCP_AUTH_TOKEN` when the HTTP-specific variable is unset, @@ -1962,7 +1964,7 @@ return `-32600`. either token to keep the MCP catalog off the LAN by default. Unset or empty configured bearer tokens disable the HTTP token gate where allowed by the listen host policy, while configured tokens must be 1-4096 characters and - cannot contain whitespace or control characters (#3505). Supplied HTTP + cannot contain whitespace, control characters, or commas (#3505, #3756). Supplied HTTP bearer values are compared exactly after the `Bearer ` prefix: they are not trimmed, and invalid-shape or oversized values are rejected before hashing. - Optional request-loop logging: `ProgramRunner` connects `HttpMcpTransport` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 361bd8540a..bb4193ecba 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2410,7 +2410,7 @@ 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` or `CDIDX_MCP_AUTH_TOKEN` to a shared secret. `CDIDX_MCP_HTTP_TOKEN` wins when both are set. When an HTTP bearer secret is configured, every request must carry `Authorization: Bearer ` or the listener returns `401 Unauthorized` with `WWW-Authenticate: Bearer realm="cdidx-mcp"`; HTTP clients do not also need `params.auth.token`. -- 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. Leaving the token variable unset, or setting it to the empty string, disables the token gate. Any configured token must be 1-4096 characters and must not contain whitespace or control characters. Supplied HTTP bearer values use the exact bytes after `Bearer ` and are not trimmed before comparison; oversized, whitespace-containing, or control-character-bearing values are rejected before hashing. +- 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. Leaving the token variable unset, or setting it to the empty string, disables the token gate. Any configured HTTP bearer token must be 1-4096 characters and must not contain whitespace, control characters, or commas. Supplied HTTP bearer values use the exact bytes after `Bearer ` and are not trimmed before comparison; duplicate or comma-joined `Authorization` headers, oversized values, whitespace-containing values, and control-character-bearing values are rejected before hashing. The stdio transport stays byte-for-byte unchanged, so existing client configs keep working without modification. @@ -4943,7 +4943,7 @@ HTTP の `POST /` 1 件が JSON-RPC フレーム 1 件に対応し、応答は - listener は既定で loopback アドレス(`127.0.0.1`)のみに bind し、ワイルドカード `+` / `*` は最初から拒否します。 - 非 loopback ホスト(例: `0.0.0.0:9000`)に bind するには `CDIDX_MCP_HTTP_TOKEN` または `CDIDX_MCP_AUTH_TOKEN` で共有秘密を指定する必要があります。両方が設定されている場合は `CDIDX_MCP_HTTP_TOKEN` が優先されます。HTTP bearer secret が設定されている場合、すべてのリクエストに `Authorization: Bearer ` ヘッダーが必要で、欠落・不一致は `401 Unauthorized`(`WWW-Authenticate: Bearer realm="cdidx-mcp"` 付き)です。HTTP クライアントは `params.auth.token` も送る必要はありません。 -- 設定トークンの SHA-256 digest はサーバー起動時に一度だけ計算してメモリ保持し、リクエスト毎の認証では受信トークンのみハッシュ計算して FixedTimeEquals で比較します。設定トークン側はリクエスト毎にハッシュしないため、長さやバイト列が timing から漏れません。設定 token と受信 token は 4096 文字を超える場合、hash 前に拒否します。 +- 設定トークンの SHA-256 digest はサーバー起動時に一度だけ計算してメモリ保持し、リクエスト毎の認証では受信トークンのみハッシュ計算して FixedTimeEquals で比較します。設定トークン側はリクエスト毎にハッシュしないため、長さやバイト列が timing から漏れません。HTTP bearer の設定 token は 1-4096 文字で、空白、制御文字、comma を含められません。受信 token は 4096 文字を超える場合、hash 前に拒否します。重複または comma 結合された `Authorization` ヘッダーも bearer 比較前に拒否します。 stdio トランスポートはバイト単位で挙動が変わらないため、既存クライアント設定はそのまま動作します。 diff --git a/changelog.d/unreleased/3756.fixed.md b/changelog.d/unreleased/3756.fixed.md new file mode 100644 index 0000000000..873f3805a0 --- /dev/null +++ b/changelog.d/unreleased/3756.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3756 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP rejects ambiguous Authorization headers before bearer comparison (#3756)** — duplicate or comma-joined `Authorization` values now fail with the same redacted unauthorized response as other bearer failures, and configured HTTP bearer tokens reject commas at startup so accepted tokens remain authenticateable. + +## 日本語 + +- **HTTP MCP が曖昧な Authorization ヘッダーを bearer 比較前に拒否するようになりました (#3756)** — 重複または comma 結合された `Authorization` 値は、他の bearer 失敗と同じ redacted な unauthorized 応答で失敗し、設定済み HTTP bearer token は起動時に comma を拒否するため、受理された token は認証可能なままです。 diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 00b82fc033..2981e39a15 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Collections.Specialized; using System.Net; using System.Net.Sockets; using System.Numerics; @@ -146,6 +147,8 @@ internal HttpMcpTransport( _queueSlots = new SemaphoreSlim(_maxQueuedRequests, _maxQueuedRequests); if (bearerToken is { Length: > 0 } && !McpAuthenticationLimits.IsTokenShapeValid(bearerToken)) throw new ArgumentException(McpAuthenticationLimits.FormatTokenShapeError("Token"), nameof(bearerToken)); + if (bearerToken is { Length: > 0 } && bearerToken.Contains(',', StringComparison.Ordinal)) + throw new ArgumentException("HTTP bearer token must not contain commas; commas are reserved for rejecting ambiguous Authorization headers.", nameof(bearerToken)); IsLoopbackBind = IsLoopbackHost(host); if (string.IsNullOrEmpty(bearerToken) && !IsLoopbackBind) throw new ArgumentException("HTTP MCP requires bearer authentication when binding outside loopback.", nameof(bearerToken)); @@ -694,10 +697,9 @@ private async Task TryAuthorizeAsync(PendingRequest request) // `authorization: bearer ...` are valid and must be accepted. // RFC 6750 §2.1 で auth-scheme は case-insensitive と規定されているため、 // `bearer ...` のような小文字スキームも受理する。 - var header = context.Request.Headers["Authorization"]; - if (string.IsNullOrEmpty(header)) + if (!TryReadSingleAuthorizationHeader(context.Request.Headers, out var header, out var headerFailure)) { - request.AuthOutcome = FormatAuthFailureOutcome("missing"); + request.AuthOutcome = FormatAuthFailureOutcome(headerFailure); } else if (TryExtractBearerToken(header, out var provided)) { @@ -728,6 +730,27 @@ private async Task TryAuthorizeAsync(PendingRequest request) private static string FormatAuthFailureOutcome(string detailedOutcome) => McpServer.IsUnsafeDebugEnabled() ? detailedOutcome : "unauthorized"; + private static bool TryReadSingleAuthorizationHeader(NameValueCollection headers, out string header, out string failure) + { + header = string.Empty; + var values = headers.GetValues("Authorization"); + if (values is null || values.Length == 0 || values.All(string.IsNullOrEmpty)) + { + failure = "missing"; + return false; + } + + if (values.Length != 1 || values[0].IndexOf(',', StringComparison.Ordinal) >= 0) + { + failure = "ambiguous"; + return false; + } + + header = values[0]; + failure = string.Empty; + return true; + } + private static bool TryExtractBearerToken(string header, out string? token) { token = null; diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 1c4e2000d2..4954796a16 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -34,6 +34,15 @@ public HttpMcpTransportTests() _db.InitializeSchema(); } + [Fact] + public void HttpTransport_Ctor_RejectsCommaBearerToken_Issue3756() + { + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + var ex = Assert.Throws(() => new HttpMcpTransport(listen.Prefix, listen.Host, listen.Port, bearerToken: "abc,def")); + + Assert.Contains("commas", ex.Message, StringComparison.Ordinal); + } + [Fact] public async Task HttpTransport_PostInitialize_ReturnsHandshakeResult() { @@ -1009,6 +1018,45 @@ public async Task HttpTransport_BearerToken_RejectsMissingHeader() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task HttpTransport_BearerToken_RejectsDuplicateAuthorizationHeaders_Issue3756() + { + const string token = "s3cret-token"; + 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", new[] { $"Bearer {token}", $"Bearer {token}" }); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain(token, body, StringComparison.Ordinal); + var record = Assert.Single(await WaitForRequestLogRecordsAsync(records, 1)); + Assert.Equal("unauthorized", record.AuthOutcome); + } + + [Fact] + public async Task HttpTransport_BearerToken_RejectsCommaJoinedAuthorizationHeader_Issue3756() + { + const string token = "s3cret-token"; + await using var harness = await McpHttpHarness.StartAsync(_dbPath, bearerToken: token); + + 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 {token}, Bearer {token}"); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + [Fact] public async Task HttpTransport_BearerToken_AcceptsMatchingHeader() { From 268213e6c05cf7dd1a40ad57419a03ee10048635 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:52:54 +0900 Subject: [PATCH 8/8] Fix MCP rate-limit status contract docs (#3780) --- AGENT_GUIDE.md | 4 ++-- DEVELOPER_GUIDE.md | 4 ++-- README.md | 4 ++-- tests/CodeIndex.Tests/DocumentationStatusContractTests.cs | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 662450782c..64a28bffb0 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -131,7 +131,7 @@ CI watching must be bounded. Do not loop indefinitely. ## Status Contract -- `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`, `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, `rate_limit.bucket_limit`, `rate_limit.bucket_limit_rejection_count`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. +- `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`, `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. - When any readiness field is degraded, the CLI adds `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, and `readiness_degradations[]`. `degraded_root_cause` is the primary stable machine code; `readiness_degradations[]` lists every degraded field with `root_cause`, human reason, and remediation strings. - `hotspot_family_degraded_reason` currently uses `hotspot_family_support_not_indexed`, `hotspot_family_metadata_stale`, `hotspot_family_disabled_at_index_time`, `partial_family_key_population`, and `hotspot_family_marker_fingerprint_incomplete`; the incomplete marker fingerprint code means marker traversal hit safety caps and should stay synchronized with README / developer-guide recovery notes. - `issues_table_available` reports physical `file_issues` table presence only. `file_issues_data_current` reports whether the table is also stamped current for the active index generation. @@ -145,7 +145,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status` also surfaces filesystem case-sensitivity via `path_case_sensitive`, stamped on every successful `cdidx index` run (full scan AND partial update, plus MCP-driven indexes) from `core.ignorecase` + a live filesystem probe. `true` means the volume is case-sensitive (`Foo.cs` and `foo.cs` are distinct); `false` means case-insensitive. Omitted on legacy DBs that predate the stamp. Use it to audit path-equality decisions on case-sensitive APFS, WSL NTFS / dev-drive, and ReFS mounts where the prior OS-keyed heuristic could mis-classify the workspace (#1546). - `status` also surfaces Linux mandatory-access-control context via `mac_profile` when `/proc/self/attr/current` or `/proc/self/attr/exec` indicates an AppArmor or SELinux profile. If proc attribute reads fail on Linux, `mac_profile_diagnostics[]` reports bounded `path`, `category`, and `message` entries so users can distinguish "no profile" from "profile detection failed" (#1768, #3480). - `status` also surfaces DB/WAL size, per-language symbol-kind histograms, current process heap/GC/working-set metrics, and the last successful index run metadata. `process` is captured at status-call time; `last_index_run` is persisted at the end of successful CLI and MCP index runs and can include a peak-memory summary when CLI `--memory-trace` was used. `last_index_run.bytes_read_skipped_file_count` and `bytes_read_incomplete` report whether unreadable files were omitted from the `bytes_read` total. `last_index_run.diagnostics`, `diagnostic_count`, and `diagnostics_truncated` carry bounded warnings for best-effort index metadata writes that failed after the index data itself was successfully written. `last_workspace_freshened_at` is the latest successful index/update timestamp and can be newer than `indexed_at` when a partial or no-op update confirms freshness without rewriting indexed file rows. -- MCP `status` also surfaces session diagnostics via `mcp_session` and rate limiter bucket cap diagnostics via `rate_limit.bucket_limit` / `rate_limit.bucket_limit_rejection_count`. `mcp_session` is not persisted DB state; it includes the current `log_level`, bounded captured `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. `rate_limit.bucket_limit` is the process-local `(tool, caller)` bucket cap, and `rate_limit.bucket_limit_rejection_count` counts calls denied because creating a new bucket would exceed that cap. +- MCP `status` also surfaces session diagnostics via `mcp_session` and rate limiter bucket cap diagnostics via `mcp.rate_limit.bucket_limit` / `mcp.rate_limit.bucket_limit_rejection_count`. `mcp_session` is not persisted DB state; it includes the current `log_level`, bounded captured `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. `mcp.rate_limit.bucket_limit` is the process-local `(tool, caller)` bucket cap, and `mcp.rate_limit.bucket_limit_rejection_count` counts calls denied because creating a new bucket would exceed that cap. - Keep `README.md`, `DEVELOPER_GUIDE.md`, and this file synchronized if this contract changes. ## Reference Extraction diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7a2abe8cc0..0587eba6db 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1063,7 +1063,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int | Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_failed_or_partial_index_run`. | | Database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`. | | Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | -| MCP-only session diagnostics | `mcp_session`, `rate_limit.bucket_limit`, and `rate_limit.bucket_limit_rejection_count`. `mcp_session` is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. `rate_limit.bucket_limit` is the configured process-local `(tool, caller)` bucket cap, and `rate_limit.bucket_limit_rejection_count` counts calls denied because creating a new bucket would exceed that cap. | +| MCP-only session diagnostics | `mcp_session`, `mcp.rate_limit.bucket_limit`, and `mcp.rate_limit.bucket_limit_rejection_count`. `mcp_session` is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. `mcp.rate_limit.bucket_limit` is the configured process-local `(tool, caller)` bucket cap, and `mcp.rate_limit.bucket_limit_rejection_count` counts calls denied because creating a new bucket would exceed that cap. | | Documentation sync | Keep this list synchronized with `README.md` and `AGENT_GUIDE.md`; `DocumentationStatusContractTests` fails when any required field is missing from one of those docs. | Runtime diagnostic subcontracts: @@ -3298,7 +3298,7 @@ AI エージェント向け検索ルールのテンプレートについては | unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_failed_or_partial_index_run`。 | | database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`。 | | remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`。 | -| MCP-only session diagnostics | `mcp_session`, `rate_limit.bucket_limit`, `rate_limit.bucket_limit_rejection_count`。`mcp_session` は persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、上限付きの任意の `client_capabilities` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。client capabilities が切り詰められた場合は `client_capabilities_truncated`、`client_capabilities_truncation_reason`、`client_capabilities_serialized_bytes`、`client_capabilities_byte_limit`、`client_capabilities_depth_limit` が保持された診断 subset を示します。`rate_limit.bucket_limit` は process-local な `(tool, caller)` bucket 上限、`rate_limit.bucket_limit_rejection_count` は新規 bucket 作成がその上限を超えるため拒否された呼び出し数です。 | +| MCP-only session diagnostics | `mcp_session`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`。`mcp_session` は persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、上限付きの任意の `client_capabilities` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。client capabilities が切り詰められた場合は `client_capabilities_truncated`、`client_capabilities_truncation_reason`、`client_capabilities_serialized_bytes`、`client_capabilities_byte_limit`、`client_capabilities_depth_limit` が保持された診断 subset を示します。`mcp.rate_limit.bucket_limit` は process-local な `(tool, caller)` bucket 上限、`mcp.rate_limit.bucket_limit_rejection_count` は新規 bucket 作成がその上限を超えるため拒否された呼び出し数です。 | | documentation sync | この一覧は `README.md` と `AGENT_GUIDE.md` と同期してください。必須 field がそれらの docs から欠けると `DocumentationStatusContractTests` が失敗します。 | runtime diagnostic subcontract: diff --git a/README.md b/README.md index 5a1c62352a..14e5ec9fe2 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ names visible so documentation and tests stay synchronized. | Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_failed_or_partial_index_run`. | | Database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`. | | Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | -| MCP-only session diagnostics | `mcp_session`, `rate_limit.bucket_limit`, `rate_limit.bucket_limit_rejection_count`. | +| MCP-only session diagnostics | `mcp_session`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`. | `worktree_head_changed` compares the runtime HEAD with the latest successful index stamp from `indexed_head_sha` when available, and falls back to the older @@ -325,7 +325,7 @@ freshness、compatibility、remediation field を返します。詳細な意味 | unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `extractors`, `hooks`, `hook_diagnostics`, `trust_overrides`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `mac_profile_diagnostics`, `stale_after_seconds`, `index_age_seconds`, `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_failed_or_partial_index_run`。 | | database maintenance | `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`。 | | remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`。 | -| MCP-only session diagnostics | `mcp_session`, `rate_limit.bucket_limit`, `rate_limit.bucket_limit_rejection_count`。 | +| MCP-only session diagnostics | `mcp_session`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`。 | `worktree_head_changed` は、利用可能な場合は最新の成功 index stamp である `indexed_head_sha` と runtime HEAD を比較し、legacy DB だけで従来の diff --git a/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs b/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs index 7052ff9e8a..44e8ab1049 100644 --- a/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs +++ b/tests/CodeIndex.Tests/DocumentationStatusContractTests.cs @@ -42,8 +42,8 @@ public class DocumentationStatusContractTests "recommended_action", "alternative_action", "repair_commands", - "rate_limit.bucket_limit", - "rate_limit.bucket_limit_rejection_count", + "mcp.rate_limit.bucket_limit", + "mcp.rate_limit.bucket_limit_rejection_count", ]; [Theory]