Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/concepts/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ 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).
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

#### Request origin validation
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

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).
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

For the full HTTP security examples, including `AllowedHosts` and restrictive CORS on `MapMcp`, see [Streamable HTTP transport](transports/transports.md#request-origin-validation).
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

### 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:
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/httpcontext/samples/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "localhost;127.0.0.1;[::1]"
}
2 changes: 1 addition & 1 deletion docs/concepts/logging/samples/server/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "localhost;127.0.0.1;[::1]"
}
2 changes: 1 addition & 1 deletion docs/concepts/progress/samples/server/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "localhost;127.0.0.1;[::1]"
}
53 changes: 53 additions & 0 deletions docs/concepts/transports/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,59 @@ 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

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).
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

```json
// appsettings.Development.json
{
"AllowedHosts": "localhost;127.0.0.1;[::1]"
}
```

For local development, we recommend keeping `AllowedHosts` limited to loopback values such as `localhost;127.0.0.1;[::1]`.
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

#### Request origin validation
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

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.
Comment thread
jeffhandley marked this conversation as resolved.
Outdated

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.


_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
{
"Mcp": {
"AllowedOrigins": [
"http://localhost:5173"
]
}
}
```

```csharp
var allowedOrigins = builder.Configuration.GetSection("Mcp:AllowedOrigins").Get<string[]>() ?? ["http://localhost:5173"];

builder.Services.AddCors(options =>
{
options.AddPolicy("McpBrowserClient", policy =>
{
policy.WithOrigins(allowedOrigins)
.WithMethods("POST")
Comment thread
jeffhandley marked this conversation as resolved.
Outdated
.WithHeaders("Content-Type", "Authorization", "MCP-Protocol-Version")
Comment thread
jeffhandley marked this conversation as resolved.
Outdated
.WithExposedHeaders("Mcp-Session-Id");
});
});

var app = builder.Build();

app.UseCors();
app.MapMcp("/mcp").RequireCors("McpBrowserClient");
```

#### 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.
Expand Down
4 changes: 2 additions & 2 deletions samples/AspNetCoreMcpPerSessionTools/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"AspNetCoreMcpPerSessionTools": "Debug"
}
},
"AllowedHosts": "*"
}
"AllowedHosts": "localhost;127.0.0.1;[::1]"
}
20 changes: 19 additions & 1 deletion samples/AspNetCoreMcpServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@
using System.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);
var allowedOrigins = builder.Configuration.GetSection("Mcp:AllowedOrigins").Get<string[]>() ?? ["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.
Expand Down Expand Up @@ -36,6 +53,7 @@

var app = builder.Build();

app.MapMcp();
app.UseCors();
app.MapMcp().RequireCors("McpBrowserClient");

app.Run();
7 changes: 6 additions & 1 deletion samples/AspNetCoreMcpServer/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "localhost;127.0.0.1;[::1]",
"Mcp": {
"AllowedOrigins": [
"http://localhost:5173"
]
}
}
2 changes: 1 addition & 1 deletion samples/EverythingServer/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "localhost;127.0.0.1;[::1]"
}
9 changes: 9 additions & 0 deletions samples/LongRunningTasks/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "localhost;127.0.0.1;[::1]"
}
24 changes: 23 additions & 1 deletion samples/ProtectedMcpServer/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,6 +10,26 @@

var serverUrl = "http://localhost:7071/";
var inMemoryOAuthServerUrl = "https://localhost:7029";
var allowedOrigins = builder.Configuration.GetSection("Mcp:AllowedOrigins").Get<string[]>() ?? ["http://localhost:5173"];

// 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 =>
{
policy.WithOrigins(allowedOrigins)
.WithMethods("POST")
.WithHeaders(HeaderNames.ContentType, HeaderNames.Authorization, "MCP-Protocol-Version")
.WithExposedHeaders(HeaderNames.WWWAuthenticate);
});
});

builder.Services.AddAuthentication(options =>
{
Expand Down Expand Up @@ -84,11 +105,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}");
Expand Down
14 changes: 14 additions & 0 deletions samples/ProtectedMcpServer/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "localhost;127.0.0.1;[::1]",
"Mcp": {
"AllowedOrigins": [
"http://localhost:5173"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using ModelContextProtocol.Tests.Utils;

Expand Down Expand Up @@ -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");

Expand All @@ -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");

Expand Down
Loading