From 4eea79d552ed6e3ac432441dc9f1e4f187f1747f Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 17 Apr 2026 15:53:00 -0700 Subject: [PATCH 1/6] Document host and origin validation for ASP.NET Core servers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/getting-started.md | 10 ++ .../httpcontext/samples/appsettings.json | 2 +- .../logging/samples/server/appsettings.json | 2 +- .../progress/samples/server/appsettings.json | 2 +- docs/concepts/transports/transports.md | 49 +++++++ .../appsettings.json | 4 +- samples/AspNetCoreMcpServer/Program.cs | 21 ++- samples/AspNetCoreMcpServer/appsettings.json | 7 +- samples/EverythingServer/appsettings.json | 2 +- samples/ProtectedMcpServer/Program.cs | 18 ++- samples/ProtectedMcpServer/appsettings.json | 14 ++ .../MapMcpStreamableHttpTests.cs | 132 ++++++++++++++++++ 12 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 samples/ProtectedMcpServer/appsettings.json diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index f009ec31e..19c9f191d 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -101,6 +101,16 @@ public static class EchoTool } ``` +#### Host name validation + +Use ASP.NET Core host filtering to limit which host names the server will respond to. Set `AllowedHosts` in configuration, commonly in `appsettings.Development.json` for local loopback development and in environment-specific configuration for deployed hosts. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering). + +#### Request origin validation + +Use a restrictive ASP.NET Core CORS policy to limit which browser origins can call the MCP endpoint. This is ASP.NET Core's built-in mechanism for validating the browser `Origin` header on cross-origin requests. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). + +For the full HTTP security examples, including `AllowedHosts` and restrictive CORS on `MapMcp`, see [Streamable HTTP transport](transports/transports.md#request-origin-validation). + ### Building an MCP client Create a new console app, add the package, and replace `Program.cs` with the code below. This client connects to the MCP "everything" reference server, lists its tools, and calls one: diff --git a/docs/concepts/httpcontext/samples/appsettings.json b/docs/concepts/httpcontext/samples/appsettings.json index 10f68b8c8..757d8426e 100644 --- a/docs/concepts/httpcontext/samples/appsettings.json +++ b/docs/concepts/httpcontext/samples/appsettings.json @@ -5,5 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "localhost;127.0.0.1;[::1]" } diff --git a/docs/concepts/logging/samples/server/appsettings.json b/docs/concepts/logging/samples/server/appsettings.json index 10f68b8c8..757d8426e 100644 --- a/docs/concepts/logging/samples/server/appsettings.json +++ b/docs/concepts/logging/samples/server/appsettings.json @@ -5,5 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "localhost;127.0.0.1;[::1]" } diff --git a/docs/concepts/progress/samples/server/appsettings.json b/docs/concepts/progress/samples/server/appsettings.json index 10f68b8c8..757d8426e 100644 --- a/docs/concepts/progress/samples/server/appsettings.json +++ b/docs/concepts/progress/samples/server/appsettings.json @@ -5,5 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "localhost;127.0.0.1;[::1]" } diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 57a7c89b9..cbd7eeb96 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -133,6 +133,55 @@ app.Run(); By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:stateless#forward-and-backward-compatibility). See [Sessions](xref:stateless) 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. +#### Host name validation + +Use ASP.NET Core host filtering to limit which host names the server will respond to. Configure `AllowedHosts` in app configuration, such as `appsettings.Development.json` for local loopback values and environment-specific configuration for deployed hosts. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering). + +```json +// appsettings.Development.json +{ + "AllowedHosts": "localhost;127.0.0.1;[::1]" +} +``` + +#### Request origin validation + +Use a restrictive ASP.NET Core CORS policy so only trusted browser origins can call the MCP endpoint. This is ASP.NET Core's built-in mechanism for validating the browser `Origin` header on cross-origin requests. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). + +For a **stateless** browser client, a narrowly scoped CORS policy usually only needs the headers the browser would otherwise preflight: `Content-Type` for JSON, `Authorization` when the endpoint is protected, and `MCP-Protocol-Version`. If you enable sessions or resumability, also allow `Mcp-Session-Id` and `Last-Event-ID`, and expose `Mcp-Session-Id` on responses so browser code can read it. + +```json +// appsettings.Development.json +{ + "Mcp": { + "AllowedOrigins": [ + "http://localhost:5173" + ] + } +} +``` + +```csharp +var allowedOrigins = builder.Configuration.GetSection("Mcp:AllowedOrigins").Get() ?? ["http://localhost:5173"]; + +builder.Services.AddCors(options => +{ + options.AddPolicy("McpBrowserClient", policy => + { + policy.WithOrigins(allowedOrigins) + .WithMethods("POST") + .WithHeaders("Content-Type", "Authorization", "MCP-Protocol-Version"); + }); +}); + +var app = builder.Build(); + +app.UseCors(); +app.MapMcp("/mcp").RequireCors("McpBrowserClient"); +``` + +`Accept` normally doesn't need to be listed because browsers can already send it without extra CORS configuration. For local development, we recommend keeping `AllowedHosts` limited to loopback values such as `localhost;127.0.0.1;[::1]`. + #### How messages flow In Streamable HTTP, client requests arrive as HTTP POST requests. The server holds each POST response body open as an SSE stream and writes the JSON-RPC response — plus any intermediate messages like progress notifications or server-to-client requests — back through it. This provides natural HTTP-level backpressure: each POST holds its connection until the handler completes. diff --git a/samples/AspNetCoreMcpPerSessionTools/appsettings.json b/samples/AspNetCoreMcpPerSessionTools/appsettings.json index 88c89fa7d..b70c33e82 100644 --- a/samples/AspNetCoreMcpPerSessionTools/appsettings.json +++ b/samples/AspNetCoreMcpPerSessionTools/appsettings.json @@ -6,5 +6,5 @@ "AspNetCoreMcpPerSessionTools": "Debug" } }, - "AllowedHosts": "*" -} \ No newline at end of file + "AllowedHosts": "localhost;127.0.0.1;[::1]" +} diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index 3b15d07af..189b32d75 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -6,6 +6,23 @@ using System.Net.Http.Headers; var builder = WebApplication.CreateBuilder(args); +var allowedOrigins = builder.Configuration.GetSection("Mcp:AllowedOrigins").Get() ?? ["http://localhost:5173"]; + +// Only enable CORS if you intentionally want browser-based cross-origin access to this server. +// Keep the allowlist narrowly scoped to known origins. Broad CORS settings weaken security. +builder.Services.AddCors(options => +{ + options.AddPolicy("McpBrowserClient", policy => + { + policy.WithOrigins(allowedOrigins) + .WithMethods("GET", "POST", "DELETE") + // Browsers can send Accept without extra CORS configuration. These are the MCP-specific + // and non-safelisted headers the browser-based client needs for stateful Streamable HTTP. + .WithHeaders("Content-Type", "MCP-Protocol-Version", "Mcp-Session-Id") + .WithExposedHeaders("Mcp-Session-Id"); + }); +}); + // Note: This sample uses SampleLlmTool which calls server.AsSamplingChatClient() to send // a server-to-client sampling request. This requires stateful (session-based) mode. Set // Stateless = false explicitly for forward compatibility in case the default changes. @@ -36,6 +53,8 @@ var app = builder.Build(); -app.MapMcp(); +app.UseCors(); + +app.MapMcp().RequireCors("McpBrowserClient"); app.Run(); diff --git a/samples/AspNetCoreMcpServer/appsettings.json b/samples/AspNetCoreMcpServer/appsettings.json index 10f68b8c8..7649a2a19 100644 --- a/samples/AspNetCoreMcpServer/appsettings.json +++ b/samples/AspNetCoreMcpServer/appsettings.json @@ -5,5 +5,10 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "localhost;127.0.0.1;[::1]", + "Mcp": { + "AllowedOrigins": [ + "http://localhost:5173" + ] + } } diff --git a/samples/EverythingServer/appsettings.json b/samples/EverythingServer/appsettings.json index 10f68b8c8..757d8426e 100644 --- a/samples/EverythingServer/appsettings.json +++ b/samples/EverythingServer/appsettings.json @@ -5,5 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "localhost;127.0.0.1;[::1]" } diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index 4980e23dd..235fec320 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; +using Microsoft.Net.Http.Headers; using ModelContextProtocol.AspNetCore.Authentication; using ProtectedMcpServer.Tools; using System.Net.Http.Headers; @@ -9,6 +10,20 @@ var serverUrl = "http://localhost:7071/"; var inMemoryOAuthServerUrl = "https://localhost:7029"; +var allowedOrigins = builder.Configuration.GetSection("Mcp:AllowedOrigins").Get() ?? ["http://localhost:5173"]; + +// This sample is intended to be callable from a browser-based client, so we enable a +// restrictive CORS policy here. If your server is not meant for browser access, leave CORS disabled. +builder.Services.AddCors(options => +{ + options.AddPolicy("McpBrowserClient", policy => + { + policy.WithOrigins(allowedOrigins) + .WithMethods("POST") + .WithHeaders(HeaderNames.ContentType, HeaderNames.Authorization, "MCP-Protocol-Version") + .WithExposedHeaders(HeaderNames.WWWAuthenticate); + }); +}); builder.Services.AddAuthentication(options => { @@ -84,11 +99,12 @@ var app = builder.Build(); +app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); // Use the default MCP policy name that we've configured -app.MapMcp().RequireAuthorization(); +app.MapMcp().RequireAuthorization().RequireCors("McpBrowserClient"); Console.WriteLine($"Starting MCP server with authorization at {serverUrl}"); Console.WriteLine($"Using in-memory OAuth server at {inMemoryOAuthServerUrl}"); diff --git a/samples/ProtectedMcpServer/appsettings.json b/samples/ProtectedMcpServer/appsettings.json new file mode 100644 index 000000000..7649a2a19 --- /dev/null +++ b/samples/ProtectedMcpServer/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "localhost;127.0.0.1;[::1]", + "Mcp": { + "AllowedOrigins": [ + "http://localhost:5173" + ] + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index e8df75201..4f2d5aaeb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -97,6 +97,138 @@ public async Task AutoDetectMode_Works_WithRootEndpoint() Assert.Equal("AutoDetectTestServer", mcpClient.ServerInfo.Name); } + [Fact] + public async Task BrowserPreflight_AllowsConfiguredOrigin_AndRequiredHeaders() + { + Builder.Services.AddCors(options => + { + options.AddPolicy("BrowserClient", policy => + { + policy.WithOrigins("http://localhost:5173") + .WithMethods("GET", "POST", "DELETE") + .WithHeaders("Content-Type", "Authorization", "MCP-Protocol-Version", "Mcp-Session-Id") + .WithExposedHeaders("Mcp-Session-Id"); + }); + }); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless); + await using var app = Builder.Build(); + + app.UseCors(); + app.MapMcp().RequireCors("BrowserClient"); + + await app.StartAsync(TestContext.Current.CancellationToken); + + using var request = new HttpRequestMessage(HttpMethod.Options, "http://localhost:5000/"); + request.Headers.Add("Origin", "http://localhost:5173"); + request.Headers.Add("Access-Control-Request-Method", "POST"); + request.Headers.Add("Access-Control-Request-Headers", "content-type,authorization,mcp-protocol-version,mcp-session-id"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal("http://localhost:5173", Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin"))); + + var allowHeaders = string.Join(",", response.Headers.GetValues("Access-Control-Allow-Headers")); + Assert.Contains("content-type", allowHeaders, StringComparison.OrdinalIgnoreCase); + Assert.Contains("authorization", allowHeaders, StringComparison.OrdinalIgnoreCase); + Assert.Contains("mcp-protocol-version", allowHeaders, StringComparison.OrdinalIgnoreCase); + Assert.Contains("mcp-session-id", allowHeaders, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BrowserPreflight_DoesNotCorsApprove_DisallowedOrigin() + { + Builder.Services.AddCors(options => + { + options.AddPolicy("BrowserClient", policy => + { + policy.WithOrigins("http://localhost:5173") + .WithMethods("POST") + .WithHeaders("Content-Type", "MCP-Protocol-Version"); + }); + }); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless); + await using var app = Builder.Build(); + + app.UseCors(); + app.MapMcp().RequireCors("BrowserClient"); + + await app.StartAsync(TestContext.Current.CancellationToken); + + using var request = new HttpRequestMessage(HttpMethod.Options, "http://localhost:5000/"); + // CORS matches the browser Origin exactly. "localhost" and "127.0.0.1" both + // resolve to loopback, but they are different origins and do not match. + request.Headers.Add("Origin", "http://127.0.0.1:5173"); + request.Headers.Add("Access-Control-Request-Method", "POST"); + request.Headers.Add("Access-Control-Request-Headers", "content-type,mcp-protocol-version"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // ASP.NET Core's CORS middleware commonly answers the preflight with 204 even when + // the origin is not approved. The browser treats the request as disallowed because + // the Access-Control-Allow-* approval headers are omitted from the response. + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(response.Headers.Contains("Access-Control-Allow-Headers")); + } + + [Fact] + public async Task InitializeResponse_ExposesMcpSessionId_ForBrowserClients() + { + Builder.Services.AddCors(options => + { + options.AddPolicy("BrowserClient", policy => + { + policy.WithOrigins("http://localhost:5173") + .WithMethods("POST") + .WithHeaders("Content-Type", "MCP-Protocol-Version") + .WithExposedHeaders("Mcp-Session-Id"); + }); + }); + + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new() + { + Name = "CorsSessionServer", + Version = "1.0.0", + }; + }).WithHttpTransport(ConfigureStateless); + await using var app = Builder.Build(); + + app.UseCors(); + app.MapMcp().RequireCors("BrowserClient"); + + await app.StartAsync(TestContext.Current.CancellationToken); + + const string initializeRequest = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"browser-client","version":"1.0.0"}}} + """; + + using var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/") + { + Content = new StringContent(initializeRequest, System.Text.Encoding.UTF8, "application/json") + }; + request.Headers.Add("Origin", "http://localhost:5173"); + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal("http://localhost:5173", Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin"))); + + var exposedHeaders = string.Join(",", response.Headers.GetValues("Access-Control-Expose-Headers")); + Assert.Contains("Mcp-Session-Id", exposedHeaders, StringComparison.OrdinalIgnoreCase); + + if (!Stateless) + { + Assert.True(response.Headers.Contains("Mcp-Session-Id")); + } + } + [Fact] public async Task SseEndpoints_AreDisabledByDefault_InStatefulMode() { From cf7a8cb766d2aacbc7945f99924d62fa9052d00c Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 17 Apr 2026 16:16:07 -0700 Subject: [PATCH 2/6] Skip unstable Windows pending conformance scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ServerConformanceTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index e538a6f3f..adb8e4ac4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.InteropServices; using System.Text; using ModelContextProtocol.Tests.Utils; @@ -109,6 +110,9 @@ public async Task RunConformanceTests() public async Task RunPendingConformanceTest_JsonSchema202012() { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Pending Node-based conformance scenario is unstable on Windows due to a libuv shutdown assertion."); var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario json-schema-2020-12"); @@ -120,6 +124,9 @@ public async Task RunPendingConformanceTest_JsonSchema202012() public async Task RunPendingConformanceTest_ServerSsePolling() { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Pending Node-based conformance scenario is unstable on Windows due to a libuv shutdown assertion."); var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario server-sse-polling"); From db5aded8fdd1bd3cf47734d9affe24d7e3ebab9c Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 17 Apr 2026 21:25:14 -0700 Subject: [PATCH 3/6] Add AllowedHosts config to LongRunningTasks sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/LongRunningTasks/appsettings.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 samples/LongRunningTasks/appsettings.json diff --git a/samples/LongRunningTasks/appsettings.json b/samples/LongRunningTasks/appsettings.json new file mode 100644 index 000000000..757d8426e --- /dev/null +++ b/samples/LongRunningTasks/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "localhost;127.0.0.1;[::1]" +} From 21b70067bbc65abfbd90a2c73c7e83e9ba4901e8 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 1 May 2026 14:18:50 -0700 Subject: [PATCH 4/6] Wordsmithing of allowed hosts and CORS guidance --- docs/concepts/getting-started.md | 4 ++-- docs/concepts/transports/transports.md | 16 ++++++++++------ samples/AspNetCoreMcpServer/Program.cs | 1 - samples/ProtectedMcpServer/Program.cs | 10 ++++++++-- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 19c9f191d..6044d6106 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -103,11 +103,11 @@ public static class EchoTool #### Host name validation -Use ASP.NET Core host filtering to limit which host names the server will respond to. Set `AllowedHosts` in configuration, commonly in `appsettings.Development.json` for local loopback development and in environment-specific configuration for deployed hosts. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering). +If you need to limit which host names the server will respond to, use ASP.NET Core host filtering. Set `AllowedHosts` in configuration, commonly in `appsettings.Development.json` for local loopback development and in environment-specific configuration for deployed hosts. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering). #### Request origin validation -Use a restrictive ASP.NET Core CORS policy to limit which browser origins can call the MCP endpoint. This is ASP.NET Core's built-in mechanism for validating the browser `Origin` header on cross-origin requests. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). +If you need to limit which browser origins can call the MCP endpoint, use a restrictive ASP.NET Core CORS policy. This is ASP.NET Core's built-in mechanism for validating the browser `Origin` header on cross-origin requests. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). For the full HTTP security examples, including `AllowedHosts` and restrictive CORS on `MapMcp`, see [Streamable HTTP transport](transports/transports.md#request-origin-validation). diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index cbd7eeb96..3e44d5161 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -135,7 +135,7 @@ By default, the HTTP transport uses **stateful sessions** — the server assigns #### Host name validation -Use ASP.NET Core host filtering to limit which host names the server will respond to. Configure `AllowedHosts` in app configuration, such as `appsettings.Development.json` for local loopback values and environment-specific configuration for deployed hosts. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering). +If you need to limit which host names the server will respond to, use ASP.NET Core host filtering. Configure `AllowedHosts` in app configuration, such as `appsettings.Development.json` for local loopback values and environment-specific configuration for deployed hosts. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering). ```json // appsettings.Development.json @@ -144,11 +144,16 @@ Use ASP.NET Core host filtering to limit which host names the server will respon } ``` +For local development, we recommend keeping `AllowedHosts` limited to loopback values such as `localhost;127.0.0.1;[::1]`. + #### Request origin validation -Use a restrictive ASP.NET Core CORS policy so only trusted browser origins can call the MCP endpoint. This is ASP.NET Core's built-in mechanism for validating the browser `Origin` header on cross-origin requests. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). +If you need to limit which browser origins can call the MCP endpoint, use a restrictive ASP.NET Core CORS policy. This is ASP.NET Core's built-in mechanism for validating the browser `Origin` header on cross-origin requests. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). Only enable CORS if you intentionally want browser-based cross-origin access to this server. + +For a **stateless** browser client, a narrowly scoped CORS policy usually only needs the headers the browser would otherwise preflight: `Content-Type` for JSON, `Authorization` when the endpoint is protected, and `MCP-Protocol-Version`. If you enable sessions or resumability, also allow `Mcp-Session-Id` and `Last-Event-ID`, and expose `Mcp-Session-Id` on responses so browser code can read it. `Accept` normally doesn't need to be listed because browsers can already send it without extra CORS configuration. -For a **stateless** browser client, a narrowly scoped CORS policy usually only needs the headers the browser would otherwise preflight: `Content-Type` for JSON, `Authorization` when the endpoint is protected, and `MCP-Protocol-Version`. If you enable sessions or resumability, also allow `Mcp-Session-Id` and `Last-Event-ID`, and expose `Mcp-Session-Id` on responses so browser code can read it. + +_In this sample below, the MCP server will allow browser calls from `localhost:5173` where a web application is making the request. In production, this allowed origin list would be configured to the trusted web application domains._ ```json // appsettings.Development.json @@ -170,7 +175,8 @@ builder.Services.AddCors(options => { policy.WithOrigins(allowedOrigins) .WithMethods("POST") - .WithHeaders("Content-Type", "Authorization", "MCP-Protocol-Version"); + .WithHeaders("Content-Type", "Authorization", "MCP-Protocol-Version") + .WithExposedHeaders("Mcp-Session-Id"); }); }); @@ -180,8 +186,6 @@ app.UseCors(); app.MapMcp("/mcp").RequireCors("McpBrowserClient"); ``` -`Accept` normally doesn't need to be listed because browsers can already send it without extra CORS configuration. For local development, we recommend keeping `AllowedHosts` limited to loopback values such as `localhost;127.0.0.1;[::1]`. - #### How messages flow In Streamable HTTP, client requests arrive as HTTP POST requests. The server holds each POST response body open as an SSE stream and writes the JSON-RPC response — plus any intermediate messages like progress notifications or server-to-client requests — back through it. This provides natural HTTP-level backpressure: each POST holds its connection until the handler completes. diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index 189b32d75..f35d0efec 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -54,7 +54,6 @@ var app = builder.Build(); app.UseCors(); - app.MapMcp().RequireCors("McpBrowserClient"); app.Run(); diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index 235fec320..f539e73bb 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -12,8 +12,14 @@ var inMemoryOAuthServerUrl = "https://localhost:7029"; var allowedOrigins = builder.Configuration.GetSection("Mcp:AllowedOrigins").Get() ?? ["http://localhost:5173"]; -// This sample is intended to be callable from a browser-based client, so we enable a -// restrictive CORS policy here. If your server is not meant for browser access, leave CORS disabled. +// This sample runs the MCP server on localhost:7071, and it is intended to be callable from a +// companion web app running on a different host (localhost:5173), while preventing requests from +// other origins. This scenario requires enabling CORS and enabling a restrictive CORS policy. +// +// If your server is not meant for cross-origin browser access, leave CORS disabled. +// +// Only apply a lenient CORS policy if your server is intended to be callable from any browser. + builder.Services.AddCors(options => { options.AddPolicy("McpBrowserClient", policy => From 11122db0da422a73973728aa36bdbd4ef009da9c Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 1 May 2026 18:11:28 -0700 Subject: [PATCH 5/6] Improve clarity of host and origin validation guidance Co-authored-by: Stephen Halter --- docs/concepts/getting-started.md | 10 +++++++--- docs/concepts/transports/transports.md | 17 +++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 6044d6106..53019ab7a 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -103,11 +103,15 @@ public static class EchoTool #### Host name validation -If you need to limit which host names the server will respond to, use ASP.NET Core host filtering. Set `AllowedHosts` in configuration, commonly in `appsettings.Development.json` for local loopback development and in environment-specific configuration for deployed hosts. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering). +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 `"*"`. -#### Request origin validation +For production servers, configure the exact public host names for the deployment, and validate the host name at the proxy or load balancer when one is responsible for forwarding client requests. 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). -If you need to limit which browser origins can call the MCP endpoint, use a restrictive ASP.NET Core CORS policy. This is ASP.NET Core's built-in mechanism for validating the browser `Origin` header on cross-origin requests. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). +#### Browser cross-origin access + +**Only** enable CORS if you intentionally want browser-based cross-origin access to this server. + +CORS is not a substitute for host name validation. When browser-based cross-origin access is required, limit which browser origins can call the MCP endpoint by using the most restrictive ASP.NET Core CORS policy possible. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). For the full HTTP security examples, including `AllowedHosts` and restrictive CORS on `MapMcp`, see [Streamable HTTP transport](transports/transports.md#request-origin-validation). diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 3e44d5161..d0d885114 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -135,7 +135,7 @@ By default, the HTTP transport uses **stateful sessions** — the server assigns #### Host name validation -If you need to limit which host names the server will respond to, use ASP.NET Core host filtering. Configure `AllowedHosts` in app configuration, such as `appsettings.Development.json` for local loopback values and environment-specific configuration for deployed hosts. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering). +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). ```json // appsettings.Development.json @@ -144,11 +144,15 @@ If you need to limit which host names the server will respond to, use ASP.NET Co } ``` -For local development, we recommend keeping `AllowedHosts` limited to loopback values such as `localhost;127.0.0.1;[::1]`. +For production servers, configure `AllowedHosts` to the exact public host names for the deployment. If Kestrel is behind a reverse proxy or load balancer, validate the host name at the layer that receives or forwards the client `Host` header. ASP.NET Core's Host Filtering Middleware is appropriate when Kestrel is public-facing or the `Host` header is directly forwarded; Forwarded Headers Middleware has its own `AllowedHosts` option for cases where the proxy doesn't preserve the original `Host` header. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering) and [Configure ASP.NET Core to work with proxy servers and load balancers | Microsoft Learn](https://learn.microsoft.com/aspnet/core/host-and-deploy/proxy-load-balancer). -#### Request origin validation +If you intentionally expose the server through another host name, such as a tunnel, container host, reverse proxy, or deployed domain, add that exact host name to `AllowedHosts` instead of using `"*"`. -If you need to limit which browser origins can call the MCP endpoint, use a restrictive ASP.NET Core CORS policy. This is ASP.NET Core's built-in mechanism for validating the browser `Origin` header on cross-origin requests. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). Only enable CORS if you intentionally want browser-based cross-origin access to this server. +#### Browser cross-origin access + +**Only** enable CORS if you intentionally want browser-based cross-origin access to this server. + +CORS is not a substitute for host name validation. When browser-based cross-origin access is required, limit which browser origins can call the MCP endpoint by using the most restrictive ASP.NET Core CORS policy possible. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). For a **stateless** browser client, a narrowly scoped CORS policy usually only needs the headers the browser would otherwise preflight: `Content-Type` for JSON, `Authorization` when the endpoint is protected, and `MCP-Protocol-Version`. If you enable sessions or resumability, also allow `Mcp-Session-Id` and `Last-Event-ID`, and expose `Mcp-Session-Id` on responses so browser code can read it. `Accept` normally doesn't need to be listed because browsers can already send it without extra CORS configuration. @@ -174,8 +178,9 @@ builder.Services.AddCors(options => options.AddPolicy("McpBrowserClient", policy => { policy.WithOrigins(allowedOrigins) - .WithMethods("POST") - .WithHeaders("Content-Type", "Authorization", "MCP-Protocol-Version") + // Add GET for standalone/resumable SSE streams and DELETE for stateful session termination. + .WithMethods("POST", "GET", "DELETE") + .WithHeaders("Content-Type", "Authorization", "MCP-Protocol-Version", "Mcp-Session-Id") .WithExposedHeaders("Mcp-Session-Id"); }); }); From bff09cefaaf2239cebe888dd3d77d1ab218dde09 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 1 May 2026 18:24:24 -0700 Subject: [PATCH 6/6] Fix link anchor Co-authored-by: Stephen Halter --- docs/concepts/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 53019ab7a..c6096aa60 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -113,7 +113,7 @@ For production servers, configure the exact public host names for the deployment CORS is not a substitute for host name validation. When browser-based cross-origin access is required, limit which browser origins can call the MCP endpoint by using the most restrictive ASP.NET Core CORS policy possible. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). -For the full HTTP security examples, including `AllowedHosts` and restrictive CORS on `MapMcp`, see [Streamable HTTP transport](transports/transports.md#request-origin-validation). +For the full HTTP security examples, including `AllowedHosts` and restrictive CORS on `MapMcp`, see [Streamable HTTP transport](transports/transports.md#browser-cross-origin-access). ### Building an MCP client