Originally published on Medium. Full source code for this project is in Tech Skill Builder: https://elitesolutions1.gumroad.com/l/TechSkillBuilder
The MCP C# SDK 2.x went stateless with the 2026-07-28 spec. Here's how to wrap it in authentication, role-based tools, auditing and human approval, with a tested .NET 10 project you can run in five minutes.
Most Model Context Protocol demos look the same. You get one Echo tool on stdio, a Claude or Copilot screenshot, and that's it. Then someone asks for the same thing as a shared HTTP service that agents across the company can call, and the hard questions start. Who is calling? Which tools should they see? What happens when the model decides to close a customer's ticket at 3 a.m.?
This article answers those questions with the official MCP C# SDK 2.2 on .NET 10. We'll build a support desk MCP server where:
Everything here comes from a complete project with 52 passing tests. It runs without an AI key.
An MCP tool is a remote procedure an LLM can call with arguments it made up. That makes it an API endpoint with a very creative client. All the usual API rules apply, plus a few new ones:
close_ticket, a prompt injection is one sentence away from using it. add_comment(author, text) takes the author as a parameter, the model can claim to be anyone.
The timing matters too. Version 2.0 of the C# SDK (July 2026) aligned with the 2026-07-28 MCP specification. That revision removes the initialize handshake and the Mcp-Session-Id header from the wire format. Clients bootstrap with server/discover, and the SDK now defaults HTTP servers to stateless mode. Stateless servers scale behind any load balancer, which is exactly what you want for a shared service. It also means you can't lean on session state for security.
Agent (IChatClient + FunctionInvokingChatClient)
│ Streamable HTTP, X-Api-Key header, MCP-Protocol-Version: 2026-07-28
â–¼
ASP.NET Core pipeline
Host filtering (AllowedHosts) → Authentication (API key → ClaimsPrincipal)
→ Authorization (endpoint requires an authenticated user)
→ Rate limiting (partitioned per caller)
â–¼
MapMcp("/mcp") SessionMode = Stateless
AddAuthorizationFilters() [Authorize(Roles = ...)] on tool classes
Call-tool audit filter caller, tool, outcome, duration
â–¼
Tools: get_ticket · list_my_tickets · search_knowledge_base (any role)
add_ticket_comment · escalate_ticket (Agent, Admin)
close_ticket (Admin, destructive)
The rule is defense in depth. ASP.NET Core decides whether you may talk to the server at all. The MCP layer decides which tools you get. The agent decides whether a human must confirm.
dotnet new web -n SupportDesk.McpServer
dotnet add package ModelContextProtocol.AspNetCore --version 2.2.0
js
builder.Services.AddMcpServer(o =>
{
o.ServerInfo = new Implementation { Name = "support-desk", Version = "1.0.0" };
o.ServerInstructions = "Look tickets up before changing them...";
})
.WithHttpTransport(http => http.SessionMode = HttpServerSessionMode.Stateless)
.AddAuthorizationFilters()
.WithTools<TicketReadTools>()
.WithTools<TicketWriteTools>()
.WithTools<TicketAdminTools>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapMcp("/mcp")
.RequireAuthorization()
.RequireRateLimiting("per-caller");
Stateless is already the default in 2.x. Set it anyway. The SDK docs recommend setting SessionMode explicitly so a future default change can't silently alter your server. If you still have older clients that need sessions, 2.2 added StatefulForInitializeClients. That mode gives initialize-handshake clients a session and serves 2026-07-28 clients statelessly on the same endpoint.
MapMcp returns a normal endpoint convention builder, so RequireAuthorization() and RequireRateLimiting() work as they do on any minimal API.
For service-to-service agents, an API key per caller is a pragmatic start. Two details make it safe. Store only a SHA-256 hash of each key in configuration, and compare in constant time:
var presented = SHA256.HashData(Encoding.UTF8.GetBytes(headerValue));
ApiKeyClient? match = null;
foreach (var client in Options.Clients)
{
if (TryDecode(client.KeySha256, out var expected)
&& CryptographicOperations.FixedTimeEquals(presented, expected))
{
match ??= client;
}
}
if (match is null) return Task.FromResult(AuthenticateResult.Fail("Invalid API key."));
var claims = new List<Claim> { new(ClaimTypes.Name, match.Name) };
claims.AddRange(match.Roles.Select(r => new Claim(ClaimTypes.Role, r)));
var identity = new ClaimsIdentity(claims, "ApiKey", ClaimTypes.Name, ClaimTypes.Role);
The handler produces an ordinary ClaimsPrincipal. The SDK copies it from HttpContext.User into every MCP request, so filters and tools can use it.
When the client acts for a person, like an IDE using your server on behalf of a developer, use OAuth instead. The MCP authorization spec builds on OAuth 2.1 and Protected Resource Metadata (RFC 9728). The SDK supports it through AddJwtBearer() plus its AddMcp() authentication scheme. The rest of this design doesn't change, because both paths end in a ClaimsPrincipal.
With AddAuthorizationFilters(), standard [Authorize] attributes work on tool classes and methods:
[McpServerToolType]
[Authorize(Roles = "Agent,Admin")]
public sealed class TicketWriteTools(TicketStore tickets)
{
[McpServerTool(Name = "escalate_ticket", ReadOnly = false, Destructive = false,
Idempotent = true, UseStructuredContent = true)]
[Description("Escalates a ticket to the platform team and raises its priority to at least High.")]
public TicketDetails Escalate(
ClaimsPrincipal user,
[Description("Ticket id in the form TCK-1234.")] string ticketId,
[Description("Why the ticket needs escalation.")] string reason)
{
var id = ToolGuard.TicketId(ticketId);
var text = ToolGuard.Text(reason, nameof(reason));
return ToolGuard.Run(() => TicketDetails.From(tickets.Escalate(id, user.Identity!.Name!, text)));
}
}
The SDK enforces this in two places. On tools/list, unauthorized tools are removed from the response. On tools/call, an unauthorized call is rejected with an "Access forbidden" error. Our tests check both. The viewer key sees three tools, the agent key sees five, the admin key sees six. A viewer calling escalate_ticket by name gets an exception, not a result.
Hiding tools matters more than it sounds. A model can't be talked into calling a tool it never heard of.
Look at the ClaimsPrincipal user parameter. The SDK resolves it from the current request and leaves it out of the tool's input schema. The model can't see it or set it. Comments and escalations are always attributed to the authenticated caller.
UseStructuredContent = true makes the SDK generate an output JSON Schema from the return type and serialize the value into structuredContent. Clients get a contract instead of prose they have to parse. In 2.x, non-object return values are emitted as-is (structuredContent: 72), with no { "result": ... } wrapper. That's one reason the project returns small records rather than bare arrays.
Errors need more care. The SDK separates them like this:
McpProtocolException becomes a McpException becomes a IsError = true
public static string TicketId(string? ticketId)
{
var id = ticketId?.Trim();
if (!TicketStore.IsValidId(id))
throw new McpProtocolException(
$"'{ticketId}' is not a valid ticket id. Expected the form TCK-1234.",
McpErrorCode.InvalidParams);
return id!.ToUpperInvariant();
}
public static T Run<T>(Func<T> action)
{
try { return action(); }
catch (TicketNotFoundException ex) { throw new McpException(ex.Message); }
catch (TicketRuleException ex) { throw new McpException(ex.Message); }
}
When an agent asks about TCK-9999, the model gets a tool error that includes "Ticket TCK-9999 was not found." and can tell the user. When it sends 1001 OR 1=1, the call fails as InvalidParams before any business code runs.
A call-tool filter wraps every tool invocation:
.WithRequestFilters(f => f.AddCallToolFilter(next => async (context, ct) =>
{
var audit = context.Services!.GetRequiredService<AuditLog>();
var caller = context.User?.Identity?.Name ?? "anonymous";
var argumentNames = context.Params?.Arguments?.Keys.Order().ToArray() ?? [];
var started = Stopwatch.GetTimestamp();
try
{
var result = await next(context, ct);
audit.Record(new(DateTimeOffset.UtcNow, caller, context.Params!.Name,
result.IsError == true ? "tool_error" : "ok",
Stopwatch.GetElapsedTime(started).TotalMilliseconds, argumentNames));
return result;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// record protocol_error / tool_error, then rethrow
throw;
}
}));
Notice it records argument names, never values. Tool arguments are model-generated text that often contains customer data. Your audit trail shouldn't become a second copy of it.
Here's a detail I only found while testing. Denied calls never reach this filter. The SDK's tool authorization runs before the ordinary call-tool filter pipeline, so a forbidden call is rejected first. It does go through ASP.NET Core's IAuthorizationService, with the MCP RequestContext<CallToolRequestParams> as the resource. So the project decorates the default authorization service and records failures for that resource type. Failures for tools/list filtering are skipped, since a hidden tool isn't an incident.
On the client side, MCP tools plug straight into Microsoft.Extensions.AI: McpClientTool is an AIFunction. That lets us use MEAI's approval support. Wrap a tool in ApprovalRequiredAIFunction, and FunctionInvokingChatClient returns a ToolApprovalRequestContent instead of running it.
Which tools need approval? Tool annotations tell us. Under the MCP schema, readOnlyHint defaults to false and destructiveHint defaults to true. So a tool that says nothing about itself should be treated as destructive:
public static bool RequiresApproval(Tool tool)
{
var a = tool.Annotations;
if (a?.ReadOnlyHint == true) return false;
return a?.DestructiveHint != false;
}
var tools = (await mcp.ListToolsAsync())
.Select(t => RequiresApproval(t.ProtocolTool) ? new ApprovalRequiredAIFunction(t) : (AITool)t)
.ToList();
The agent loop answers approval requests and calls the model again:
var response = await chatClient.GetResponseAsync(history, new() { Tools = tools });
history.AddMessages(response);
var requests = response.Messages.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>().ToList();
foreach (var request in requests)
{
var approved = await approve((FunctionCallContent)request.ToolCall, ct);
answers.Add(request.CreateResponse(approved, approved ? null : "Rejected by operator."));
}
history.Add(new ChatMessage(ChatRole.User, answers));
A word of caution: the spec says clients MUST treat annotations as untrusted unless they come from trusted servers. They're fine for a server you operate. For third-party servers, keep your own allow list.
For a real model, the project uses OpenAI through the Responses API (GetResponsesClient().AsIChatClient("gpt-6-luna")). OpenAI's current guidance is to use Responses for tool calling with the GPT-6 family. For tests and demos, a deterministic offline IChatClient drives the same loop with no key.
ClaimsPrincipal. SessionMode explicitly. Choose Stateless unless you truly need server-push or session state.[Authorize] is enforced.UseStructuredContent = true, and keep the contract stable (strings instead of enums on the wire).ReadOnly, Destructive and Idempotent. Clients use those hints to decide on confirmation. AllowedHosts as *. Host header.
Rate limiting is partitioned by authenticated caller, so one runaway agent loop gets HTTP 429 without starving everyone else. The limiter is per instance, so keep a gateway limit as well. Text inputs are capped at 2,000 characters, which bounds the cost of anything you forward downstream. On the client, FunctionInvokingChatClient caps tool iterations per request, and IncludeDetailedErrors = false keeps exception text away from the model. Stateless mode means every request carries its own protocol version and identity, so any instance can serve any call. Scaling out is just adding replicas.
MCP makes it easy to give an LLM tools. The 2.x SDK makes it easy to serve them over stateless HTTP. Making that safe is still your job, and it's mostly the ASP.NET Core you already know: authentication, [Authorize], filters, rate limiting and host filtering. MCP adds two habits on top. Hide tools from callers who can't use them, and get a human's yes before destructive actions. Get those right and an MCP server is just another well-run API.
Want to run it yourself? The complete .NET 10 solution from this post (the MCP server, the agent with human approval, and all 52 tests) is in Tech Skill Builder, along with a step-by-step PDF guide. It runs offline, no API key needed. Members get a new tested .NET + AI project every day: https://elitesolutions1.gumroad.com/l/TechSkillBuilder
How are you locking down MCP tools in your own setup? I'd like to hear what's working for you in the comments.