{"slug": "don-t-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-asp-net", "title": "Don't Hand Your AI Agent the Keys: Building a Secure Remote MCP Server in ASP.NET Core", "summary": "A developer published a walkthrough for building a secure remote Model Context Protocol server in ASP.NET Core using the official MCP C# SDK 2.2 on .NET 10, wrapping the stateless 2026-07-28 spec with API-key authentication, role-based tool authorization, per-caller rate limiting, call auditing and human approval. The support-desk example exposes read, write and destructive tools gated by roles, and ships as a project with 52 passing tests that runs without an AI key. The author notes the 2.x SDK removed the initialize handshake and Mcp-Session-Id header, so security can no longer rely on session state.", "body_md": "*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)*\n\n*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.*\n\nMost 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.?\n\nThis article answers those questions with the official MCP C# SDK 2.2 on .NET 10. We'll build a support desk MCP server where:\n\nEverything here comes from a complete project with 52 passing tests. It runs without an AI key.\n\nAn 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:\n\n`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.\nThe 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.\n\n```\nAgent (IChatClient + FunctionInvokingChatClient)\n   â”‚  Streamable HTTP, X-Api-Key header, MCP-Protocol-Version: 2026-07-28\n   â–¼\nASP.NET Core pipeline\n   Host filtering (AllowedHosts) â†’ Authentication (API key â†’ ClaimsPrincipal)\n   â†’ Authorization (endpoint requires an authenticated user)\n   â†’ Rate limiting (partitioned per caller)\n   â–¼\nMapMcp(\"/mcp\")   SessionMode = Stateless\n   AddAuthorizationFilters()   [Authorize(Roles = ...)] on tool classes\n   Call-tool audit filter      caller, tool, outcome, duration\n   â–¼\nTools: get_ticket Â· list_my_tickets Â· search_knowledge_base   (any role)\n       add_ticket_comment Â· escalate_ticket                  (Agent, Admin)\n       close_ticket                                          (Admin, destructive)\n```\n\nThe 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*.\n\n```\ndotnet new web -n SupportDesk.McpServer\ndotnet add package ModelContextProtocol.AspNetCore --version 2.2.0\njs\nbuilder.Services.AddMcpServer(o =>\n    {\n        o.ServerInfo = new Implementation { Name = \"support-desk\", Version = \"1.0.0\" };\n        o.ServerInstructions = \"Look tickets up before changing them...\";\n    })\n    .WithHttpTransport(http => http.SessionMode = HttpServerSessionMode.Stateless)\n    .AddAuthorizationFilters()\n    .WithTools<TicketReadTools>()\n    .WithTools<TicketWriteTools>()\n    .WithTools<TicketAdminTools>();\n\nvar app = builder.Build();\napp.UseAuthentication();\napp.UseAuthorization();\napp.UseRateLimiter();\n\napp.MapMcp(\"/mcp\")\n    .RequireAuthorization()\n    .RequireRateLimiting(\"per-caller\");\n```\n\nStateless 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.\n\n`MapMcp` returns a normal endpoint convention builder, so `RequireAuthorization()` and `RequireRateLimiting()` work as they do on any minimal API.\n\nFor 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**:\n\n``` js\nvar presented = SHA256.HashData(Encoding.UTF8.GetBytes(headerValue));\n\nApiKeyClient? match = null;\nforeach (var client in Options.Clients)\n{\n    if (TryDecode(client.KeySha256, out var expected)\n        && CryptographicOperations.FixedTimeEquals(presented, expected))\n    {\n        match ??= client;\n    }\n}\n\nif (match is null) return Task.FromResult(AuthenticateResult.Fail(\"Invalid API key.\"));\n\nvar claims = new List<Claim> { new(ClaimTypes.Name, match.Name) };\nclaims.AddRange(match.Roles.Select(r => new Claim(ClaimTypes.Role, r)));\nvar identity = new ClaimsIdentity(claims, \"ApiKey\", ClaimTypes.Name, ClaimTypes.Role);\n```\n\nThe handler produces an ordinary `ClaimsPrincipal`. The SDK copies it from `HttpContext.User` into every MCP request, so filters and tools can use it.\n\nWhen 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`.\n\nWith `AddAuthorizationFilters()`, standard `[Authorize]` attributes work on tool classes and methods:\n\n```\n[McpServerToolType]\n[Authorize(Roles = \"Agent,Admin\")]\npublic sealed class TicketWriteTools(TicketStore tickets)\n{\n    [McpServerTool(Name = \"escalate_ticket\", ReadOnly = false, Destructive = false,\n                   Idempotent = true, UseStructuredContent = true)]\n    [Description(\"Escalates a ticket to the platform team and raises its priority to at least High.\")]\n    public TicketDetails Escalate(\n        ClaimsPrincipal user,\n        [Description(\"Ticket id in the form TCK-1234.\")] string ticketId,\n        [Description(\"Why the ticket needs escalation.\")] string reason)\n    {\n        var id = ToolGuard.TicketId(ticketId);\n        var text = ToolGuard.Text(reason, nameof(reason));\n        return ToolGuard.Run(() => TicketDetails.From(tickets.Escalate(id, user.Identity!.Name!, text)));\n    }\n}\n```\n\nThe 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.\n\nHiding tools matters more than it sounds. A model can't be talked into calling a tool it never heard of.\n\nLook 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.\n\n`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.\n\nErrors need more care. The SDK separates them like this:\n\n`McpProtocolException` becomes a `McpException` becomes a `IsError = true`\n\n``` js\npublic static string TicketId(string? ticketId)\n{\n    var id = ticketId?.Trim();\n    if (!TicketStore.IsValidId(id))\n        throw new McpProtocolException(\n            $\"'{ticketId}' is not a valid ticket id. Expected the form TCK-1234.\",\n            McpErrorCode.InvalidParams);\n    return id!.ToUpperInvariant();\n}\n\npublic static T Run<T>(Func<T> action)\n{\n    try { return action(); }\n    catch (TicketNotFoundException ex) { throw new McpException(ex.Message); }\n    catch (TicketRuleException ex) { throw new McpException(ex.Message); }\n}\n```\n\nWhen 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.\n\nA call-tool filter wraps every tool invocation:\n\n``` js\n.WithRequestFilters(f => f.AddCallToolFilter(next => async (context, ct) =>\n{\n    var audit = context.Services!.GetRequiredService<AuditLog>();\n    var caller = context.User?.Identity?.Name ?? \"anonymous\";\n    var argumentNames = context.Params?.Arguments?.Keys.Order().ToArray() ?? [];\n    var started = Stopwatch.GetTimestamp();\n    try\n    {\n        var result = await next(context, ct);\n        audit.Record(new(DateTimeOffset.UtcNow, caller, context.Params!.Name,\n            result.IsError == true ? \"tool_error\" : \"ok\",\n            Stopwatch.GetElapsedTime(started).TotalMilliseconds, argumentNames));\n        return result;\n    }\n    catch (Exception ex) when (ex is not OperationCanceledException)\n    {\n        // record protocol_error / tool_error, then rethrow\n        throw;\n    }\n}));\n```\n\nNotice 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.\n\nHere'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.\n\nOn 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.\n\nWhich 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:\n\n``` js\npublic static bool RequiresApproval(Tool tool)\n{\n    var a = tool.Annotations;\n    if (a?.ReadOnlyHint == true) return false;\n    return a?.DestructiveHint != false;\n}\n\nvar tools = (await mcp.ListToolsAsync())\n    .Select(t => RequiresApproval(t.ProtocolTool) ? new ApprovalRequiredAIFunction(t) : (AITool)t)\n    .ToList();\n```\n\nThe agent loop answers approval requests and calls the model again:\n\n``` js\nvar response = await chatClient.GetResponseAsync(history, new() { Tools = tools });\nhistory.AddMessages(response);\n\nvar requests = response.Messages.SelectMany(m => m.Contents)\n    .OfType<ToolApprovalRequestContent>().ToList();\n\nforeach (var request in requests)\n{\n    var approved = await approve((FunctionCallContent)request.ToolCall, ct);\n    answers.Add(request.CreateResponse(approved, approved ? null : \"Rejected by operator.\"));\n}\nhistory.Add(new ChatMessage(ChatRole.User, answers));\n```\n\nA 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.\n\nFor 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.\n\n`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.\nRate 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.\n\nMCP 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.\n\n**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)\n\nHow are you locking down MCP tools in your own setup? I'd like to hear what's working for you in the comments.", "url": "https://wpnews.pro/news/don-t-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-asp-net", "canonical_source": "https://dev.to/michael_maurice/dont-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-aspnet-core-641", "published_at": "2026-10-09 18:11:50+00:00", "updated_at": "2026-10-09 18:21:58.177192+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Model Context Protocol", "MCP C# SDK", "ASP.NET Core", ".NET 10", "Claude", "Copilot", "Medium", "Gumroad"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/don-t-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-asp-net", "markdown": "https://wpnews.pro/news/don-t-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-asp-net.md", "text": "https://wpnews.pro/news/don-t-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-asp-net.txt", "jsonld": "https://wpnews.pro/news/don-t-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-asp-net.jsonld"}}