Skip to content
Open
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
103 changes: 102 additions & 1 deletion src/ModelContextProtocol.Core/Server/StreamServerTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,32 @@ private async Task ReadMessagesAsync()
LogTransportMessageParseFailed(Name, ex);
}

// Continue reading even if we fail to parse a message
// Deserializing the full message failed, for example because the params object was nested
// more deeply than the JSON reader's MaxDepth allows. If the message still carried a request
// id, reply with a JSON-RPC parse error using that id so the caller's pending request
// completes instead of hanging until it times out. If no id can be recovered, the message
// was either a notification or too malformed to correlate, so we just continue reading.
if (TryRecoverRequestId(line, out RequestId id))
{
var errorResponse = new JsonRpcError
{
Id = id,
Error = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.ParseError,
Message = "Failed to parse the JSON-RPC request.",
},
};

try
{
await SendMessageAsync(errorResponse, shutdownToken).ConfigureAwait(false);
}
catch (Exception sendEx) when (sendEx is not OperationCanceledException)
{
LogTransportSendFailed(Name, id.ToString(), sendEx);
}
}
}
}
}
Expand All @@ -156,6 +181,82 @@ private async Task ReadMessagesAsync()
}
}

/// <summary>
/// Attempts to recover the JSON-RPC request id from a line that failed full deserialization.
/// </summary>
/// <remarks>
/// This walks only the top-level object looking for an "id" property and skips every other value,
/// using a large reader depth so a deeply nested "params" value cannot make recovery itself fail.
/// </remarks>
private static bool TryRecoverRequestId(string line, out RequestId id)
{
id = default;

try
{
byte[] utf8 = Encoding.UTF8.GetBytes(line);
var reader = new Utf8JsonReader(utf8, new JsonReaderOptions
{
// Use the maximum reader depth so that an over-nested "params" value cannot make id
// recovery throw for the same reason the original parse did.
MaxDepth = int.MaxValue,
});

if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject)
{
return false;
}

while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
break;
}

if (reader.TokenType != JsonTokenType.PropertyName)
{
continue;
}

bool isId = reader.ValueTextEquals("id"u8);

if (!reader.Read())
{
break;
}

if (isId)
{
switch (reader.TokenType)
{
case JsonTokenType.String:
id = new RequestId(reader.GetString()!);
return true;

case JsonTokenType.Number when reader.TryGetInt64(out long longId):
id = new RequestId(longId);
return true;

default:
// An id that is neither a string nor an integer cannot be correlated, so
// there is no point sending an error response for it.
return false;
}
}

// Skip the value of any property other than id, including a deeply nested params object.
reader.Skip();
}
}
catch (JsonException)
{
// The line was too malformed to even locate a top-level id; nothing to correlate.
}

return false;
}

/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,55 @@ public async Task ReadMessagesAsync_Should_Accept_CRLF_Delimited_Messages()
Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString());
}

[Fact]
public async Task ReadMessagesAsync_Should_Respond_With_ParseError_For_Request_Exceeding_MaxDepth()
{
// Build a ping request whose params nest more deeply than System.Text.Json's default
// reader MaxDepth of 64, which is what makes full deserialization throw. The request still
// carries an id, so the transport should reply with a JSON-RPC parse error for that id rather
// than dropping the request and leaving the caller pending.
var nested = new StringBuilder();
const int depth = 100;
for (int i = 0; i < depth; i++)
{
nested.Append("{\"p").Append(i).Append("\":");
}
nested.Append("{\"leaf\":true}");
nested.Append('}', depth);

var requestLine = $"{{\"jsonrpc\":\"2.0\",\"id\":900100,\"method\":\"ping\",\"params\":{nested}}}";

Pipe inputPipe = new();
Pipe outputPipe = new();
using var input = inputPipe.Reader.AsStream();
using var output = outputPipe.Writer.AsStream();

await using var transport = new StreamServerTransport(
input,
output,
loggerFactory: LoggerFactory);

await inputPipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{requestLine}\n"), TestContext.Current.CancellationToken);

// Read the single response line the transport writes back to the output stream.
using var responseReader = new StreamReader(outputPipe.Reader.AsStream(), Encoding.UTF8);
var responseLine = await responseReader.ReadLineAsync(
#if NET
TestContext.Current.CancellationToken
#endif
);

Assert.NotNull(responseLine);

var response = JsonSerializer.Deserialize<JsonRpcMessage>(responseLine!, McpJsonUtilities.DefaultOptions);
var error = Assert.IsType<JsonRpcError>(response);
Assert.Equal("900100", error.Id.ToString());
Assert.Equal((int)McpErrorCode.ParseError, error.Error.Code);

// The transport should still be reading further messages after recovering from the bad one.
Assert.True(transport.IsConnected);
}

[Fact]
public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
{
Expand Down
Loading