Add ModelContextProtocol.AspNetCore (#160)

* Add ModelContextProtocol.AspNetCore

- Remove IServerTransport and HttpListenerSseServerTransport
- Replace HttpListenerSseServerTransport usage in tests with ASP.NET Core.

Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com>
Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
This commit is contained in:
Stephen Halter 2025-03-31 12:01:04 -07:00 • committed by GitHub
commit 973c7c093f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 332 additions and 831 deletions

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
</ItemGroup>

View file

@ -1,7 +1,6 @@
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using Microsoft.Extensions.Logging;
using Serilog;
using System.Text;
using System.Text.Json;
@ -10,9 +9,7 @@ namespace ModelContextProtocol.TestSseServer;
public class Program
{
private static ILoggerFactory CreateLoggerFactory() => LoggerFactory.Create(ConfigureSerilog);
public static void ConfigureSerilog(ILoggingBuilder loggingBuilder)
private static void ConfigureSerilog(ILoggingBuilder loggingBuilder)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Verbose() // Capture all log levels
@ -27,22 +24,17 @@ public class Program
public static Task Main(string[] args) => MainAsync(args);
public static async Task MainAsync(string[] args, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
private static void ConfigureOptions(McpServerOptions options)
{
Console.WriteLine("Starting server...");
McpServerOptions options = new()
options.ServerInfo = new Implementation() { Name = "TestServer", Version = "1.0.0" };
options.Capabilities = new ServerCapabilities()
{
ServerInfo = new Implementation() { Name = "TestServer", Version = "1.0.0" },
Capabilities = new ServerCapabilities()
{
Tools = new(),
Resources = new(),
Prompts = new(),
},
ProtocolVersion = "2024-11-05",
ServerInstructions = "This is a test server with only stub functionality"
Tools = new(),
Resources = new(),
Prompts = new(),
};
options.ProtocolVersion = "2024-11-05";
options.ServerInstructions = "This is a test server with only stub functionality";
Console.WriteLine("Registering handlers.");
@ -380,17 +372,31 @@ public class Program
}
},
};
}
loggerFactory ??= CreateLoggerFactory();
await using var httpListenerSseTransport = new HttpListenerSseServerTransport("TestServer", 3001, loggerFactory);
Console.WriteLine("Server running...");
public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvider = null, CancellationToken cancellationToken = default)
{
Console.WriteLine("Starting server...");
// Each IMcpServer represents a new SSE session.
while (true)
var builder = WebApplication.CreateSlimBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
var server = await McpServerFactory.AcceptAsync(httpListenerSseTransport, options, loggerFactory, cancellationToken: cancellationToken);
_ = server.RunAsync(cancellationToken: cancellationToken);
options.ListenLocalhost(3001);
});
ConfigureSerilog(builder.Logging);
if (loggerProvider is not null)
{
builder.Logging.AddProvider(loggerProvider);
}
builder.Services.AddMcpServer(ConfigureOptions);
var app = builder.Build();
app.MapMcp();
await app.RunAsync(cancellationToken);
}
const string MCP_TINY_IMAGE =

View file

@ -0,0 +1,12 @@
{
"profiles": {
"ModelContextProtocol.TestSseServer": {
"commandName": "Project",
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:3001"
}
}
}

View file

@ -1,20 +1,20 @@
using ModelContextProtocol.Client;
using Microsoft.Extensions.AI;
using OpenAI;
using ModelContextProtocol.Protocol.Types;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Messages;
using System.Text.Json;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Tests.Utils;
using OpenAI;
using System.Text.Encodings.Web;
using System.Text.Json.Serialization.Metadata;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.Tests;
public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegrationTestFixture>
{
private static readonly string? s_openAIKey = Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!;
private static readonly string? s_openAIKey = Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey");
public static bool NoOpenAIKeySet => string.IsNullOrWhiteSpace(s_openAIKey);

View file

@ -19,18 +19,4 @@ public class McpServerBuilderExtensionsTransportsTests
Assert.NotNull(transportType);
Assert.Equal(typeof(StdioServerTransport), transportType.ImplementationType);
}
[Fact]
public void WithHttpListenerSseServerTransport_Sets_Transport()
{
var services = new ServiceCollection();
var builder = new Mock<IMcpServerBuilder>();
builder.SetupGet(b => b.Services).Returns(services);
builder.Object.WithHttpListenerSseServerTransport();
var transportType = services.FirstOrDefault(s => s.ServiceType == typeof(IServerTransport));
Assert.NotNull(transportType);
Assert.Equal(typeof(HttpListenerSseServerTransport), transportType.ImplementationType);
}
}

View file

@ -32,22 +32,16 @@ public class McpServerFactoryTests : LoggedTest
}
[Fact]
public async Task Create_Throws_For_Null_ServerTransport()
public void Create_Throws_For_Null_ServerTransport()
{
// Arrange, Act & Assert
Assert.Throws<ArgumentNullException>("transport", () => McpServerFactory.Create(null!, _options, LoggerFactory));
await Assert.ThrowsAsync<ArgumentNullException>("serverTransport", () =>
McpServerFactory.AcceptAsync(null!, _options, LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task Create_Throws_For_Null_Options()
public void Create_Throws_For_Null_Options()
{
// Arrange, Act & Assert
Assert.Throws<ArgumentNullException>("serverOptions", () => McpServerFactory.Create(Mock.Of<ITransport>(), null!, LoggerFactory));
await Assert.ThrowsAsync<ArgumentNullException>("serverOptions", () =>
McpServerFactory.AcceptAsync(Mock.Of<IServerTransport>(), null!, LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
}

View file

@ -168,18 +168,6 @@ public class McpServerTests : LoggedTest
await runTask;
}
[Fact]
public async Task Throws_Exception_If_Not_Connected()
{
await using var server = McpServerFactory.Create(_serverTransport.Object, _options, LoggerFactory, _serviceProvider);
SetClientCapabilities(server, new ClientCapabilities { Roots = new RootsCapability() });
_serverTransport.SetupGet(t => t.IsConnected).Returns(false);
var action = async () => await server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None);
await Assert.ThrowsAsync<McpClientException>(action);
}
[Fact]
public async Task Can_Handle_Ping_Requests()
{

View file

@ -13,18 +13,11 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
private readonly CancellationTokenSource _stopCts = new();
private readonly DelegatingTestOutputHelper _delegatingTestOutputHelper = new();
private readonly ILoggerFactory _redirectingLoggerFactory;
public McpServerConfig DefaultConfig { get; }
public SseServerIntegrationTestFixture()
{
_redirectingLoggerFactory = LoggerFactory.Create(builder =>
{
Program.ConfigureSerilog(builder);
builder.AddProvider(new XunitLoggerProvider(_delegatingTestOutputHelper));
});
DefaultConfig = new McpServerConfig
{
Id = "test_server",
@ -34,7 +27,7 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
Location = "http://localhost:3001/sse"
};
_serverTask = Program.MainAsync([], _redirectingLoggerFactory, _stopCts.Token);
_serverTask = Program.MainAsync([], new XunitLoggerProvider(_delegatingTestOutputHelper), _stopCts.Token);
}
public static McpClientOptions CreateDefaultClientOptions() => new()
@ -63,7 +56,6 @@ public class SseServerIntegrationTestFixture : IAsyncDisposable
catch (OperationCanceledException)
{
}
_redirectingLoggerFactory.Dispose();
_stopCts.Dispose();
}
}

View file

@ -36,7 +36,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// Arrange
// Act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
await client.PingAsync(TestContext.Current.CancellationToken);
// Assert
@ -49,7 +49,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// Arrange
// Act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
// Assert
Assert.NotNull(client.ServerCapabilities);
@ -62,7 +62,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
// assert
@ -75,7 +75,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?>
@ -98,7 +98,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
IList<Resource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
@ -112,7 +112,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
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
@ -131,7 +131,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
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
@ -150,7 +150,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
// assert
@ -167,7 +167,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
var result = await client.GetPromptAsync("simple_prompt", null, TestContext.Current.CancellationToken);
// assert
@ -181,7 +181,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
var arguments = new Dictionary<string, object?>
{
{ "temperature", "0.7" },
@ -200,7 +200,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
// arrange
// act
var client = await GetClientAsync();
await using var client = await GetClientAsync();
await Assert.ThrowsAsync<McpClientException>(() =>
client.GetPromptAsync("non_existent_prompt", null, TestContext.Current.CancellationToken));
}
@ -229,7 +229,7 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
}
};
};
var client = await GetClientAsync(options);
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
@ -246,4 +246,29 @@ public class SseServerIntegrationTests : LoggedTest, IClassFixture<SseServerInte
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}"
},
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);
}
}
}

View file

@ -6,7 +6,6 @@ using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using ModelContextProtocol.Utils.Json;
using System.IO.Pipelines;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
@ -32,11 +31,9 @@ public class StdioServerTransportTests : LoggedTest
};
}
[Fact]
[Fact(Skip="https://github.com/modelcontextprotocol/csharp-sdk/issues/143")]
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
{
Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Linux), "https://github.com/modelcontextprotocol/csharp-sdk/issues/143");
// Act
await using var transport = new StdioServerTransport(_serverOptions);

View file

@ -13,14 +13,16 @@ public class LoggedTest : IDisposable
{
CurrentTestOutputHelper = testOutputHelper,
};
LoggerProvider = new XunitLoggerProvider(_delegatingTestOutputHelper);
LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
{
builder.AddProvider(new XunitLoggerProvider(_delegatingTestOutputHelper));
builder.AddProvider(LoggerProvider);
});
}
public ITestOutputHelper TestOutputHelper => _delegatingTestOutputHelper;
public ILoggerFactory LoggerFactory { get; }
public ILoggerProvider LoggerProvider { get; }
public virtual void Dispose()
{