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:
- callers authenticate, and each one only sees the tools their role allows,
- tools return typed, schema-described results instead of strings,
- every call is audited, including the ones that were denied,
- a human approves anything destructive before it runs.
Everything here comes from a complete project with 52 passing tests. It runs without an AI key.
The problem: MCP tools are an API, so treat them like one
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:
-
The tool list is an attack surface. If a read-only bot can see
close_ticket, a prompt injection is one sentence away from using it. -
Identity can't come from arguments. If
add_comment(author, text)takes the author as a parameter, the model can claim to be anyone. - Errors have two audiences. The model needs readable failures ("ticket not found") so it can recover. Your logs need the details. The model must never see a stack trace.
- Some actions need a person. The MCP specification says there SHOULD always be a human in the loop with the ability to deny tool invocations.
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.
Architecture
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.
Step 1: A stateless MCP endpoint behind normal ASP.NET Core security
dotnet new web -n SupportDesk.McpServer
dotnet add package ModelContextProtocol.AspNetCore --version 2.2.0
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.
Step 2: Authenticate callers without storing secrets
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.
Step 3: Role-based tools that are invisible to the wrong caller
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.
Step 4: Typed results and two kinds of errors
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:
-
McpProtocolExceptionbecomes a JSON-RPC error. Use it when the request itself is malformed. -
McpExceptionbecomes a tool result withIsError = truethat includes your message. The model can read it and recover. - Any other exception also becomes
IsError = true, but with a generic message, so internal details don't leak.
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.
Step 5: Audit everything, including denials
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.
Step 6: Put a human in front of destructive tools
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.
Real-world use cases
- Internal support and ops copilots that read tickets broadly but escalate or close only with the right role and a human click.
- Platform APIs exposed to agents. Wrap the deployment or feature-flag API you already have, and make rollbacks destructive tools.
-
Multi-tenant SaaS integrations. Map each tenant's key or token to claims, and filter data by the injected
ClaimsPrincipal. - Regulated environments where an audit trail of who did what through which AI tool is a hard requirement.
Best practices
- Set
SessionModeexplicitly. ChooseStatelessunless you truly need server-push or session state. - Put authorization on the tool, not in the prompt. Instructions are suggestions.
[Authorize]is enforced. - Get identity from
ClaimsPrincipalinjection. Never accept "user" as an argument. - Return records with
UseStructuredContent = true, and keep the contract stable (strings instead of enums on the wire). - Annotate every tool honestly with
ReadOnly,DestructiveandIdempotent. Clients use those hints to decide on confirmation. - Use
McpExceptionfor errors the model should read, and let everything else stay generic.
Common mistakes
-
Leaving
AllowedHostsas*. The SDK docs warn this exposes local servers to DNS rebinding. The project sets loopback hosts for development and has a test that sends a hostileHostheader. - Relying on the system prompt for permissions. One prompt injection undoes it.
- Logging tool arguments. This is the fastest way to leak PII into your log store.
- Assuming an ordinary filter sees every call. Authorization failures happen earlier. Audit them separately.
- Stateful by habit. Sessions pin clients to instances and complicate scaling. The 2026-07-28 spec moved away from them for a reason.
Security and performance notes
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.
Conclusion
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.
Top comments (0)