<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MICHAEL-MAURICE</title>
    <description>The latest articles on DEV Community by MICHAEL-MAURICE (@michael_maurice).</description>
    <link>https://dev.to/michael_maurice</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F618103%2F20ba8a04-033b-4681-8ffb-a02d4116e3a7.jpg</url>
      <title>DEV Community: MICHAEL-MAURICE</title>
      <link>https://dev.to/michael_maurice</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michael_maurice"/>
    <language>en</language>
    <item>
      <title>I Got Tired of Rebuilding the Same AI SaaS Plumbing, So I Packaged It</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Fri, 09 Oct 2026 19:24:49 +0000</pubDate>
      <link>https://dev.to/michael_maurice/i-got-tired-of-rebuilding-the-same-ai-saas-plumbing-so-i-packaged-it-53j</link>
      <guid>https://dev.to/michael_maurice/i-got-tired-of-rebuilding-the-same-ai-saas-plumbing-so-i-packaged-it-53j</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@michaelmaurice410/i-got-tired-of-rebuilding-the-same-ai-saas-plumbing-so-i-packaged-it-ce49d1e3a648" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A complete multi-tenant AI SaaS in C# and .NET 10: teams, Stripe billing, metered AI credits, RAG with citations and streaming chat. 101 tests, full source, $79.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every AI product idea I've had lately started the same way. I'd get excited about the actual feature, the clever prompt or the document assistant, and then lose two weeks to everything around it.&lt;/p&gt;

&lt;p&gt;Who belongs to which organization? What happens when someone gets removed from a team? How do I stop a free user from burning $40 of GPT tokens in an afternoon? Why did Stripe send &lt;code&gt;subscription.updated&lt;/code&gt; before &lt;code&gt;subscription.created&lt;/code&gt;, and why did my app just downgrade a paying customer?&lt;/p&gt;

&lt;p&gt;None of that is the product. All of it has to work before anyone can pay you.&lt;/p&gt;

&lt;p&gt;So I built it once, properly, and tested it. Today I'm releasing it as the &lt;strong&gt;AI SaaS Starter Kit for .NET 10&lt;/strong&gt;: &lt;a href="https://elitesolutions1.gumroad.com/l/ufvbep" rel="noopener noreferrer"&gt;elitesolutions1.gumroad.com/l/ufvbep&lt;/a&gt;. For a limited time, the code &lt;strong&gt;ELITE40&lt;/strong&gt; takes 40% off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually is
&lt;/h2&gt;

&lt;p&gt;It's not a template with a login page and a "your AI feature here" comment. It's a working, multi-tenant AI SaaS written in C# on ASP.NET Core 10, EF Core 10 and Microsoft.Extensions.AI 10.10. You rename it, plug in your OpenAI and Stripe keys, and customers can sign up, invite their team, pick a plan and pay monthly, while every token they use is counted against that plan.&lt;/p&gt;

&lt;p&gt;The part I'm proudest of is small: &lt;strong&gt;it runs with no API keys at all.&lt;/strong&gt; The default provider is a little offline model that streams real answers from your uploaded documents. Clone it, run &lt;code&gt;dotnet test&lt;/code&gt;, run the app, sign in with the seeded demo account, and ask "How long do refunds take to arrive?" You get a streamed answer with a citation pointing at &lt;code&gt;refund-policy.md&lt;/code&gt;. No account, no credit card, no surprise bill. When you're ready, switching to OpenAI (or any OpenAI-compatible endpoint) is one setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's inside
&lt;/h2&gt;

