csharp-sdk/tests/ModelContextProtocol.Tests/Transport/SseClientTransportTests.cs
Stephen Toub d86b3277d4
Add McpServer/ClientResource{Template} and friends (#391)
* Add McpServer/ClientResource{Template} and friends

This PR copies the same design used by tools and prompts to add McpServerResource, McpServerResourceAttribute, McpServerResourceTypeAttribute, McpClientResource, and then all of those for ResourceTemplate as well.

Unlike the others, [McpServerResource] is permitted not only on methods but also on properties. This convenience is afforded because such resources aren't parameterized, and thus can be easily modeled as properties, likely auto-props, e.g.
```C#
[McpServerResource]
public static BlobResourceContents MyAmazingData { get; } = Create();
```

Details about resources can be encoded into the attributes or provided via McpServerResource{Template}.Create methods. If not URI is explicitly defined, one is derived from the member name and arguments. For templates, this means manufacturing a URI template that includes variables for all non-DI parameters.

This also fixes a few issues discovered along the way:
- McpClientExtensions are updated to use `ValueTask<T>` instead of `Task<T>` where relevant.
- McpServerPrompt was missing handling for progress notifications; that's been rectified. It was also missing configuring the description on the ProtocolPrompt.
- Renamed IMcpServerPrimitive.Name to Id. For resources, names aren't unique.
- After going back and forth, updated McpServer to not fail for missing handlers and to instead register default handlers. It simplifies the implementation and makes it more consistent.
- Added a bunch of primitive types into the McpJsonUtilities JSON source generator context so that such primitives are usable with tools/prompts/resources without folks being required to add them into their own context for Native AOT. I think we should consider pushing this down to AIJsonUtilities, instead, and that will then flow up to McpJsonUtilities automatically.

* Address feedback
2025-05-09 18:21:11 -04:00

175 lines
No EOL
6.4 KiB
C#

using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Tests.Utils;
using System.Net;
namespace ModelContextProtocol.Tests.Transport;
public class SseClientTransportTests : LoggedTest
{
private readonly SseClientTransportOptions _transportOptions;
public SseClientTransportTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
_transportOptions = new SseClientTransportOptions
{
Endpoint = new Uri("http://localhost:8080"),
ConnectionTimeout = TimeSpan.FromSeconds(2),
Name = "Test Server",
AdditionalHeaders = new Dictionary<string, string>
{
["test"] = "header"
}
};
}
[Fact]
public void Constructor_Throws_For_Null_Options()
{
var exception = Assert.Throws<ArgumentNullException>(() => new SseClientTransport(null!, LoggerFactory));
Assert.Equal("transportOptions", exception.ParamName);
}
[Fact]
public void Constructor_Throws_For_Null_HttpClient()
{
var exception = Assert.Throws<ArgumentNullException>(() => new SseClientTransport(_transportOptions, null!, LoggerFactory));
Assert.Equal("httpClient", exception.ParamName);
}
[Fact]
public async Task ConnectAsync_Should_Connect_Successfully()
{
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new SseClientTransport(_transportOptions, httpClient, LoggerFactory);
bool firstCall = true;
mockHttpHandler.RequestHandler = (request) =>
{
firstCall = false;
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("event: endpoint\r\ndata: http://localhost\r\n\r\n")
});
};
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
Assert.NotNull(session);
Assert.False(firstCall);
}
[Fact]
public async Task ConnectAsync_Throws_Exception_On_Failure()
{
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new SseClientTransport(_transportOptions, httpClient, LoggerFactory);
var retries = 0;
mockHttpHandler.RequestHandler = (request) =>
{
retries++;
throw new Exception("Test exception");
};
var exception = await Assert.ThrowsAsync<Exception>(() => transport.ConnectAsync(TestContext.Current.CancellationToken));
Assert.Equal("Test exception", exception.Message);
Assert.Equal(1, retries);
}
[Fact]
public async Task SendMessageAsync_Handles_Accepted_Response()
{
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new SseClientTransport(_transportOptions, httpClient, LoggerFactory);
var firstCall = true;
mockHttpHandler.RequestHandler = (request) =>
{
if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == "http://localhost:8080/sseendpoint")
{
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("accepted")
});
}
else
{
if (!firstCall)
throw new IOException("Abort");
else
firstCall = false;
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n")
});
}
};
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
await session.SendMessageAsync(new JsonRpcRequest() { Method = RequestMethods.Initialize, Id = new RequestId(44) }, CancellationToken.None);
Assert.True(true);
}
[Fact]
public async Task ReceiveMessagesAsync_Handles_Messages()
{
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new SseClientTransport(_transportOptions, httpClient, LoggerFactory);
var callIndex = 0;
mockHttpHandler.RequestHandler = (request) =>
{
callIndex++;
if (callIndex == 1)
{
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\nevent: message\r\ndata: {\"jsonrpc\":\"2.0\", \"id\": \"44\", \"method\": \"test\", \"params\": null}\r\n\r\n")
});
}
throw new IOException("Abort");
};
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
Assert.True(session.MessageReader.TryRead(out var message));
Assert.NotNull(message);
Assert.IsType<JsonRpcRequest>(message);
Assert.Equal("44", ((JsonRpcRequest)message).Id.ToString());
}
[Fact]
public async Task DisposeAsync_Should_Dispose_Resources()
{
using var mockHttpHandler = new MockHttpHandler();
using var httpClient = new HttpClient(mockHttpHandler);
mockHttpHandler.RequestHandler = request =>
{
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("event: endpoint\r\ndata: http://localhost\r\n\r\n")
});
};
await using var transport = new SseClientTransport(_transportOptions, httpClient, LoggerFactory);
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
await session.DisposeAsync();
var transportBase = Assert.IsAssignableFrom<TransportBase>(session);
Assert.False(transportBase.IsConnected);
}
}