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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixes

- Restore legacy MCP HTTP+SSE endpoints alongside Streamable HTTP on the same `--mcp` server using SDK 2.2. No extra option is required. Bound outstanding legacy requests to 16 across all sessions and reject excess requests without queuing.
- Parse `exec` options and shell words like direct commands. Bind built-in options normally and pass option-shaped words to functions and script files as positional text.
- `for` and `do` loops now reject misspelled `in` and `while` keywords before any statements in the input execute. They previously accepted any identifier in those positions.
- `filter` now rejects array index literals larger than `2147483647`, including optional access, instead of silently selecting element zero. Representable indexes beyond the array length still return `null`.
Expand Down
142 changes: 142 additions & 0 deletions CosmosDBShell.Tests/LegacyHttpAdmissionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests;

using System.Text.Json;
using Azure.Data.Cosmos.Shell.Mcp;
using Microsoft.AspNetCore.Http;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using NSubstitute;

public class LegacyHttpAdmissionTests : IDisposable
{
private readonly List<MemoryStream> requestBodies = [];

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SlowSseResponse_RejectsExcessHttpRequestsBeforeDispatch(bool errorResponse)
{
var admission = new LegacyHttpAdmission();
var dispatched = 0;
RequestDelegate dispatch = context =>
{
dispatched++;
context.Response.StatusCode = StatusCodes.Status202Accepted;
return Task.CompletedTask;
};
for (var i = 0; i < LegacyRequestLimiter.MaxOutstandingRequests; i++)
{
await admission.InvokeAsync(CreateContext(CreateRequest(i)), dispatch);
}

var server = Substitute.For<ModelContextProtocol.Server.McpServer>();
server.SessionId.Returns("legacy-test");
var releaseStream = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var outgoing = admission.TrackOutgoing(
async (_, cancellationToken) => await releaseStream.Task.WaitAsync(cancellationToken));
JsonRpcMessage response = errorResponse
? new JsonRpcError { Id = new RequestId(0), Error = new JsonRpcErrorDetail { Code = -32601, Message = "Unknown method." } }
: new JsonRpcResponse { Id = new RequestId(0), Result = null };
var pendingResponse = outgoing(new MessageContext(server, response), TestContext.Current.CancellationToken);
try
{
for (var i = 0; i < 32; i++)
{
var excess = CreateContext(CreateRequest(100 + i));
await admission.InvokeAsync(excess, dispatch);
Assert.Equal(StatusCodes.Status429TooManyRequests, excess.Response.StatusCode);
Assert.Equal("1", excess.Response.Headers.RetryAfter.ToString());
}

Assert.Equal(LegacyRequestLimiter.MaxOutstandingRequests, dispatched);
Assert.Equal(LegacyRequestLimiter.MaxOutstandingRequests, admission.OutstandingRequests);
Assert.False(pendingResponse.IsCompleted);

var cancellation = CreateContext(new JsonRpcNotification { Method = NotificationMethods.CancelledNotification });
await admission.InvokeAsync(cancellation, dispatch);
var reply = CreateContext(new JsonRpcResponse { Id = new RequestId(200), Result = null });
await admission.InvokeAsync(reply, dispatch);
Assert.Equal(StatusCodes.Status202Accepted, cancellation.Response.StatusCode);
Assert.Equal(StatusCodes.Status202Accepted, reply.Response.StatusCode);
Assert.Equal(LegacyRequestLimiter.MaxOutstandingRequests, admission.OutstandingRequests);
}
finally
{
releaseStream.TrySetResult(true);
await pendingResponse.WaitAsync(TestContext.Current.CancellationToken);
}

Assert.Equal(LegacyRequestLimiter.MaxOutstandingRequests - 1, admission.OutstandingRequests);
var retry = CreateContext(CreateRequest(300));
await admission.InvokeAsync(retry, dispatch);
Assert.Equal(StatusCodes.Status202Accepted, retry.Response.StatusCode);
Assert.Equal(LegacyRequestLimiter.MaxOutstandingRequests, admission.OutstandingRequests);
admission.EndSession("legacy-test");
Assert.Equal(0, admission.OutstandingRequests);
}

[Theory]
[InlineData(StatusCodes.Status400BadRequest)]
[InlineData(StatusCodes.Status500InternalServerError)]
public async Task RejectedHttpRequest_ReleasesAdmission(int statusCode)
{
var admission = new LegacyHttpAdmission();
var context = CreateContext(CreateRequest(1));
await admission.InvokeAsync(context, current =>
{
current.Response.StatusCode = statusCode;
return Task.CompletedTask;
});
Assert.Equal(0, admission.OutstandingRequests);
}

[Fact]
public async Task CancelledHandler_ReleasesAdmission()
{
var admission = new LegacyHttpAdmission();
await admission.InvokeAsync(CreateContext(CreateRequest(1)), context =>
{
context.Response.StatusCode = StatusCodes.Status202Accepted;
return Task.CompletedTask;
});
var server = Substitute.For<ModelContextProtocol.Server.McpServer>();
server.SessionId.Returns("legacy-test");
using var cancellation = new CancellationTokenSource();
await cancellation.CancelAsync();
var handler = admission.TrackIncoming((_, token) => Task.FromCanceled(token));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => handler(new MessageContext(server, CreateRequest(1)), cancellation.Token));
Assert.Equal(0, admission.OutstandingRequests);
}

