# Don't Hand Your AI Agent the Keys: Building a Secure Remote MCP Server in ASP.NET Core

> Source: <https://dev.to/michael_maurice/dont-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-aspnet-core-641>
> Published: 2026-10-09 18:11:50+00:00

*Originally published on [Medium](https://medium.com/@michaelmaurice410/dont-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-asp-net-core-e3eb311b141e). Full source code for this project is in Tech Skill Builder: [https://elitesolutions1.gumroad.com/l/TechSkillBuilder](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**:

``` js
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`

``` js
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:

``` js
.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:

``` js
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:

``` js
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](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.
