* 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
272 lines
8.5 KiB
C#
272 lines
8.5 KiB
C#
using ModelContextProtocol.Client;
|
|
using ModelContextProtocol.Protocol.Transport;
|
|
using ModelContextProtocol.Protocol.Types;
|
|
using ModelContextProtocol.Tests.Utils;
|
|
|
|
namespace ModelContextProtocol.AspNetCore.Tests;
|
|
|
|
public abstract class HttpServerIntegrationTests : LoggedTest, IClassFixture<SseServerIntegrationTestFixture>
|
|
{
|
|
protected readonly SseServerIntegrationTestFixture _fixture;
|
|
|
|
public HttpServerIntegrationTests(SseServerIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
|
|
: base(testOutputHelper)
|
|
{
|
|
_fixture = fixture;
|
|
_fixture.Initialize(testOutputHelper, ClientTransportOptions);
|
|
}
|
|
|
|
public override void Dispose()
|
|
{
|
|
_fixture.TestCompleted();
|
|
base.Dispose();
|
|
}
|
|
|
|
protected abstract SseClientTransportOptions ClientTransportOptions { get; }
|
|
|
|
private Task<IMcpClient> GetClientAsync(McpClientOptions? options = null)
|
|
{
|
|
return _fixture.ConnectMcpClientAsync(options, LoggerFactory);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectAndPing_Sse_TestServer()
|
|
{
|
|
// Arrange
|
|
|
|
// Act
|
|
await using var client = await GetClientAsync();
|
|
await client.PingAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(client);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_TestServer_ShouldProvideServerFields()
|
|
{
|
|
// Arrange
|
|
|
|
// Act
|
|
await using var client = await GetClientAsync();
|
|
|
|
// Assert
|
|
Assert.NotNull(client.ServerCapabilities);
|
|
Assert.NotNull(client.ServerInfo);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListTools_Sse_TestServer()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// assert
|
|
Assert.NotNull(tools);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallTool_Sse_EchoServer()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
var result = await client.CallToolAsync(
|
|
"echo",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["message"] = "Hello MCP!"
|
|
},
|
|
cancellationToken: TestContext.Current.CancellationToken
|
|
);
|
|
|
|
// assert
|
|
Assert.NotNull(result);
|
|
Assert.False(result.IsError);
|
|
var textContent = Assert.Single(result.Content, c => c.Type == "text");
|
|
Assert.Equal("Echo: Hello MCP!", textContent.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListResources_Sse_TestServer()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
|
|
IList<McpClientResource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
|
|
|
|
// The everything server provides 100 test resources
|
|
Assert.Equal(100, allResources.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadResource_Sse_TextResource()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
// Odd numbered resources are text in the everything server (despite the docs saying otherwise)
|
|
// 1 is index 0, which is "even" in the 0-based index
|
|
// We copied this oddity to the test server
|
|
var result = await client.ReadResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Single(result.Contents);
|
|
|
|
TextResourceContents textContent = Assert.IsType<TextResourceContents>(result.Contents[0]);
|
|
Assert.NotNull(textContent.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadResource_Sse_BinaryResource()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
// Even numbered resources are binary in the everything server (despite the docs saying otherwise)
|
|
// 2 is index 1, which is "odd" in the 0-based index
|
|
// We copied this oddity to the test server
|
|
var result = await client.ReadResourceAsync("test://static/resource/2", TestContext.Current.CancellationToken);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Single(result.Contents);
|
|
|
|
BlobResourceContents blobContent = Assert.IsType<BlobResourceContents>(result.Contents[0]);
|
|
Assert.NotNull(blobContent.Blob);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListPrompts_Sse_TestServer()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
|
|
|
|
// assert
|
|
Assert.NotNull(prompts);
|
|
Assert.NotEmpty(prompts);
|
|
// We could add specific assertions for the known prompts
|
|
Assert.Contains(prompts, p => p.Name == "simple_prompt");
|
|
Assert.Contains(prompts, p => p.Name == "complex_prompt");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPrompt_Sse_SimplePrompt()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
var result = await client.GetPromptAsync("simple_prompt", null, cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// assert
|
|
Assert.NotNull(result);
|
|
Assert.NotEmpty(result.Messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPrompt_Sse_ComplexPrompt()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
var arguments = new Dictionary<string, object?>
|
|
{
|
|
{ "temperature", "0.7" },
|
|
{ "style", "formal" }
|
|
};
|
|
var result = await client.GetPromptAsync("complex_prompt", arguments, cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// assert
|
|
Assert.NotNull(result);
|
|
Assert.NotEmpty(result.Messages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPrompt_Sse_NonExistent_ThrowsException()
|
|
{
|
|
// arrange
|
|
|
|
// act
|
|
await using var client = await GetClientAsync();
|
|
await Assert.ThrowsAsync<McpException>(async () => await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sampling_Sse_TestServer()
|
|
{
|
|
// arrange
|
|
// Set up the sampling handler
|
|
int samplingHandlerCalls = 0;
|
|
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
|
McpClientOptions options = new();
|
|
options.Capabilities = new();
|
|
options.Capabilities.Sampling ??= new();
|
|
options.Capabilities.Sampling.SamplingHandler = async (_, _, _) =>
|
|
{
|
|
samplingHandlerCalls++;
|
|
return new CreateMessageResult
|
|
{
|
|
Model = "test-model",
|
|
Role = Role.Assistant,
|
|
Content = new Content
|
|
{
|
|
Type = "text",
|
|
Text = "Test response"
|
|
}
|
|
};
|
|
};
|
|
await using var client = await GetClientAsync(options);
|
|
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
|
|
|
// Call the server's sampleLLM tool which should trigger our sampling handler
|
|
var result = await client.CallToolAsync("sampleLLM", new Dictionary<string, object?>
|
|
{
|
|
["prompt"] = "Test prompt",
|
|
["maxTokens"] = 100
|
|
},
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
// assert
|
|
Assert.NotNull(result);
|
|
var textContent = Assert.Single(result.Content);
|
|
Assert.Equal("text", textContent.Type);
|
|
Assert.False(string.IsNullOrEmpty(textContent.Text));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallTool_Sse_EchoServer_Concurrently()
|
|
{
|
|
await using var client1 = await GetClientAsync();
|
|
await using var client2 = await GetClientAsync();
|
|
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
var client = (i % 2 == 0) ? client1 : client2;
|
|
var result = await client.CallToolAsync(
|
|
"echo",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["message"] = $"Hello MCP! {i}"
|
|
},
|
|
cancellationToken: TestContext.Current.CancellationToken
|
|
);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.False(result.IsError);
|
|
var textContent = Assert.Single(result.Content, c => c.Type == "text");
|
|
Assert.Equal($"Echo: Hello MCP! {i}", textContent.Text);
|
|
}
|
|
}
|
|
}
|