public void Dispose()
{
foreach (var body in this.requestBodies)
{
body.Dispose();
}
}

private static JsonRpcRequest CreateRequest(int id)
{
return new JsonRpcRequest { Id = new RequestId(id), Method = RequestMethods.ToolsCall };
}

private DefaultHttpContext CreateContext(JsonRpcMessage message)
{
var body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(message));
this.requestBodies.Add(body);
var context = new DefaultHttpContext();
context.Request.Path = "/message";
context.Request.Method = HttpMethods.Post;
context.Request.QueryString = new QueryString("?sessionId=legacy-test");
context.Request.ContentType = "application/json";
context.Request.Body = body;
context.Response.Body = Stream.Null;
return context;
}
}
67 changes: 67 additions & 0 deletions CosmosDBShell.Tests/LegacyRequestLimiterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests;

using Azure.Data.Cosmos.Shell.Mcp;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using NSubstitute;

public class LegacyRequestLimiterTests
{
[Fact]
public async Task SaturatedLimiter_AllowsRepliesAndNotifications()
{
using var limiter = new LegacyRequestLimiter();
var server = Substitute.For<ModelContextProtocol.Server.McpServer>();
var releaseRequests = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var controlMessages = 0;
var handler = limiter.Limit(async (context, cancellationToken) =>
{
if (context.JsonRpcMessage is JsonRpcRequest)
{
await releaseRequests.Task.WaitAsync(cancellationToken);
}
else
{
controlMessages++;
}
});
var requests = new List<Task>();
try
{
for (var i = 0; i < LegacyRequestLimiter.MaxOutstandingRequests; i++)
{
requests.Add(handler(
new MessageContext(server, new JsonRpcRequest { Id = new RequestId(i), Method = RequestMethods.ToolsCall }),
TestContext.Current.CancellationToken));
}

Assert.Equal(LegacyRequestLimiter.MaxOutstandingRequests, limiter.OutstandingRequests);
var error = await Assert.ThrowsAsync<McpProtocolException>(
async () => await handler(
new MessageContext(server, new JsonRpcRequest { Id = new RequestId(100), Method = RequestMethods.ToolsCall }),
TestContext.Current.CancellationToken));
Assert.Equal(LegacyRequestLimiter.OverloadErrorCode, error.ErrorCode);

await handler(
new MessageContext(server, new JsonRpcResponse { Id = new RequestId(200), Result = null }),
TestContext.Current.CancellationToken);
await handler(
new MessageContext(server, new JsonRpcNotification { Method = NotificationMethods.CancelledNotification }),
TestContext.Current.CancellationToken);
Assert.Equal(2, controlMessages);
Assert.Equal(LegacyRequestLimiter.MaxOutstandingRequests, limiter.OutstandingRequests);
}
finally
{
releaseRequests.TrySetResult(true);
await Task.WhenAll(requests);
}

Assert.Equal(0, limiter.OutstandingRequests);
}
}
Loading
Loading