&lt;p&gt;Here's the honest list, grouped the way I think about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tenancy that doesn't rely on you remembering a &lt;code&gt;Where&lt;/code&gt; clause.&lt;/strong&gt; Organizations with Owner, Admin and Member roles, invitations, and users who can belong to several orgs with a switcher. Every tenant-owned table gets an EF Core global query filter, and &lt;code&gt;SaveChanges&lt;/code&gt; has a guard that refuses cross-tenant writes. Membership is re-checked on every request, so someone you remove loses access right away, not when their cookie expires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe billing that survives real webhooks.&lt;/strong&gt; Checkout and the Customer Portal over plain REST (no SDK lock-in). Webhooks are signature-verified, recorded in an idempotency ledger, and protected against out-of-order delivery, so a late event can't roll a subscription back. Only your own Price ids can grant a paid plan. There's also a billing simulator, so you can build the whole upgrade flow before you even open a Stripe account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metered AI credits.&lt;/strong&gt; Every model call goes through a &lt;code&gt;DelegatingChatClient&lt;/code&gt; that bills the current organization. Plans get monthly credit allowances, models get per-token weights, and there's a live usage dashboard. When an org is out of credits it gets a 402 "upgrade required" &lt;em&gt;before&lt;/em&gt; any streaming starts, and when it's nearly out, the answer is capped to what the balance can afford.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG with citations.&lt;/strong&gt; Upload documents, and they're chunked, embedded and searched with cosine similarity. Answers carry numbered citations, and retrieved text is fenced off to blunt prompt injection. Moving to pgvector, Azure AI Search or Qdrant means replacing one method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming chat.&lt;/strong&gt; Server-Sent Events using .NET 10's &lt;code&gt;TypedResults.ServerSentEvents&lt;/code&gt;, conversation history, errors delivered inside the stream, and partial answers saved if the provider fails mid-reply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A public API for your customers.&lt;/strong&gt; Versioned &lt;code&gt;/api/v1&lt;/code&gt; endpoints with hashed, revocable API keys (shown once, like they should be), sharing the same credits and limits as the web app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boring production stuff.&lt;/strong&gt; Per-plan rate limits with 429 and Retry-After, CSRF protection, a strict CSP, an audit log, health checks, OpenTelemetry, a Dockerfile plus docker-compose with PostgreSQL, and GitHub Actions CI. SQLite in development so there's zero setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;101 xUnit tests, all passing.&lt;/strong&gt; Unit tests plus full integration tests with &lt;code&gt;WebApplicationFactory&lt;/code&gt;, covering tenant isolation, billing edge cases, metering, quotas, rate limits, invitations and API keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bits of real code
&lt;/h2&gt;

&lt;p&gt;I'd rather show you than tell you it's well built. This is the streaming half of the metering client, straight from the project. The &lt;code&gt;finally&lt;/code&gt; block is the whole point: it runs when the stream completes, when it fails, and when the user closes the tab halfway through. The provider charged you for those tokens either way, so the tenant gets billed either way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MeteringChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;innerClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UsageMeter&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;feature&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"chat"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;DelegatingChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;innerClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;IAsyncEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatResponseUpdate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetStreamingResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatOptions&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EnumeratorCancellation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;IList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StringBuilder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetStreamingResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConfigureAwait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="p"&gt;??=&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;TextContent&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                            &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;UsageContent&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                            &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InputTokenCount&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                            &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutputTokenCount&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;

                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Runs on completion, failure and cancellation alike: the provider charged for what it generated.&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;estimated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RecordAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;ModelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;EstimateInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;CreditCalculator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EstimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
                &lt;span class="n"&gt;estimated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConfigureAwait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... GetResponseAsync (non-streaming) does the same, minus the stream&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the provider reports token counts, it uses them. If it doesn't, it estimates and flags the record as estimated, so you can tell the difference later. Because it's ordinary Microsoft.Extensions.AI middleware, swapping providers doesn't touch the billing.&lt;/p&gt;

