Integrate Microsoft.Extensions.AI as a foundational building block (#94)

* Integrate Microsoft.Extensions.AI as a foundational building block

As with logging abstractions coming from M.E.Logging and DI abstractions coming from M.E.DependencyInjection, M.E.AI provides the relevant AI abstractions.

- Adds AsAIFunction and GetAIFunctionsAsync methods for getting functions for tools via a client.
- Uses AIFunctionFactory to implement McpServerBuilderExtensions.WithTools.
- Adds a RequestSamplingAsync overload that accepts ChatMessages/Options.
- Adds an AsSamplingChatClient method that provides an IChatClient usable on the server to request sampling of the client.
- Adds a CreateSamplingHandler method to enable an IChatClient to be easily used to satisfy sampling requests on the client.
- Deprecate McpDotNet.Extensions.AI.

* Address PR feedback
This commit is contained in:
Stephen Toub 2025-03-17 16:34:00 -04:00 • committed by GitHub
commit 4597bbd7c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.RegularExpressions;
using McpDotNet.Configuration;
using McpDotNet.Server;
using Microsoft.Extensions.DependencyInjection;
@ -108,10 +109,7 @@ public class McpServerBuilderExtensionsToolsTests
var result = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "ReturnNull" }), CancellationToken.None);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("null", result.Content[0].Text);
Assert.Empty(result.Content[0].Type);
Assert.Empty(result.Content);
}
[Fact]
@ -127,7 +125,7 @@ public class McpServerBuilderExtensionsToolsTests
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("{\"SomeProp\": false}", result.Content[0].Text);
Assert.Equal("{\"SomeProp\":false}", Regex.Replace(result.Content[0].Text ?? string.Empty, "\\s+", ""));
Assert.Equal("text", result.Content[0].Type);
}
@ -202,45 +200,18 @@ public class McpServerBuilderExtensionsToolsTests
}
[Fact]
public async Task Throws_Exception_When_Tool_Fails()
public async Task Returns_IsError_Content_When_Tool_Fails()
{
_builder.Object.WithTools(typeof(EchoTool));
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().Value;
var action = async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "ReturnError" }), CancellationToken.None);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(action);
Assert.Equal("Test error", exception.Message);
}
[Fact]
public async Task Can_Call_Registered_Tool_With_Dependency_Injection()
{
bool dependencyInjectionCalled = false;
_services.AddTransient(sp =>
{
dependencyInjectionCalled = true;
return Mock.Of<IDependendService>();
});
_builder.Object.WithTool<EchoToolWithDi>();
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().Value;
var mcpServer = new Mock<IMcpServer>();
mcpServer.SetupGet(s => s.ServiceProvider).Returns(serviceProvider);
var result = await options.CallToolHandler!(new(mcpServer.Object, new() { Name = "Echo", Arguments = new() { { "message", "Peter" } } }), CancellationToken.None);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("hello Peter", result.Content[0].Text);
Assert.True(dependencyInjectionCalled);
var response = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = nameof(EchoTool.ThrowException) }), CancellationToken.None);
Assert.True(response.IsError);
Assert.NotNull(response.Content);
Assert.NotEmpty(response.Content);
Assert.Contains("Test error", response.Content[0].Text);
}
[Fact]
@ -252,37 +223,32 @@ public class McpServerBuilderExtensionsToolsTests
var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().Value;
var exception = await Assert.ThrowsAsync<McpServerException>(async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "NotRegisteredTool" }), CancellationToken.None));
Assert.Equal("Unknown tool: NotRegisteredTool", exception.Message);
Assert.Contains("'NotRegisteredTool'", exception.Message);
}
[Fact]
public async Task Throws_Exception_Missing_Parameter()
{
_builder.Object.WithTools(typeof(EchoTool));
// TODO: https://github.com/dotnet/extensions/issues/6124
//[Fact]
//public async Task Throws_Exception_Missing_Parameter()
//{
// _builder.Object.WithTools(typeof(EchoTool));
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().Value;
// var serviceProvider = _services.BuildServiceProvider();
// var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().Value;
var exception = await Assert.ThrowsAsync<McpServerException>(async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "Echo" }), CancellationToken.None));
Assert.Equal("Missing required argument 'message'.", exception.Message);
}
// var exception = await Assert.ThrowsAsync<McpServerException>(async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "Echo" }), CancellationToken.None));
// Assert.Equal("Missing required argument 'message'.", exception.Message);
//}
[Fact]
public void Throws_Exception_For_Null_Types()
{
var action = () => _builder.Object.WithTools(toolTypes: null!);
var exception = Assert.Throws<ArgumentException>(action);
Assert.Equal("At least one tool type must be provided. (Parameter 'toolTypes')", exception.Message);
Assert.Throws<ArgumentNullException>("toolTypes", () => _builder.Object.WithTools(toolTypes: null!));
}
[Fact]
public void Throws_Exception_For_Empty_Types()
public void Empty_Types_Is_Allowed()
{
var action = () => _builder.Object.WithTools(toolTypes: []);
var exception = Assert.Throws<ArgumentException>(action);
Assert.Equal("At least one tool type must be provided. (Parameter 'toolTypes')", exception.Message);
_builder.Object.WithTools(toolTypes: []); // no exception
}
[Fact]
@ -302,12 +268,9 @@ public class McpServerBuilderExtensionsToolsTests
}
[Fact]
public void Throws_Exception_When_No_Tools_Are_Found_In_Given_Assembly()
public void Ok_If_No_Tools_Are_Found_In_Given_Assembly()
{
var action = () => _builder.Object.WithToolsFromAssembly(typeof(Mock).Assembly);
var exception = Assert.Throws<ArgumentException>(action);
Assert.Equal("No types with marked methods found in the assembly. (Parameter 'assembly')", exception.Message);
_builder.Object.WithToolsFromAssembly(typeof(Mock).Assembly);
}
[Fact]
@ -324,14 +287,14 @@ public class McpServerBuilderExtensionsToolsTests
var tool = result.Tools.First(t => t.Name == "TestTool");
Assert.Equal("TestTool", tool.Name);
Assert.Null(tool.Description);
Assert.Empty(tool.Description);
Assert.NotNull(tool.InputSchema);
Assert.Equal("object", tool.InputSchema.Type);
Assert.NotNull(tool.InputSchema.Properties);
Assert.NotEmpty(tool.InputSchema.Properties);
Assert.Contains("number", tool.InputSchema.Properties);
Assert.Equal("number", tool.InputSchema.Properties["number"].Type);
Assert.Equal("integer", tool.InputSchema.Properties["number"].Type);
Assert.Contains("otherNumber", tool.InputSchema.Properties);
Assert.Equal("number", tool.InputSchema.Properties["otherNumber"].Type);
@ -398,7 +361,7 @@ public class McpServerBuilderExtensionsToolsTests
}
[McpTool]
public static string ReturnError()
public static string ThrowException()
{
throw new InvalidOperationException("Test error");
}