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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
19 changes: 19 additions & 0 deletions changelog.d/unreleased/1799-1996.fixed.md
Original file line number Diff line number Diff line change
@@ -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` に表示します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/1816.fixed.md
Original file line number Diff line number Diff line change
@@ -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 として返ります。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1892.added.md
Original file line number Diff line number Diff line change
@@ -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` エラーで拒否します。
82 changes: 70 additions & 12 deletions src/CodeIndex/Mcp/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;

Expand All @@ -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);
}
Expand Down Expand Up @@ -797,6 +801,9 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id
/// </summary>
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,
Expand Down Expand Up @@ -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<JsonNode> action)
{
var requestKey = SerializeRequestId(id);
Expand Down Expand Up @@ -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<string>();
Expand Down Expand Up @@ -1897,8 +1955,8 @@ internal static (IReadOnlyList<string> Keys, IReadOnlyList<KeyValuePair<string,
return null;
}

internal static string BuildOversizedMessageLog(int lineLength) =>
$"[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.";
Expand Down
12 changes: 11 additions & 1 deletion src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace CodeIndex.Mcp;
/// </summary>
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;
Expand Down Expand Up @@ -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);
});
}
Expand Down
86 changes: 85 additions & 1 deletion tests/CodeIndex.Tests/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<int>());
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<int>());
Assert.Equal("message_too_large", response["error"]!["data"]!["category"]!.GetValue<string>());
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<int>());
Assert.Equal("parse_error", response["error"]!["data"]!["category"]!.GetValue<string>());
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<int>());
Assert.NotNull(response[0]!["result"]);
Assert.Equal(2, response[1]!["id"]!.GetValue<int>());
Assert.Equal(-32601, response[1]!["error"]!["code"]!.GetValue<int>());
}

[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<int>());
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<int>());
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<int>());
AssertJsonNullId(response[0]!);
}

[Fact]
public void BuildData_ExtraDataCannotShadowCanonicalKeys()
{
Expand All @@ -9101,6 +9178,13 @@ public void BuildData_ExtraDataCannotShadowCanonicalKeys()
Assert.Equal("search", data["tool"]!.GetValue<string>());
}

private static void AssertJsonNullId(JsonNode node)
{
var obj = Assert.IsType<JsonObject>(node);
Assert.True(obj.ContainsKey("id"));
Assert.Null(obj["id"]);
}

private static void WriteOversizedAsciiFile(string path)
{
const int targetBytes = 10 * 1024 * 1024 + 1;
Expand Down
Loading