&lt;p&gt;The second one is the tenant guard in the &lt;code&gt;DbContext&lt;/code&gt;. Query filters stop you from &lt;em&gt;reading&lt;/em&gt; another tenant's rows. This stops you from &lt;em&gt;writing&lt;/em&gt; them, which is the bug that tends to show up at 2 a.m. in a background job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;StampTenant&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ChangeTracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entries&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ITenantOwned&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;EntityState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Added&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganizationId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganizationId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CurrentOrganizationId&lt;/span&gt;
                &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Cannot add &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; without an active organization."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;EntityState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Added&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;EntityState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Modified&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;EntityState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deleted&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="n"&gt;CurrentOrganizationId&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganizationId&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;$"Cross-tenant write blocked: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; belongs to another organization."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New rows get stamped with the current organization automatically. Anything that would touch another org's data throws. Code that genuinely needs to cross tenants, like the Stripe webhook handler, has to say so explicitly with &lt;code&gt;IgnoreQueryFilters()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who I built it for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Solo developers launching a micro-SaaS.&lt;/strong&gt; You have an idea for an AI tool for lawyers, or landlords, or dental clinics. The kit gives you signup, teams, plans, payments and usage limits on day one, so the next weekend goes into the part your customers will actually notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freelancers and agencies.&lt;/strong&gt; "Build us an AI assistant that knows our internal docs" is one of the most common client requests right now. That's document upload, grounded answers with citations, per-company isolation and usage tracking, which is this kit. The commercial license covers unlimited personal and client projects. The only thing you can't do is resell the kit itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams that want a tested reference.&lt;/strong&gt; Maybe you'll never ship this code as-is. That's fine. Having a working, tested answer to "how should multi-tenant AI billing work in .NET 10?" saves a lot of whiteboard arguments, and the 101 tests tell you exactly which edge cases were thought about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The time math
&lt;/h2&gt;

&lt;p&gt;I won't pretend there's a precise number, but here's my rough take. Getting tenant isolation right, with filters, the write guard, role checks and invitations, is a week for most people, and longer if you do it carefully. Stripe subscriptions with webhooks that are idempotent and handle out-of-order events is another week, and most first attempts get the ordering wrong. Token metering that also works for streamed and cancelled requests is a few days, plus the quota checks, plus the usage page. Then there are tests for all of it.&lt;/p&gt;

&lt;p&gt;That adds up to weeks of work that no customer will ever thank you for, because when it works it's invisible. At $79 (or about $47 with the code) it's less than one billable hour for most of us.&lt;/p&gt;

&lt;p&gt;To be clear about the limits: the kit doesn't include hosting, API credits or a Stripe account. The tests run against the offline model and the billing simulator, so you'll still want to do your own run against your real Stripe test keys and model provider before you go live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it now
&lt;/h2&gt;

&lt;p&gt;I'm genuinely excited about this one. It's the codebase I wish I'd had for every AI idea I started and abandoned at the "now add billing" stage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the code ELITE40 for 40% off: about $47 instead of $79.&lt;/strong&gt; It's a limited-time offer, so don't wait too long if you want it. And ELITE40 takes 40% off everything in my &lt;a href="https://elitesolutions1.gumroad.com" rel="noopener noreferrer"&gt;Elite Solutions store&lt;/a&gt;, the kit included, so it's a good moment to look around.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/ufvbep" rel="noopener noreferrer"&gt;Get the AI SaaS Starter Kit for .NET 10&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Full C# source, 101 passing tests, and a commercial license. Rename it and ship something.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Want a new tested .NET + AI project every day, with full source? That's what &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; is for.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>dotnet</category>
      <category>saas</category>
      <category>ai</category>
    </item>
    <item>
      <title>Don't Hand Your AI Agent the Keys: Building a Secure Remote MCP Server in ASP.NET Core</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Fri, 09 Oct 2026 18:11:50 +0000</pubDate>
      <link>https://dev.to/michael_maurice/dont-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-aspnet-core-641</link>
      <guid>https://dev.to/michael_maurice/dont-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-aspnet-core-641</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@michaelmaurice410/dont-hand-your-ai-agent-the-keys-building-a-secure-remote-mcp-server-in-asp-net-core-e3eb311b141e" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;. Full source code for this project is in Tech Skill Builder: &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;https://elitesolutions1.gumroad.com/l/TechSkillBuilder&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most Model Context Protocol demos look the same. You get one &lt;code&gt;Echo&lt;/code&gt; 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.?&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;callers authenticate, and each one only &lt;strong&gt;sees&lt;/strong&gt; the tools their role allows,&lt;/li&gt;
&lt;li&gt;tools return &lt;strong&gt;typed, schema-described results&lt;/strong&gt; instead of strings,&lt;/li&gt;
&lt;li&gt;every call is &lt;strong&gt;audited&lt;/strong&gt;, including the ones that were denied,&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;human approves&lt;/strong&gt; anything destructive before it runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything here comes from a complete project with 52 passing tests. It runs without an AI key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: MCP tools are an API, so treat them like one
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The tool list is an attack surface.&lt;/strong&gt; If a read-only bot can see &lt;code&gt;close_ticket&lt;/code&gt;, a prompt injection is one sentence away from using it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity can't come from arguments.&lt;/strong&gt; If &lt;code&gt;add_comment(author, text)&lt;/code&gt; takes the author as a parameter, the model can claim to be anyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors have two audiences.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some actions need a person.&lt;/strong&gt; The MCP specification says there SHOULD always be a human in the loop with the ability to deny tool invocations.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The timing matters too. Version 2.0 of the C# SDK (July 2026) aligned with the &lt;code&gt;2026-07-28&lt;/code&gt; MCP specification. That revision removes the &lt;code&gt;initialize&lt;/code&gt; handshake and the &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header from the wire format. Clients bootstrap with &lt;code&gt;server/discover&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is defense in depth. ASP.NET Core decides &lt;em&gt;whether&lt;/em&gt; you may talk to the server at all. The MCP layer decides &lt;em&gt;which tools&lt;/em&gt; you get. The agent decides &lt;em&gt;whether a human must confirm&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: A stateless MCP endpoint behind normal ASP.NET Core security
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new web &lt;span class="nt"&gt;-n&lt;/span&gt; SupportDesk.McpServer
dotnet add package ModelContextProtocol.AspNetCore &lt;span class="nt"&gt;--version&lt;/span&gt; 2.2.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServerInfo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Implementation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"support-desk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.0"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServerInstructions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Look tickets up before changing them..."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHttpTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HttpServerSessionMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stateless&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthorizationFilters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTools&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TicketReadTools&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTools&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TicketWriteTools&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTools&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TicketAdminTools&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthentication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseRateLimiter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapMcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/mcp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthorization&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireRateLimiting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"per-caller"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stateless is already the default in 2.x. Set it anyway. The SDK docs recommend setting &lt;code&gt;SessionMode&lt;/code&gt; explicitly so a future default change can't silently alter your server. If you still have older clients that need sessions, 2.2 added &lt;code&gt;StatefulForInitializeClients&lt;/code&gt;. That mode gives &lt;code&gt;initialize&lt;/code&gt;-handshake clients a session and serves &lt;code&gt;2026-07-28&lt;/code&gt; clients statelessly on the same endpoint.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MapMcp&lt;/code&gt; returns a normal endpoint convention builder, so &lt;code&gt;RequireAuthorization()&lt;/code&gt; and &lt;code&gt;RequireRateLimiting()&lt;/code&gt; work as they do on any minimal API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Authenticate callers without storing secrets
&lt;/h2&gt;

&lt;p&gt;For service-to-service agents, an API key per caller is a pragmatic start. Two details make it safe. Store only a &lt;strong&gt;SHA-256 hash&lt;/strong&gt; of each key in configuration, and compare in &lt;strong&gt;constant time&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;presented&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SHA256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HashData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headerValue&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;ApiKeyClient&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Clients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;TryDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeySha256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;CryptographicOperations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FixedTimeEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="p"&gt;??=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthenticateResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invalid API key."&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ClaimsIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ApiKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler produces an ordinary &lt;code&gt;ClaimsPrincipal&lt;/code&gt;. The SDK copies it from &lt;code&gt;HttpContext.User&lt;/code&gt; into every MCP request, so filters and tools can use it.&lt;/p&gt;

&lt;p&gt;When the client acts &lt;em&gt;for a person&lt;/em&gt;, 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 &lt;code&gt;AddJwtBearer()&lt;/code&gt; plus its &lt;code&gt;AddMcp()&lt;/code&gt; authentication scheme. The rest of this design doesn't change, because both paths end in a &lt;code&gt;ClaimsPrincipal&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Role-based tools that are invisible to the wrong caller
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;AddAuthorizationFilters()&lt;/code&gt;, standard &lt;code&gt;[Authorize]&lt;/code&gt; attributes work on tool classes and methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;McpServerToolType&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Roles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Agent,Admin"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketWriteTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TicketStore&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpServerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"escalate_ticket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReadOnly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Destructive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;Idempotent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UseStructuredContent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Escalates a ticket to the platform team and raises its priority to at least High."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;TicketDetails&lt;/span&gt; &lt;span class="nf"&gt;Escalate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ClaimsPrincipal&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ticket id in the form TCK-1234."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Why the ticket needs escalation."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ToolGuard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TicketId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ToolGuard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ToolGuard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TicketDetails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Escalate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK enforces this in two places. On &lt;code&gt;tools/list&lt;/code&gt;, unauthorized tools are &lt;strong&gt;removed&lt;/strong&gt; from the response. On &lt;code&gt;tools/call&lt;/code&gt;, 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 &lt;code&gt;escalate_ticket&lt;/code&gt; by name gets an exception, not a result.&lt;/p&gt;

&lt;p&gt;Hiding tools matters more than it sounds. A model can't be talked into calling a tool it never heard of.&lt;/p&gt;

&lt;p&gt;Look at the &lt;code&gt;ClaimsPrincipal user&lt;/code&gt; parameter. The SDK resolves it from the current request and &lt;strong&gt;leaves it out of the tool's input schema&lt;/strong&gt;. The model can't see it or set it. Comments and escalations are always attributed to the authenticated caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Typed results and two kinds of errors
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;UseStructuredContent = true&lt;/code&gt; makes the SDK generate an output JSON Schema from the return type and serialize the value into &lt;code&gt;structuredContent&lt;/code&gt;. Clients get a contract instead of prose they have to parse. In 2.x, non-object return values are emitted as-is (&lt;code&gt;structuredContent: 72&lt;/code&gt;), with no &lt;code&gt;{ "result": ... }&lt;/code&gt; wrapper. That's one reason the project returns small records rather than bare arrays.&lt;/p&gt;

&lt;p&gt;Errors need more care. The SDK separates them like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;McpProtocolException&lt;/code&gt; becomes a &lt;strong&gt;JSON-RPC error&lt;/strong&gt;. Use it when the request itself is malformed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;McpException&lt;/code&gt; becomes a &lt;strong&gt;tool result with &lt;code&gt;IsError = true&lt;/code&gt;&lt;/strong&gt; that includes your message. The model can read it and recover.&lt;/li&gt;
&lt;li&gt;Any other exception also becomes &lt;code&gt;IsError = true&lt;/code&gt;, but with a &lt;strong&gt;generic message&lt;/strong&gt;, so internal details don't leak.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;TicketId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;Trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;TicketStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsValidId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;McpProtocolException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;$"'&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' is not a valid ticket id. Expected the form TCK-1234."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;McpErrorCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidParams&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="nf"&gt;ToUpperInvariant&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TicketNotFoundException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;McpException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TicketRuleException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;McpException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an agent asks about &lt;code&gt;TCK-9999&lt;/code&gt;, the model gets a tool error that includes "Ticket TCK-9999 was not found." and can tell the user. When it sends &lt;code&gt;1001 OR 1=1&lt;/code&gt;, the call fails as &lt;code&gt;InvalidParams&lt;/code&gt; before any business code runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Audit everything, including denials
&lt;/h2&gt;

&lt;p&gt;A call-tool filter wraps every tool invocation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithRequestFilters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCallToolFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;audit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AuditLog&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"anonymous"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;argumentNames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToArray&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Stopwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetTimestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsError&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"tool_error"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Stopwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetElapsedTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;TotalMilliseconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;argumentNames&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;OperationCanceledException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// record protocol_error / tool_error, then rethrow&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice it records argument &lt;strong&gt;names&lt;/strong&gt;, never values. Tool arguments are model-generated text that often contains customer data. Your audit trail shouldn't become a second copy of it.&lt;/p&gt;

&lt;p&gt;Here's a detail I only found while testing. &lt;strong&gt;Denied calls never reach this filter.&lt;/strong&gt; 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 &lt;code&gt;IAuthorizationService&lt;/code&gt;, with the MCP &lt;code&gt;RequestContext&amp;lt;CallToolRequestParams&amp;gt;&lt;/code&gt; as the resource. So the project decorates the default authorization service and records failures for that resource type. Failures for &lt;code&gt;tools/list&lt;/code&gt; filtering are skipped, since a hidden tool isn't an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Put a human in front of destructive tools
&lt;/h2&gt;

&lt;p&gt;On the client side, MCP tools plug straight into &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt;: &lt;code&gt;McpClientTool&lt;/code&gt; is an &lt;code&gt;AIFunction&lt;/code&gt;. That lets us use MEAI's approval support. Wrap a tool in &lt;code&gt;ApprovalRequiredAIFunction&lt;/code&gt;, and &lt;code&gt;FunctionInvokingChatClient&lt;/code&gt; returns a &lt;code&gt;ToolApprovalRequestContent&lt;/code&gt; instead of running it.&lt;/p&gt;

&lt;p&gt;Which tools need approval? Tool annotations tell us. Under the MCP schema, &lt;code&gt;readOnlyHint&lt;/code&gt; defaults to false and &lt;code&gt;destructiveHint&lt;/code&gt; defaults to &lt;strong&gt;true&lt;/strong&gt;. So a tool that says nothing about itself should be treated as destructive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;RequiresApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Annotations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;ReadOnlyHint&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;DestructiveHint&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ListToolsAsync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RequiresApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProtocolTool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApprovalRequiredAIFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AITool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent loop answers approval requests and calls the model again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Tools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SelectMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ToolApprovalRequestContent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;FunctionCallContent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolCall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Rejected by operator."&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChatRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For a real model, the project uses OpenAI through the Responses API (&lt;code&gt;GetResponsesClient().AsIChatClient("gpt-6-luna")&lt;/code&gt;). OpenAI's current guidance is to use Responses for tool calling with the GPT-6 family. For tests and demos, a deterministic offline &lt;code&gt;IChatClient&lt;/code&gt; drives the same loop with no key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-world use cases
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Internal support and ops copilots&lt;/strong&gt; that read tickets broadly but escalate or close only with the right role and a human click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform APIs exposed to agents.&lt;/strong&gt; Wrap the deployment or feature-flag API you already have, and make rollbacks destructive tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant SaaS integrations.&lt;/strong&gt; Map each tenant's key or token to claims, and filter data by the injected &lt;code&gt;ClaimsPrincipal&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulated environments&lt;/strong&gt; where an audit trail of who did what through which AI tool is a hard requirement.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;code&gt;SessionMode&lt;/code&gt; explicitly. Choose &lt;code&gt;Stateless&lt;/code&gt; unless you truly need server-push or session state.&lt;/li&gt;
&lt;li&gt;Put authorization on the &lt;strong&gt;tool&lt;/strong&gt;, not in the prompt. Instructions are suggestions. &lt;code&gt;[Authorize]&lt;/code&gt; is enforced.&lt;/li&gt;
&lt;li&gt;Get identity from &lt;code&gt;ClaimsPrincipal&lt;/code&gt; injection. Never accept "user" as an argument.&lt;/li&gt;
&lt;li&gt;Return records with &lt;code&gt;UseStructuredContent = true&lt;/code&gt;, and keep the contract stable (strings instead of enums on the wire).&lt;/li&gt;
&lt;li&gt;Annotate every tool honestly with &lt;code&gt;ReadOnly&lt;/code&gt;, &lt;code&gt;Destructive&lt;/code&gt; and &lt;code&gt;Idempotent&lt;/code&gt;. Clients use those hints to decide on confirmation.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;McpException&lt;/code&gt; for errors the model should read, and let everything else stay generic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leaving &lt;code&gt;AllowedHosts&lt;/code&gt; as &lt;code&gt;*&lt;/code&gt;.&lt;/strong&gt; 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 hostile &lt;code&gt;Host&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relying on the system prompt for permissions.&lt;/strong&gt; One prompt injection undoes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging tool arguments.&lt;/strong&gt; This is the fastest way to leak PII into your log store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming an ordinary filter sees every call.&lt;/strong&gt; Authorization failures happen earlier. Audit them separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateful by habit.&lt;/strong&gt; Sessions pin clients to instances and complicate scaling. The 2026-07-28 spec moved away from them for a reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security and performance notes
&lt;/h2&gt;

&lt;p&gt;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, &lt;code&gt;FunctionInvokingChatClient&lt;/code&gt; caps tool iterations per request, and &lt;code&gt;IncludeDetailedErrors = false&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;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, &lt;code&gt;[Authorize]&lt;/code&gt;, 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.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Want to run it yourself?&lt;/strong&gt; 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: &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;https://elitesolutions1.gumroad.com/l/TechSkillBuilder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How are you locking down MCP tools in your own setup? I'd like to hear what's working for you in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>dotnet</category>
      <category>mcp</category>
      <category>security</category>
    </item>
    <item>
      <title>Resize Images in asp.Net</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Fri, 11 Mar 2022 21:57:52 +0000</pubDate>
      <link>https://dev.to/michael_maurice/resize-images-in-aspnet-304</link>
      <guid>https://dev.to/michael_maurice/resize-images-in-aspnet-304</guid>
      <description>&lt;p&gt;I was doing a task today, and part of it was that I was uploading images..but there was a problem that would happen if the user uploaded a large image. I want to control the size of the image that I came from the user because my database is arrogant. this ..&lt;br&gt;
The first thing I need to do is check the size of the image before I do it and save it. This is how I compare the length of the image (array of bytes) with the length I need, even if it is larger than it. Do not allow the user to upload this image.&lt;br&gt;
The second thing is that I resize the image that I came to before I save it, and I found two packages that do this&lt;br&gt;
1- SixLabors.ImageSharp, and you can read more about it here: &lt;a href="https://www.nuget.org/packages/SixLabors.ImageSharp/2.0.0?_src=template" rel="noopener noreferrer"&gt;https://www.nuget.org/packages/SixLabors.ImageSharp/2.0.0?_src=template&lt;/a&gt;&lt;br&gt;
2- System.Drawing.Common, and you can read more about it here:-&lt;br&gt;
&lt;a href="https://www.nuget.org/packages/System.Drawing.Common/6.0.0?_src=template" rel="noopener noreferrer"&gt;https://www.nuget.org/packages/System.Drawing.Common/6.0.0?_src=template&lt;/a&gt;&lt;br&gt;
And they are both, through which you can specify the width and height of your image, and this is how you can let the user upload any image, regardless of its size....&lt;/p&gt;

</description>
      <category>database</category>
      <category>aspdotnet</category>
      <category>programming</category>
      <category>csharp</category>
    </item>
    <item>
      <title>movie Tickets Reservation system</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Thu, 10 Mar 2022 22:55:25 +0000</pubDate>
      <link>https://dev.to/michael_maurice/movie-tickets-reservation-system-24db</link>
      <guid>https://dev.to/michael_maurice/movie-tickets-reservation-system-24db</guid>
      <description>&lt;p&gt;database diagram:- &lt;a href="https://lnkd.in/gM2PiTRt" rel="noopener noreferrer"&gt;https://lnkd.in/gM2PiTRt&lt;/a&gt;&lt;br&gt;
GitHub repo:- &lt;a href="https://lnkd.in/gCZEUg46" rel="noopener noreferrer"&gt;https://lnkd.in/gCZEUg46&lt;/a&gt;&lt;br&gt;
Full Video:- &lt;a href="https://lnkd.in/gbbB-8pD" rel="noopener noreferrer"&gt;https://lnkd.in/gbbB-8pD&lt;/a&gt;&lt;br&gt;
in this project, you can buy tickets for any movie &lt;br&gt;
we use asp.net core MVC , SQL server&lt;br&gt;
and also we used injection patterns ,solid principles&lt;br&gt;
this project has a lot of functionalities For Example:&lt;br&gt;
 user &lt;br&gt;
