Reply with JSON-RPC parse error for over-nested stdio requests (#1629)
Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
This commit is contained in:
parent
ba372faece
commit
990bd76b53
2 changed files with 155 additions and 1 deletions
|
|
@ -137,7 +137,33 @@ public class StreamServerTransport : TransportBase
|
||||||
LogTransportMessageParseFailed(Name, ex);
|
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)
|
||||||
|
{
|
||||||
|
// Swallow so a failed error-send does not tear down the read loop. No logging
|
||||||
|
// here because SendMessageAsync already logs send failures before it throws.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,6 +182,85 @@ public class StreamServerTransport : TransportBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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 (Exception)
|
||||||
|
{
|
||||||
|
// Recovery is best effort. Whether the line was too malformed to locate a top-level id or
|
||||||
|
// the reader failed for any other reason, swallow it here and return false. Letting an
|
||||||
|
// exception escape would propagate out of the read loop and disconnect the transport,
|
||||||
|
// turning a single bad message into a full session teardown.
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,55 @@ public class StdioServerTransportTests : LoggedTest
|
||||||
Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString());
|
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]
|
[Fact]
|
||||||
public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
|
public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue