diff --git a/Directory.Packages.props b/Directory.Packages.props index 69ed858b3..ce902d18b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,6 +69,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 9020d2fbe..7976fe8f9 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -39,6 +39,15 @@ + + + + + + + + + diff --git a/docs/concepts/transports/samples/integration-testing/server/IntegrationTestingMcpServer.csproj b/docs/concepts/transports/samples/integration-testing/server/IntegrationTestingMcpServer.csproj new file mode 100644 index 000000000..e8e8a5b6b --- /dev/null +++ b/docs/concepts/transports/samples/integration-testing/server/IntegrationTestingMcpServer.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/docs/concepts/transports/samples/integration-testing/server/Program.cs b/docs/concepts/transports/samples/integration-testing/server/Program.cs new file mode 100644 index 000000000..febad8180 --- /dev/null +++ b/docs/concepts/transports/samples/integration-testing/server/Program.cs @@ -0,0 +1,24 @@ +// +using System.ComponentModel; +using ModelContextProtocol.Server; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + +var app = builder.Build(); +app.MapMcp("/mcp"); +app.Run(); + +// WebApplicationFactory needs a public entry point. +public partial class Program; + +[McpServerToolType] +public sealed class EchoTools +{ + [McpServerTool(Name = "echo"), Description("Returns the supplied message.")] + public static string Echo(string message) => $"Echo: {message}"; +} +// diff --git a/docs/concepts/transports/samples/integration-testing/tests/IntegrationTestingMcpServer.Tests.csproj b/docs/concepts/transports/samples/integration-testing/tests/IntegrationTestingMcpServer.Tests.csproj new file mode 100644 index 000000000..892ff5abd --- /dev/null +++ b/docs/concepts/transports/samples/integration-testing/tests/IntegrationTestingMcpServer.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + true + enable + enable + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/docs/concepts/transports/samples/integration-testing/tests/McpServerTests.cs b/docs/concepts/transports/samples/integration-testing/tests/McpServerTests.cs new file mode 100644 index 000000000..d7a695cd5 --- /dev/null +++ b/docs/concepts/transports/samples/integration-testing/tests/McpServerTests.cs @@ -0,0 +1,37 @@ +// +using Microsoft.AspNetCore.Mvc.Testing; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit; + +namespace IntegrationTestingMcpServer.Tests; + +public class McpServerTests(WebApplicationFactory factory) + : IClassFixture> +{ + [Fact] + public async Task EchoTool_RoundTripsThroughStreamableHttp() + { + using HttpClient httpClient = factory.CreateClient(); + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions + { + Endpoint = new Uri(httpClient.BaseAddress!, "/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, + httpClient); + + await using McpClient client = await McpClient.CreateAsync( + transport, + cancellationToken: TestContext.Current.CancellationToken); + + var result = await client.CallToolAsync( + "echo", + new Dictionary { ["message"] = "Hello MCP" }, + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.Single(result.Content.OfType()); + Assert.Equal("Echo: Hello MCP", text.Text); + } +} +// diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index bb4e155f2..149e6d902 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -188,6 +188,18 @@ app.Run(); By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `SessionMode = HttpServerSessionMode.Stateful` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Stateless and Stateful](xref:stateless). +#### Integration testing an ASP.NET Core MCP server + +Use `Microsoft.AspNetCore.Mvc.Testing` to host the complete ASP.NET Core application in process and exercise its Streamable HTTP endpoint without opening a network port. When the server uses top-level statements, expose its generated entry point to the test project by adding `public partial class Program`: + +[!code-csharp[](samples/integration-testing/server/Program.cs?name=snippet_IntegrationTestServer)] + +Create an `HttpClient` from `WebApplicationFactory` and pass that same client to . The factory client is backed by the in-memory test server; constructing `HttpClientTransport` without it would create a separate `HttpClient` that cannot reach the in-process application. + +[!code-csharp[](samples/integration-testing/tests/McpServerTests.cs?name=snippet_IntegrationTest)] + +The test project needs references to the server project and `ModelContextProtocol.Core`, plus the `Microsoft.AspNetCore.Mvc.Testing` package. Keep ownership of the factory-created `HttpClient` in the test, dispose the MCP client and transport asynchronously, and pass the test cancellation token to MCP operations so a failed connection cannot hang the test run. + #### Host name validation For local HTTP servers, keep the set of accepted host names limited to loopback values. This helps protect against DNS rebinding, where a browser reaches a local server through an attacker-controlled DNS name while sending that DNS name in the HTTP `Host` header. ASP.NET Core's Kestrel server doesn't validate `Host` headers by default, so configure `AllowedHosts` with known host names rather than `"*"`. This also avoids reflecting untrusted host names through ASP.NET Core features such as absolute URL generation. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering) and [URL generation concepts | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/routing#url-generation-concepts).