1-authentication&lt;br&gt;
2-searching for any (movie, category, actor)&lt;br&gt;
3-showing actors and producers for this movie&lt;br&gt;
4-watch trailer &lt;br&gt;
5- buy ticket buy using stripe payment method&lt;br&gt;
6-add any movie to your cart&lt;br&gt;
and more...&lt;br&gt;
 admin &lt;br&gt;
you can (create ,read ,update, delete ,search) for &lt;br&gt;
1-category&lt;br&gt;
2-movie&lt;br&gt;
3-cinema&lt;br&gt;
4-Actor&lt;br&gt;
and more ... &lt;/p&gt;

</description>
    </item>
    <item>
      <title>.NET Core vs .NET Framework</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Thu, 03 Mar 2022 11:46:47 +0000</pubDate>
      <link>https://dev.to/michael_maurice/net-core-vs-net-framework-7cm</link>
      <guid>https://dev.to/michael_maurice/net-core-vs-net-framework-7cm</guid>
      <description>&lt;h2&gt;
  
  
  &lt;strong&gt;What is .NET Core?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;.NET Core is the latest version of Microsoft’s .NET Framewok, which is a free, open-source, general-purpose programming platform. It’s a cross-platform framework .The.NET Core Framework may be used to create a variety of apps, including mobile, desktop, online, cloud, IoT, machine learning, microservices, games, and more. .NET Core is written from scratch to make it a modular, lightweight, fast, and cross-platform Framework. &lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What is .NET Framework?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Microsoft’s Net Framework is a software development platform for building and running Windows applications.Net framework includes developer tools, programming languages, and libraries that are used to develop desktop and web applications. It is also used to build websites, web services, and games.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key difference&lt;/strong&gt; between .NET Core and .NET Framework is a platform for .NET applications on Windows whereas, NET Core is the latest version of the .NET Framework which is a cross-platform and open-source framework optimized for modern app needs and developer workflows.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Cumulative Sum</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Thu, 10 Feb 2022 19:25:43 +0000</pubDate>
      <link>https://dev.to/michael_maurice/cumulative-sum-5ccj</link>
      <guid>https://dev.to/michael_maurice/cumulative-sum-5ccj</guid>
      <description>&lt;p&gt;in this article, I will talk about Cumulative sum but first, let me talk about one of the most common problem in computer science if we have an array and we wanna calculate the summation &lt;br&gt;
of a specific range in this array, you will think of nested loops that sound good. &lt;br&gt;
but this solution is (o(n)^2) can we get a better solution ??&lt;br&gt;
Yes, we can do that by using (cumulative sum). ok, what is that exactly?&lt;/p&gt;

&lt;p&gt;The cumulative sum is a technic we use to get the summation of a specific range in an array in(o(1)).&lt;br&gt;
by generating a new array each element in that array contain the summation of elements before that &lt;/p&gt;

&lt;p&gt;element.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;strong&gt;ok, How we can write this code?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftbanuobjte2jf8failfi.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftbanuobjte2jf8failfi.jpg" alt=" " width="320" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ZERO BASED&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;int range(int s,int e,vector&amp;lt;int&amp;gt;&amp;amp;v){
if(s==0)
    return v[e];
    return v[e]-v[s-1];
}
int main()
{

    vector&amp;lt;int&amp;gt;v={1,2,3,4,5,6};
    vector&amp;lt;int&amp;gt;s(v.size(),0);
    for(int i=0;i&amp;lt;v.size();i++){
        s[i]+=(i==0)?v[i]:v[i]+s[i-1];
    }
    cout&amp;lt;&amp;lt;range(2,4,s);
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;ONE BASED&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;int range(int s,int e,vector&amp;lt;int&amp;gt;&amp;amp;v){
    return v[e]-v[s-1];
}
int main()
{//one based
    vector&amp;lt;int&amp;gt;v={0,1,2,3,4,5,6};
    vector&amp;lt;int&amp;gt;s(v.size(),0);
    for(int i=1;i&amp;lt;v.size();i++){
        s[i]+=v[i]+s[i-1];
    }
    cout&amp;lt;&amp;lt;range(2,4,s);
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>algorithms</category>
      <category>database</category>
      <category>cpp</category>
    </item>
  </channel>
</rss>
