Build an MCP Server in C# (.NET 11): stdio, HTTP and Auth
Last updated: October 28, 2026 · Tested with .NET 11 RC3 (SDK 11.0.100-rc.3.26522.148), ModelContextProtocol 2.1.0, ModelContextProtocol.AspNetCore 2.1.0, Microsoft.AspNetCore.Authentication.JwtBearer 11.0.0-rc.3, Windows 11
Short answer: to build an MCP server in C#, run dotnet new mcpserver (the template ships with the .NET 11 SDK), add your tools as methods marked [McpServerTool], and register them with AddMcpServer().WithStdioServerTransport().WithTools<T>(). Use -t remote for an HTTP server that you protect with normal ASP.NET Core authentication. I built both versions, called them from a C# MCP client, and broke them on purpose: the outputs below are from those runs, including the three things that surprised me.
| What I saw | Why | Section |
|---|---|---|
An error occurred invoking 'crash'. and no details |
Only McpException messages reach the client |
4 |
| Log lines mixed into the protocol stream, client still works | Logs on stdout; the C# client drops the bad lines, other clients may not | 5 |
406 Not Acceptable: Client must accept both application/json and text/event-stream |
Streamable HTTP needs both types in Accept |
6 |
The JSON-RPC answer arrives as event: message / data: {...} |
The HTTP transport answers as Server-Sent Events | 6 |
401 (Unauthorized) from McpClient.CreateAsync |
No bearer token; AdditionalHeaders fixes it |
7 |
1. Create the Server From the Template
dotnet new mcpserver -n LocalMcp -o LocalMcp
dotnet new mcpserver -n RemoteMcp -o RemoteMcp -t remote
-t local (the default) creates a console app that talks over stdin/stdout, the way VS Code, Visual Studio and desktop AI apps start local servers. -t remote creates an ASP.NET Core app with the HTTP transport. On the RC3 SDK the only framework choice is net11.0; the ModelContextProtocol package itself also ships net8.0 and net10.0 builds, so you can add it to an existing .NET 8 or 10 project by hand. The important parts of the generated project file:
<PackAsTool>true</PackAsTool>
<PackageType>McpServer</PackageType>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="11.0.0-rc.3.26522.148" />
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
PackageType McpServer lets you publish the server to NuGet as an MCP server package, and SelfContained means users don’t need the .NET 11 runtime installed. The generated Program.cs:
var builder = Host.CreateApplicationBuilder(args);
// Configure all logs to go to stderr (stdout is used for the MCP protocol messages).
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
// Add the MCP services: the transport to use (stdio) and the tools to register.
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<RandomNumberTools>();
await builder.Build().RunAsync();
Keep the logging line. Section 5 shows what happens without it.
2. Add Real Tools (With Dependency Injection)
The template’s sample tool returns a random number. A real tool needs services, so I added an order lookup with a constructor-injected store, an array parameter, and two tools that misbehave on purpose:
public record OrderLine(string Sku, int Quantity);
[McpServerToolType]
public class OrderTools(OrderStore store)
{
[McpServerTool, Description("Returns the status of an order.")]
public string GetOrderStatus([Description("The order number, for example 1001")] int orderId)
=> store.GetStatus(orderId) ?? throw new McpException($"Order {orderId} was not found.");
[McpServerTool, Description("Calculates the total price of order lines.")]
public decimal GetOrderTotal(OrderLine[] lines)
=> lines.Sum(l => l.Quantity * (l.Sku == "KB-01" ? 49.00m : 19.50m));
[McpServerTool, Description("Fails with a normal .NET exception.")]
public string Crash() => throw new InvalidOperationException("Connection string 'Orders' is missing.");
builder.Services.AddSingleton<OrderStore>();
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<RandomNumberTools>()
.WithTools<OrderTools>();
Constructor injection works as long as the service is registered. The [Description] attributes matter: they become the text the AI model reads to decide when to call your tool and what to pass.
3. Call It From a C# MCP Client
Instead of a chat app, I tested with a small client from the same package, so every run is repeatable:
IClientTransport transport = args[0] == "stdio"
? new StdioClientTransport(new StdioClientTransportOptions { Name = "LocalMcp", Command = args[1] }, loggerFactory)
: new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(args[1]),
AdditionalHeaders = args.Length > 2 ? new Dictionary<string, string> { ["Authorization"] = $"Bearer {args[2]}" } : null,
}, loggerFactory: loggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: loggerFactory);
Console.WriteLine($"Connected to {client.ServerInfo.Name} {client.ServerInfo.Version}");
foreach (var tool in await client.ListToolsAsync())
Console.WriteLine($"tool: {tool.Name} {tool.JsonSchema.GetRawText()}");
var result = await client.CallToolAsync(name, arguments);
(The loggerFactory is only there for the trace logs in section 5.) For stdio, the client starts the server process itself (Command is the server’s exe) and talks to it over stdin/stdout. What it saw:
Connected to LocalMcp 1.0.0.0
tool: get_order_status {"type":"object","properties":{"orderId":{"description":"The order number, for example 1001","type":"integer"}},"required":["orderId"]}
tool: get_random_number {"type":"object","properties":{"min":{"description":"Minimum value (inclusive)","type":"integer","default":0},"max":{"description":"Maximum value (exclusive)","type":"integer","default":100}}}
tool: get_order_total {"type":"object","properties":{"lines":{"type":"array","items":{"type":"object","properties":{"sku":{"type":"string"},"quantity":{"type":"integer"}},"required":["sku","quantity"]}}},"required":["lines"]}
call get_random_number: isError=False 4
call get_order_status: isError=False Shipped
call get_order_total: isError=False 117.50
Three things to notice. The method names become snake_case tool names (GetOrderStatus → get_order_status), so that’s the name clients call. Parameters with defaults are optional in the schema, and parameters without them are required. And the OrderLine record became a nested JSON schema with camelCase properties, so the client sends {"sku":"KB-01","quantity":2}, not Sku/Quantity.
4. Errors: Only McpException Messages Reach the Client
call get_order_status: isError=True An error occurred invoking 'get_order_status': Order 42 was not found.
call crash: isError=True An error occurred invoking 'crash'.
Both calls return a normal result with isError=True, not a protocol error, so the AI model can read the message and try again. The difference is the text. The McpException message (“Order 42 was not found.”) goes to the client; the InvalidOperationException message (“Connection string ‘Orders’ is missing.”) is replaced by a generic line. That’s a sensible default, because exception messages can leak internal details into a chat transcript. Throw McpException for errors the model should see (not found, invalid input), and let everything else stay generic and go to your logs.
5. The stdout Trap
With the stdio transport, stdout is the protocol channel. The MCP specification says the server must not write anything to stdout that isn’t a valid MCP message. The template sends logs to stderr for exactly this reason. Here is what the server writes to stdout when I remove that logging line (the default console logger writes to stdout):
info: ModelContextProtocol.Server.StdioServerTransport[857250842]
Server (stream) (LocalMcp) transport reading messages.
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
{"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"tools":{"listChanged":true}},"serverInfo":{"name":"LocalMcp","version":"1.0.0.0"}},"id":1,"jsonrpc":"2.0"}
Log text and JSON-RPC are now mixed in one stream. The surprise: my C# client kept working, with every call succeeding. Its trace log shows why (lines shortened):
trce: ModelContextProtocol.Client.StdioClientSessionTransport: LocalMcp transport message parsing failed. Message: 'info: ModelContextProtocol.Server.StdioServerTransport[857250842]'. System.Text.Json.JsonException: 'i' is an invalid start of a value.
trce: ModelContextProtocol.Client.StdioClientSessionTransport: LocalMcp transport message parsing failed. Message: 'Server (stream) (LocalMcp) transport reading messages.'. System.Text.Json.JsonException: 'S' is an invalid start of a value.
The C# client drops each line that isn’t JSON and logs it only at Trace level. A Console.WriteLine inside a tool behaves the same way. So “it works with my test client” proves little: other clients aren’t required to be this forgiving, and I didn’t test them here. Rule: in a stdio server, logs go to stderr, and tools never write to Console.
6. Remote Server: Streamable HTTP
The -t remote template:
builder.Services
.AddMcpServer()
.WithHttpTransport(options =>
{
// Stateless mode is recommended for servers that don't need
// server-to-client requests like sampling or elicitation.
options.Stateless = true;
})
.WithTools<RandomNumberTools>();
var app = builder.Build();
app.MapMcp();
The same C# client connects with HttpClientTransport and an Endpoint of http://localhost:6115/:
Connected to RemoteMcp 1.0.0.0
call get_random_number: isError=False 3
Calling it by hand shows what the transport expects. A correct request:
curl -i -X POST http://localhost:6115/ -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" -H "MCP-Protocol-Version: 2025-11-25" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_random_number","arguments":{"min":1,"max":7}}}'
HTTP/1.1 200 OK
Content-Type: text/event-stream
event: message
data: {"result":{"content":[{"type":"text","text":"2"}]},"id":1,"jsonrpc":"2.0"}
Even a single answer comes back as a Server-Sent Event, not a plain JSON body, so hand-written client code has to read the JSON from the data: line. And with only Accept: application/json:
HTTP/1.1 406 Not Acceptable
{"error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null,"jsonrpc":"2.0"}
A GET to the endpoint returns 405 Method Not Allowed with Allow: POST in stateless mode, because there is no server-to-client stream to open. That’s expected, not a routing problem.
7. Authentication for a Remote MCP Server
An HTTP MCP server is a normal ASP.NET Core endpoint, so it uses normal authentication. JWT bearer plus RequireAuthorization() on MapMcp():
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
For local testing, dotnet user-jwts creates a signed development token and writes the issuer and audiences into appsettings.Development.json:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --prerelease
dotnet user-jwts create --name mcp-test --output token
### client, no token
System.Net.Http.HttpRequestException: Response status code does not indicate success: 401 (Unauthorized).
### client, with token
Connected to SecureMcp 1.0.0.0
call get_random_number: isError=False 4
### client, tampered token (last character changed)
System.Net.Http.HttpRequestException: Response status code does not indicate success: 401 (Unauthorized).
The client sends the token through AdditionalHeaders on HttpClientTransportOptions (the client code in section 3).
Let OAuth clients discover your login server
Without a token, the plain JWT setup answers WWW-Authenticate: Bearer and nothing more. Clients that sign the user in themselves (the MCP authorization spec uses OAuth) need to know which authorization server to use. The SDK’s AddMcp() authentication handler adds that:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Bearer";
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
})
.AddJwtBearer()
.AddMcp(options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = "http://localhost:6117/",
AuthorizationServers = { "https://login.example.com/" },
ScopesSupported = { "mcp:tools" },
};
});
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:6117/.well-known/oauth-protected-resource/"
### metadata
HTTP/1.1 200 OK
{"resource":"http://localhost:6117/","authorization_servers":["https://login.example.com/"],"bearer_methods_supported":["header"],"scopes_supported":["mcp:tools"]}
The 401 now points to a metadata document, and that document names your authorization server (Entra ID, Auth0, Keycloak or your own). I tested the discovery responses only, with a placeholder authorization server, not a full OAuth sign-in from a chat client.
8. Use It From VS Code or Visual Studio
The template’s README gives this configuration for running the server from source (in .vscode/mcp.json for VS Code, .mcp.json next to the solution for Visual Studio):
{
"servers": {
"LocalMcp": {
"type": "stdio",
"command": "dotnet",
"args": [
"run",
"--project",
"<PATH TO PROJECT DIRECTORY>"
]
}
}
}
Checklist
- Start from
dotnet new mcpserver;-t remotefor HTTP. - Good
[Description]text on every tool and parameter; the model reads it. - Throw
McpExceptionfor errors the model should see; other exceptions stay generic. - stdio: logs to stderr, no
Console.WriteLinein tools. - HTTP clients send
Accept: application/json, text/event-streamand read the SSEdata:line. - Remote servers:
MapMcp().RequireAuthorization(), andAddMcp()resource metadata for OAuth clients. - Test with a client in code before testing in a chat app; it’s repeatable.
References: the MCP C# SDK on GitHub and the MCP transports specification.
Related: Semantic Kernel C# tutorial · AI chatbot with C# and OpenAI · upgrading to .NET 11 · C# 15 union types · all AI in .NET tutorials
One Comment