diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ac673479ba..f24adc335e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1436,6 +1436,13 @@ frames cannot switch encodings, output is BOM-less UTF-8, 64 KiB buffer, `AutoFlush = true`). Malformed UTF-8 bytes raise a transport decode failure that the MCP loop maps to JSON-RPC `-32700` with an invalid-UTF-8 hint instead of silently replacing bytes with U+FFFD. +JSON-RPC frames are also bounded before dispatch: at most 1,000,000 UTF-16 +characters, at most 1,048,576 UTF-8 bytes, and JSON nesting depth 32. Oversized, +malformed, or too-deep frames return `-32700` with `id: null`; MCP `status` +surfaces the active limits under `mcp.limits`. JSON-RPC batch arrays are +supported up to 100 items: each item is dispatched through the same single-request +path, notification-only batches produce no response, and empty or nested batches +return `-32600`. `HttpMcpTransport` (also #1558) wraps `System.Net.HttpListener`: diff --git a/changelog.d/unreleased/1799-1996.fixed.md b/changelog.d/unreleased/1799-1996.fixed.md new file mode 100644 index 0000000000..17840ac39a --- /dev/null +++ b/changelog.d/unreleased/1799-1996.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1799 + - 1996 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **MCP JSON-RPC input now enforces character, UTF-8 byte, and nesting-depth limits (#1799, #1996)** - request frames are capped before dispatch, multi-byte UTF-8 payloads can no longer bypass the byte ceiling, and MCP `status` exposes the configured limits under `mcp.limits`. + +## 日本語 + +- **MCP JSON-RPC 入力で文字数・UTF-8 バイト数・ネスト深さの上限を適用するようになりました (#1799, #1996)** - request frame は dispatch 前に制限され、マルチバイト UTF-8 payload がバイト上限をすり抜けなくなり、MCP `status` は設定済み上限を `mcp.limits` に表示します。 diff --git a/changelog.d/unreleased/1816.fixed.md b/changelog.d/unreleased/1816.fixed.md new file mode 100644 index 0000000000..9241c33233 --- /dev/null +++ b/changelog.d/unreleased/1816.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1816 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Malformed MCP JSON-RPC frames now return parse errors with `id: null` (#1816)** - invalid JSON now produces JSON-RPC `-32700` parse-error responses with a spec-compliant null id instead of omitting the id field. + +## 日本語 + +- **不正な MCP JSON-RPC フレームが `id: null` 付きの parse error を返すようになりました (#1816)** - 不正な JSON は id フィールドを省略せず、JSON-RPC 仕様どおり null id を持つ `-32700` parse error として返ります。 diff --git a/changelog.d/unreleased/1892.added.md b/changelog.d/unreleased/1892.added.md new file mode 100644 index 0000000000..f84fdbcda5 --- /dev/null +++ b/changelog.d/unreleased/1892.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1892 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **MCP JSON-RPC batch arrays are now supported (#1892)** - batch frames dispatch each request through the normal MCP path, omit responses for notification-only batches, and reject empty or nested batches with clear `-32600` errors. + +## 日本語 + +- **MCP JSON-RPC の batch array に対応しました (#1892)** - batch frame は各 request を通常の MCP 経路で dispatch し、notification のみの batch は応答なし、空またはネストした batch は明確な `-32600` エラーで拒否します。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index d802ec328f..5c454cfe32 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -135,9 +135,12 @@ public partial class McpServer : IDisposable // `startLine - before` が underflow、`endLine + after` が overflow し、`Math.Max/Min` で clamp // する前に slice 経路が破綻していたため、CLI の `--before` / `--after` 上限と揃える(#1528)。 private const int MaxContextLines = 1000; - private const int MaxLineLength = 1_000_000; // 1 MB per JSON-RPC message / 1メッセージあたり最大1MB + internal const int MaxLineCharacterCount = 1_000_000; + internal const int MaxLineByteLength = 1_048_576; + internal const int MaxJsonDepth = 32; + internal const int MaxBatchRequestCount = 100; // Stdio buffer for the JSON-RPC loop. Sized to fit typical large MCP payloads (e.g. batch_query) - // in a single read so the StreamReader does not grow from its 1 KB default toward MaxLineLength. + // in a single read so the StreamReader does not grow from its 1 KB default toward MaxLineCharacterCount. // JSON-RPCループのstdioバッファ。大きめのMCPペイロードを1回の読み取りで吸収し、 // StreamReaderのデフォルト1KBから繰り返し拡張されるのを避けるサイズ。 private const int StdioBufferSize = 64 * 1024; @@ -664,12 +667,13 @@ internal static string BuildInvalidUtf8ErrorLog(string detail) // Reject oversized messages to prevent memory exhaustion // メモリ枯渇を防ぐため巨大メッセージを拒否 - if (line.Length > MaxLineLength) + var byteLength = Encoding.UTF8.GetByteCount(line); + if (line.Length > MaxLineCharacterCount || byteLength > MaxLineByteLength) { - DeferFrameLog(BuildOversizedMessageLog(line.Length)); - var errorResponse = CreateErrorResponse(null, -32700, "Message too large", + DeferFrameLog(BuildOversizedMessageLog(line.Length, byteLength)); + var errorResponse = CreateErrorResponse(hasId: true, id: null, code: -32700, message: "Message too large", category: McpErrorEnvelope.CategoryMessageTooLarge, - suggestion: $"JSON-RPC frame exceeds the {MaxLineLength} byte cap. Split the request into smaller calls or use `batch_query` with smaller slots.", + suggestion: $"JSON-RPC frame exceeds the {MaxLineCharacterCount} character or {MaxLineByteLength} byte cap. Split the request into smaller calls or use `batch_query` with smaller slots.", retrySafe: false); return errorResponse.ToJsonString(_jsonOptions); } @@ -679,7 +683,7 @@ internal static string BuildInvalidUtf8ErrorLog(string detail) JsonNode? responseId = null; try { - request = JsonNode.Parse(line); + request = JsonNode.Parse(line, documentOptions: new JsonDocumentOptions { MaxDepth = MaxJsonDepth }); if (request == null) return null; @@ -691,9 +695,9 @@ internal static string BuildInvalidUtf8ErrorLog(string detail) { // Parse error / パースエラー DeferFrameLog(BuildJsonParseErrorLog(ex.Message)); - var errorResponse = CreateErrorResponse(null, -32700, "Parse error", + var errorResponse = CreateErrorResponse(hasId: true, id: null, code: -32700, message: "Parse error", category: McpErrorEnvelope.CategoryParseError, - suggestion: "Send valid JSON-RPC 2.0 framed as a single line of UTF-8 JSON.", + suggestion: $"Send valid JSON-RPC 2.0 framed as a single line of UTF-8 JSON with nesting depth <= {MaxJsonDepth}.", retrySafe: false); return errorResponse.ToJsonString(_jsonOptions); } @@ -797,6 +801,9 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id /// internal JsonNode? HandleMessage(JsonNode request) { + if (request is JsonArray batch) + return HandleBatchMessage(batch); + if (request is not JsonObject obj) return CreateErrorResponse(hasId: false, id: null, code: -32600, message: "Invalid request: expected JSON object", category: McpErrorEnvelope.CategoryInvalidRequest, @@ -908,6 +915,57 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id }); } + private JsonNode? HandleBatchMessage(JsonArray batch) + { + if (batch.Count == 0) + return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: empty batch", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "JSON-RPC 2.0 batch requests must contain at least one request object.", + retrySafe: false); + + if (batch.Count > MaxBatchRequestCount) + return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: batch too large", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: $"JSON-RPC batch requests are limited to {MaxBatchRequestCount} items.", + retrySafe: false); + + var responses = new JsonArray(); + foreach (var item in batch) + { + JsonNode? response; + if (item is null) + { + response = CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: expected JSON object", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Each JSON-RPC batch item must be a request object.", + retrySafe: false); + } + else if (item is JsonArray) + { + response = CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: nested batches are not supported", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "JSON-RPC batch items must be request objects, not nested arrays.", + retrySafe: false); + } + else if (item is not JsonObject) + { + response = CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: expected JSON object", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Each JSON-RPC batch item must be a request object.", + retrySafe: false); + } + else + { + response = HandleMessage(item); + } + + if (response != null) + responses.Add(response); + } + + return responses.Count == 0 ? null : responses; + } + private JsonNode DispatchWithRequestCancellation(JsonNode? id, Func action) { var requestKey = SerializeRequestId(id); @@ -959,7 +1017,7 @@ private static bool IsCancellationFrame(string frame) { try { - var node = JsonNode.Parse(frame); + var node = JsonNode.Parse(frame, documentOptions: new JsonDocumentOptions { MaxDepth = MaxJsonDepth }); if (node is not JsonObject obj) return false; var method = obj["method"]?.GetValue(); @@ -1897,8 +1955,8 @@ internal static (IReadOnlyList Keys, IReadOnlyList - $"[cdidx-mcp] Message too large ({lineLength} bytes), rejecting. Split the request into smaller JSON-RPC messages or shorter arguments, then retry."; + internal static string BuildOversizedMessageLog(int characterCount, int byteCount) => + $"[cdidx-mcp] Message too large ({characterCount} chars / {byteCount} bytes), rejecting. Split the request into smaller JSON-RPC messages or shorter arguments, then retry."; internal static string BuildJsonParseErrorLog(string detail) => $"[cdidx-mcp] JSON parse error: {detail}. Send one UTF-8 JSON-RPC object per line and retry."; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 1f78ec9f25..e13f39e725 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -16,7 +16,7 @@ namespace CodeIndex.Mcp; /// public partial class McpServer { - private const int DefaultBatchQueryResponseByteLimit = MaxLineLength; + private const int DefaultBatchQueryResponseByteLimit = MaxLineByteLength; private const string BatchQueryResponseByteLimitEnvVar = "CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES"; internal const int MaxMcpArrayFilterCount = 100; internal const int MaxMcpArrayFilterStringLength = 4096; @@ -1344,6 +1344,16 @@ private JsonNode ExecuteStatus(JsonNode? id) structured["sqlGraphContractReady"] = status.SqlGraphContractReady; if (status.SqlGraphContractDegradedReason != null) structured["sqlGraphContractDegradedReason"] = status.SqlGraphContractDegradedReason; + structured["mcp"] = new JsonObject + { + ["limits"] = new JsonObject + { + ["max_request_characters"] = MaxLineCharacterCount, + ["max_request_bytes"] = MaxLineByteLength, + ["max_json_depth"] = MaxJsonDepth, + ["max_batch_requests"] = MaxBatchRequestCount, + } + }; return CreateToolResult(id, "Database stats returned.", structured); }); } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 15bfae8018..83797609eb 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -858,9 +858,11 @@ public void McpAuthenticatorFactory_TokenSet_ReturnsTokenAuthenticator() [Fact] public void BuildOversizedMessageLog_IsActionable() { - var message = McpServer.BuildOversizedMessageLog(1_234_567); + var message = McpServer.BuildOversizedMessageLog(1_234_567, 1_500_000); Assert.Contains("Message too large", message); + Assert.Contains("chars", message); + Assert.Contains("bytes", message); Assert.Contains("Split the request into smaller JSON-RPC messages", message); Assert.Contains("retry", message); } @@ -9078,9 +9080,84 @@ public void ErrorResponse_ProcessFrame_ParseError_CarriesEnvelope() var response = JsonNode.Parse(raw!)!; var error = response["error"]!; Assert.Equal(-32700, error["code"]!.GetValue()); + AssertJsonNullId(response); AssertEnvelope(error["data"], "parse_error", expectedRetrySafe: false); } + [Fact] + public void ProcessFrame_OversizedUtf8Bytes_ReturnsParseErrorWithNullId() + { + var multibyte = new string('\u3042', (McpServer.MaxLineByteLength / 3) + 1); + var raw = _server.ProcessFrame("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\",\"params\":{\"q\":\"" + multibyte + "\"}}"); + + var response = JsonNode.Parse(raw!)!; + Assert.Equal(-32700, response["error"]!["code"]!.GetValue()); + Assert.Equal("message_too_large", response["error"]!["data"]!["category"]!.GetValue()); + AssertJsonNullId(response); + } + + [Fact] + public void ProcessFrame_TooDeepJson_ReturnsParseErrorWithNullId() + { + var raw = _server.ProcessFrame(new string('[', McpServer.MaxJsonDepth + 2) + "0" + new string(']', McpServer.MaxJsonDepth + 2)); + + var response = JsonNode.Parse(raw!)!; + Assert.Equal(-32700, response["error"]!["code"]!.GetValue()); + Assert.Equal("parse_error", response["error"]!["data"]!["category"]!.GetValue()); + AssertJsonNullId(response); + } + + [Fact] + public void HandleMessage_BatchMixedRequests_ReturnsResponseArray() + { + var batch = JsonNode.Parse("""[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/initialized"},{"jsonrpc":"2.0","id":2,"method":"nope"}]""")!; + + var response = _server.HandleMessage(batch)!.AsArray(); + + Assert.Equal(2, response.Count); + Assert.Equal(1, response[0]!["id"]!.GetValue()); + Assert.NotNull(response[0]!["result"]); + Assert.Equal(2, response[1]!["id"]!.GetValue()); + Assert.Equal(-32601, response[1]!["error"]!["code"]!.GetValue()); + } + + [Fact] + public void HandleMessage_AllNotificationBatch_ReturnsNull() + { + var batch = JsonNode.Parse("""[{"jsonrpc":"2.0","method":"notifications/initialized"}]""")!; + + Assert.Null(_server.HandleMessage(batch)); + } + + [Fact] + public void HandleMessage_EmptyBatch_ReturnsInvalidRequest() + { + var response = _server.HandleMessage(JsonNode.Parse("[]")!)!; + + Assert.Equal(-32600, response["error"]!["code"]!.GetValue()); + AssertJsonNullId(response); + } + + [Fact] + public void HandleMessage_NestedBatchItem_ReturnsInvalidRequestInBatchResponse() + { + var response = _server.HandleMessage(JsonNode.Parse("""[[{"jsonrpc":"2.0","id":1,"method":"ping"}]]""")!)!.AsArray(); + + Assert.Single(response); + Assert.Equal(-32600, response[0]!["error"]!["code"]!.GetValue()); + AssertJsonNullId(response[0]!); + } + + [Fact] + public void HandleMessage_ScalarBatchItem_ReturnsInvalidRequestWithNullId() + { + var response = _server.HandleMessage(JsonNode.Parse("""[1]""")!)!.AsArray(); + + Assert.Single(response); + Assert.Equal(-32600, response[0]!["error"]!["code"]!.GetValue()); + AssertJsonNullId(response[0]!); + } + [Fact] public void BuildData_ExtraDataCannotShadowCanonicalKeys() { @@ -9101,6 +9178,13 @@ public void BuildData_ExtraDataCannotShadowCanonicalKeys() Assert.Equal("search", data["tool"]!.GetValue()); } + private static void AssertJsonNullId(JsonNode node) + { + var obj = Assert.IsType(node); + Assert.True(obj.ContainsKey("id")); + Assert.Null(obj["id"]); + } + private static void WriteOversizedAsciiFile(string path) { const int targetBytes = 10 * 1024 * 1024 + 1;