<?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: JF Meyers</title>
    <description>The latest articles on DEV Community by JF Meyers (@jfmeyers).</description>
    <link>https://dev.to/jfmeyers</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3844793%2F7cc46ea9-adca-40a3-8383-6ab03f708542.jpg</url>
      <title>DEV Community: JF Meyers</title>
      <link>https://dev.to/jfmeyers</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jfmeyers"/>
    <language>en</language>
    <item>
      <title>I Benchmarked 3 Ways to Log in .NET : One Allocates Nothing</title>
      <dc:creator>JF Meyers</dc:creator>
      <pubDate>Fri, 03 Apr 2026 10:12:20 +0000</pubDate>
      <link>https://dev.to/jfmeyers/i-benchmarked-3-ways-to-log-in-net-one-allocates-nothing-25ok</link>
      <guid>https://dev.to/jfmeyers/i-benchmarked-3-ways-to-log-in-net-one-allocates-nothing-25ok</guid>
      <description>&lt;p&gt;Your API handles 2,000 requests per second. Each request logs 5 messages. That is 10,000 log calls per second — and if your log level is set to &lt;code&gt;Warning&lt;/code&gt;, every single &lt;code&gt;Debug&lt;/code&gt; and &lt;code&gt;Information&lt;/code&gt; call is wasted work. The string gets built. The arguments get boxed. The GC collects the result. Nobody reads it.&lt;/p&gt;

&lt;p&gt;I ran the benchmarks. The difference is not subtle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to log the same thing
&lt;/h2&gt;

&lt;p&gt;Here is the same log statement written three ways. All three produce identical output when the level is enabled.&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="c1"&gt;// Style 1: string interpolation&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Order &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; shipped to &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;country&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="c1"&gt;// Style 2: message template (structured)&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order {OrderId} shipped to {Country}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Style 3: [LoggerMessage] source generation&lt;/span&gt;
&lt;span class="nf"&gt;LogOrderShipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;LoggerMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&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="s"&gt;"Order {OrderId} shipped to {Country}"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;LogOrderShipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;orderId&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;country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Style 1 is what most developers write by default. Style 2 is what the docs recommend. Style 3 is what the runtime team actually uses internally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;I used BenchmarkDotNet to measure all three styles under two conditions: when the log level is &lt;strong&gt;enabled&lt;/strong&gt; (the message reaches the sink) and when it is &lt;strong&gt;disabled&lt;/strong&gt; (the message is filtered out before writing).&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;MemoryDiagnoser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ShortRunJob&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;class&lt;/span&gt; &lt;span class="nc"&gt;LoggingBenchmarks&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LoggingBenchmarks&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_enabledLogger&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;private&lt;/span&gt; &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LoggingBenchmarks&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_disabledLogger&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;private&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;_orderId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;_country&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;GlobalSetup&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;void&lt;/span&gt; &lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_orderId&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="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;_country&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Belgium"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="n"&gt;_enabledLogger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LoggerFactory&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetMinimumLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trace&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;AddFakeLogger&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateLogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LoggingBenchmarks&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="n"&gt;_disabledLogger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LoggerFactory&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetMinimumLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;AddFakeLogger&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateLogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LoggingBenchmarks&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Benchmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Baseline&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;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Interpolation_Enabled&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;_enabledLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Order &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; shipped to &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_country&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Benchmark&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;void&lt;/span&gt; &lt;span class="nf"&gt;Template_Enabled&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;_enabledLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order {OrderId} shipped to {Country}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Benchmark&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;void&lt;/span&gt; &lt;span class="nf"&gt;SourceGen_Enabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;LogOrderShipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_enabledLogger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Benchmark&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;void&lt;/span&gt; &lt;span class="nf"&gt;Interpolation_Disabled&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;_disabledLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Order &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; shipped to &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_country&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Benchmark&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;void&lt;/span&gt; &lt;span class="nf"&gt;Template_Disabled&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;_disabledLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order {OrderId} shipped to {Country}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Benchmark&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;void&lt;/span&gt; &lt;span class="nf"&gt;SourceGen_Disabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;LogOrderShipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_disabledLogger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;LoggerMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&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="s"&gt;"Order {OrderId} shipped to {Country}"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;LogOrderShipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ILogger&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;orderId&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;country&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;h3&gt;
  
  
  Results (.NET 10, x64, RyuJIT)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interpolation_Enabled&lt;/td&gt;
&lt;td&gt;~320 ns&lt;/td&gt;
&lt;td&gt;256 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Template_Enabled&lt;/td&gt;
&lt;td&gt;~280 ns&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SourceGen_Enabled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~250 ns&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interpolation_Disabled&lt;/td&gt;
&lt;td&gt;~95 ns&lt;/td&gt;
&lt;td&gt;184 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Template_Disabled&lt;/td&gt;
&lt;td&gt;~35 ns&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SourceGen_Disabled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1.5 ns&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that last row again. &lt;strong&gt;1.5 nanoseconds. Zero bytes allocated.&lt;/strong&gt; When the level is disabled, the source-generated method checks &lt;code&gt;IsEnabled&lt;/code&gt; and returns immediately — no string formatting, no boxing, no array allocation. The interpolation version still burns 95 ns building a string that nobody will ever see.&lt;/p&gt;

&lt;p&gt;At 10,000 calls/second with the level disabled, that is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Interpolation: &lt;strong&gt;1.8 MB/s&lt;/strong&gt; of garbage for the GC to collect&lt;/li&gt;
&lt;li&gt;Message template: &lt;strong&gt;640 KB/s&lt;/strong&gt; of garbage&lt;/li&gt;
&lt;li&gt;Source generation: &lt;strong&gt;nothing&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the difference exists
&lt;/h2&gt;

&lt;h3&gt;
  
  
  String interpolation (&lt;code&gt;$"..."&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The compiler transforms &lt;code&gt;$"Order {orderId} shipped to {country}"&lt;/code&gt; into a &lt;code&gt;string.Format&lt;/code&gt; call (or &lt;code&gt;DefaultInterpolatedStringHandler&lt;/code&gt; on .NET 6+). Either way, the string is &lt;strong&gt;fully constructed before &lt;code&gt;LogInformation&lt;/code&gt; is called&lt;/strong&gt;. The logger has no chance to skip it.&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="c1"&gt;// What the compiler actually generates (simplified)&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;handler&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;DefaultInterpolatedStringHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLiteral&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order "&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendFormatted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLiteral&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;" shipped to "&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendFormatted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToStringAndClear&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// Already a string. Too late.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Message template (&lt;code&gt;"Order {OrderId}"&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;ILogger&lt;/code&gt; extension methods accept &lt;code&gt;params object?[]&lt;/code&gt; arguments. Value types like &lt;code&gt;Guid&lt;/code&gt; get boxed. The &lt;code&gt;object[]&lt;/code&gt; is allocated on every call. The level check happens inside the method — after the caller has already paid for the array.&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="c1"&gt;// What you write&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order {OrderId} shipped to {Country}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// What happens at the call site&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// boxed Guid + array alloc&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Order {OrderId} shipped to {Country}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// IsEnabled check is INSIDE Log() — too late to avoid the allocation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Source generation (&lt;code&gt;[LoggerMessage]&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The source generator emits a method that checks &lt;code&gt;IsEnabled&lt;/code&gt; &lt;strong&gt;before touching any argument&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="c1"&gt;// What the source generator emits (simplified)&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;LogOrderShipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;orderId&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;country&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;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Zero work done&lt;/span&gt;

    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&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;EventId&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;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogOrderShipped&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;__LogOrderShippedStruct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// stack-allocated struct&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;__LogOrderShippedStruct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Format&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 arguments are passed to a generated &lt;code&gt;struct&lt;/code&gt; — no heap allocation, no boxing. The format method is cached as a static delegate. When the level is disabled, the method returns in a single branch instruction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things I got wrong the first time
&lt;/h2&gt;

&lt;p&gt;When I migrated a codebase to &lt;code&gt;[LoggerMessage]&lt;/code&gt;, I hit every possible mistake. Saving you the trip:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Forgetting &lt;code&gt;partial&lt;/code&gt; on the class&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The method is &lt;code&gt;partial&lt;/code&gt;, but &lt;strong&gt;so must be the containing class&lt;/strong&gt;. The source generator needs to emit the implementation in a separate file.&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="c1"&gt;// Compiler error: no suitable method found to override&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&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;// Works&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&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;&lt;strong&gt;2. Using interpolation inside the message string&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Message&lt;/code&gt; property is a template, not a format string. Placeholders use &lt;code&gt;{PascalCase}&lt;/code&gt; names that match the method parameters.&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="c1"&gt;// Wrong — this is a constant string, not a template&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;LoggerMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&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="s"&gt;$"Order &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;orderId&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s"&gt; processed"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="c1"&gt;// Compile error&lt;/span&gt;

&lt;span class="c1"&gt;// Right — named placeholders matching parameters&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;LoggerMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&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="s"&gt;"Order {OrderId} processed"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;LogOrderProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Returning a value from a log method&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[LoggerMessage]&lt;/code&gt; methods must return &lt;code&gt;void&lt;/code&gt;. They are fire-and-forget by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Mismatching parameter names and placeholders&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The parameter name &lt;code&gt;orderId&lt;/code&gt; maps to the placeholder &lt;code&gt;{OrderId}&lt;/code&gt; by convention (case-insensitive). If they do not match, the generator emits a warning and the value shows as &lt;code&gt;(null)&lt;/code&gt; in output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Forgetting the &lt;code&gt;Exception&lt;/code&gt; parameter&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your message accompanies an error, pass the &lt;code&gt;Exception&lt;/code&gt; as a parameter — do not &lt;code&gt;.ToString()&lt;/code&gt; it into the message. The logging infrastructure preserves the full exception object, and sinks like Seq or Application Insights render it properly.&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="c1"&gt;// Wrong — stack trace baked into a string, impossible to filter&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;LoggerMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&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="s"&gt;"Import failed: {Error}"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;LogImportFailed&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;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Called as: LogImportFailed(ex.ToString()); &lt;/span&gt;

&lt;span class="c1"&gt;// Right — exception is structured metadata&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;LoggerMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&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="s"&gt;"Import failed for job {JobId}"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;LogImportFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;jobId&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;exception&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When to use each style
&lt;/h2&gt;

&lt;p&gt;I am not saying you should grep-and-replace every &lt;code&gt;logger.Log*&lt;/code&gt; call in your codebase tomorrow. Here is a practical decision framework:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hot paths (&amp;gt;100 calls/s)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[LoggerMessage]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zero allocation matters here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Library code (NuGet packages)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[LoggerMessage]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You do not control the consumer's log level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application startup / shutdown&lt;/td&gt;
&lt;td&gt;Message template is fine&lt;/td&gt;
&lt;td&gt;Runs once, readability wins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick prototype / script&lt;/td&gt;
&lt;td&gt;Interpolation is fine&lt;/td&gt;
&lt;td&gt;You are not shipping this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anywhere you log an &lt;code&gt;Exception&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[LoggerMessage]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Structured exception &amp;gt; &lt;code&gt;.ToString()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real decision point: &lt;strong&gt;if the code runs in a loop or on every request, use &lt;code&gt;[LoggerMessage]&lt;/code&gt;&lt;/strong&gt;. For everything else, message templates are a reasonable default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration in 10 minutes
&lt;/h2&gt;

&lt;p&gt;Here is a quick mechanical process for an existing class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- public class PaymentService
&lt;/span&gt;&lt;span class="gi"&gt;+ public partial class PaymentService
&lt;/span&gt;  {
      private readonly ILogger&amp;lt;PaymentService&amp;gt; _logger;
&lt;span class="err"&gt;
&lt;/span&gt;      public async Task ChargeAsync(Guid paymentId, decimal amount)
      {
&lt;span class="gd"&gt;-         _logger.LogInformation($"Charging {amount:C} for payment {paymentId}");
&lt;/span&gt;&lt;span class="gi"&gt;+         LogCharging(paymentId, amount);
&lt;/span&gt;          // ...
&lt;span class="gd"&gt;-         _logger.LogError(ex, $"Payment {paymentId} failed");
&lt;/span&gt;&lt;span class="gi"&gt;+         LogChargeFailed(paymentId, ex);
&lt;/span&gt;      }
&lt;span class="gi"&gt;+
+     [LoggerMessage(Level = LogLevel.Information,
+         Message = "Charging {Amount} for payment {PaymentId}")]
+     private partial void LogCharging(Guid paymentId, decimal amount);
+
+     [LoggerMessage(Level = LogLevel.Error,
+         Message = "Payment {PaymentId} failed")]
+     private partial void LogChargeFailed(Guid paymentId, Exception exception);
&lt;/span&gt;  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add &lt;code&gt;partial&lt;/code&gt; to the class declaration&lt;/li&gt;
&lt;li&gt;For each &lt;code&gt;logger.Log*()&lt;/code&gt; call, create a &lt;code&gt;[LoggerMessage]&lt;/code&gt; partial method&lt;/li&gt;
&lt;li&gt;Replace the call site with the new method&lt;/li&gt;
&lt;li&gt;Build — the generator validates signatures at compile time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you have a large codebase, the &lt;a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator" rel="noopener noreferrer"&gt;Microsoft.Extensions.Logging analyzer&lt;/a&gt; (&lt;code&gt;SYSLIB1006&lt;/code&gt;-&lt;code&gt;SYSLIB1015&lt;/code&gt;) flags common issues. Enable it in your &lt;code&gt;.editorconfig&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[*.cs]&lt;/span&gt;
&lt;span class="py"&gt;dotnet_diagnostic.SYSLIB1006.severity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The compliance bonus nobody talks about
&lt;/h2&gt;

&lt;p&gt;Here is a side benefit that surprised me: &lt;code&gt;[LoggerMessage]&lt;/code&gt; makes &lt;strong&gt;GDPR compliance audits easier&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;With string interpolation, PII can hide anywhere: &lt;code&gt;$"User {email} logged in"&lt;/code&gt; bakes the email into an opaque string. You cannot redact it downstream. Your only option is a regex-based log scrubber — fragile and always one edge case away from leaking data.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;[LoggerMessage]&lt;/code&gt;, every parameter is &lt;strong&gt;explicitly named&lt;/strong&gt; in the attribute. You can write a Roslyn analyzer that scans all &lt;code&gt;[LoggerMessage]&lt;/code&gt; declarations and flags any placeholder named &lt;code&gt;Email&lt;/code&gt;, &lt;code&gt;Phone&lt;/code&gt;, &lt;code&gt;IpAddress&lt;/code&gt;, or &lt;code&gt;Token&lt;/code&gt; at build time. PII never reaches production logs because the compiler stops you.&lt;/p&gt;

&lt;p&gt;I built exactly this for &lt;a href="https://github.com/granit-fx/granit-dotnet" rel="noopener noreferrer"&gt;Granit&lt;/a&gt;, an open-source modular .NET framework I maintain. The analyzer (&lt;code&gt;GRSEC011&lt;/code&gt;) flags PII-indicative parameter names, and architecture tests verify every &lt;code&gt;[LoggerMessage]&lt;/code&gt; across 120+ packages. Zero PII in logs, enforced at compile time — not by policy documents that nobody reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;String interpolation in logs &lt;strong&gt;allocates on every call&lt;/strong&gt;, even when the level is disabled. At scale, that is measurable GC pressure.&lt;/li&gt;
&lt;li&gt;Message templates avoid the string but still &lt;strong&gt;box value types&lt;/strong&gt; into &lt;code&gt;object[]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[LoggerMessage]&lt;/code&gt; source generation &lt;strong&gt;checks the level first&lt;/strong&gt;, &lt;strong&gt;avoids all allocation&lt;/strong&gt;, and &lt;strong&gt;preserves structured data&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The difference is &lt;strong&gt;1.5 ns vs 95 ns&lt;/strong&gt; when the level is disabled. At scale, that is free GC pressure you are handing back to your application.&lt;/li&gt;
&lt;li&gt;Migration is mechanical: add &lt;code&gt;partial&lt;/code&gt;, extract methods, build. The compiler does the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bonus&lt;/strong&gt;: named parameters make PII audits trivial — a Roslyn analyzer can enforce data minimization at compile time.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I maintain &lt;a href="https://github.com/granit-fx/granit-dotnet" rel="noopener noreferrer"&gt;Granit&lt;/a&gt;, an open-source modular .NET framework (200+ NuGet packages, Apache-2.0) with built-in GDPR compliance, isolated DbContexts, and source-generated everything. If you are building enterprise .NET apps and tired of reinventing the same plumbing, &lt;a href="https://github.com/granit-fx/granit-dotnet" rel="noopener noreferrer"&gt;check it out&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>performance</category>
      <category>logging</category>
    </item>
    <item>
      <title>RoslynLens: Give Your AI Assistant Semantic Eyes on .NET Code</title>
      <dc:creator>JF Meyers</dc:creator>
      <pubDate>Wed, 01 Apr 2026 07:53:06 +0000</pubDate>
      <link>https://dev.to/jfmeyers/roslynlens-give-your-ai-assistant-semantic-eyes-on-net-code-1k07</link>
      <guid>https://dev.to/jfmeyers/roslynlens-give-your-ai-assistant-semantic-eyes-on-net-code-1k07</guid>
      <description>&lt;p&gt;&lt;strong&gt;Reading a 1,500-line C# file to find one method signature wastes tokens, time, and money.&lt;/strong&gt; RoslynLens is an open-source MCP server that gives AI coding assistants like Claude Code direct access to Roslyn semantic analysis — so they can query your .NET codebase the way an IDE does, not the way &lt;code&gt;grep&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;One MCP call. 30-150 tokens. No file reading required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: AI assistants are blind navigators
&lt;/h2&gt;

&lt;p&gt;When Claude Code (or any AI assistant) works with a .NET codebase, it has two options to understand your code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read entire files&lt;/strong&gt; — 500 to 2,000+ tokens per &lt;code&gt;.cs&lt;/code&gt; file, most of which is irrelevant noise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep and hope&lt;/strong&gt; — text-based search that can't distinguish a type name from a variable, a declaration from a usage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither approach understands your code. They process text. They miss inheritance hierarchies, cross-project references, interface implementations, and semantic relationships.&lt;/p&gt;

&lt;p&gt;The result? Wasted context window, missed connections, and wrong assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution: semantic queries via MCP
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RoslynLens&lt;/strong&gt; loads your .NET solution into a Roslyn &lt;code&gt;MSBuildWorkspace&lt;/code&gt; and exposes 28 tools through the &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;. Instead of reading files, the AI assistant sends focused queries and gets back structured, minimal responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before RoslynLens&lt;/strong&gt; — finding who calls &lt;code&gt;OrderService.SubmitAsync&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Grep for "SubmitAsync" across 200 files -&amp;gt; 47 matches (including comments, strings, other methods)
2. Read 12 files to narrow down -&amp;gt; ~15,000 tokens consumed
3. Still not sure about indirect calls through interfaces
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After RoslynLens&lt;/strong&gt; — same task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;find_callers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OrderService.SubmitAsync&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="n"&gt;callers&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;containing&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quick start
&lt;/h2&gt;

&lt;p&gt;Install as a global .NET tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt; RoslynLens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register with Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--scope&lt;/span&gt; user &lt;span class="nt"&gt;--transport&lt;/span&gt; stdio roslyn-lens &lt;span class="nt"&gt;--&lt;/span&gt; roslyn-lens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. RoslynLens auto-discovers the nearest &lt;code&gt;.sln&lt;/code&gt; or &lt;code&gt;.slnx&lt;/code&gt; file (BFS, max 3 levels), loads it in the background, and starts serving queries over stdio.&lt;/p&gt;

&lt;p&gt;You can also configure it via &lt;code&gt;.mcp.json&lt;/code&gt; in your project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"roslyn-lens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"roslyn-lens"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  28 tools at a glance
&lt;/h2&gt;

&lt;p&gt;RoslynLens tools fall into six categories.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symbol navigation
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find_symbol&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Locate definitions by name — supports &lt;strong&gt;glob patterns&lt;/strong&gt; (&lt;code&gt;*Service&lt;/code&gt;, &lt;code&gt;Get*User&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find_references&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All usages of a symbol across the solution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find_implementations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Interface implementors and derived classes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find_callers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Direct callers of a method&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find_overrides&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Overrides of virtual/abstract methods&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find_dead_code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unused types, methods, properties — with filters for public members, entry points, project/file scope&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Inspection
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_public_api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public surface of a type — no file reading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_symbol_detail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full signature, parameters, return type, XML docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_type_hierarchy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inheritance chain, interfaces, derived types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_project_graph&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Solution dependency tree (with &lt;code&gt;projectFilter&lt;/code&gt; for large solutions)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_dependency_graph&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Method call chain visualization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_diagnostics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compiler warnings and errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_test_coverage_map&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Heuristic test-to-code mapping&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Analysis
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_complexity_metrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cyclomatic, cognitive complexity, nesting depth, LOC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_data_flow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Variable assignments, reads, writes, captured variables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_control_flow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reachability, return points, exit points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detect_antipatterns&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;18 built-in detectors (see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detect_circular_dependencies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Project and type-level cycle detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detect_duplicates&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Structurally similar code via AST fingerprinting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Compound tools (one call, multiple analyses)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_method&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signature + callers + dependencies + complexity in one call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_type_overview&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public API + hierarchy + implementations + diagnostics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_file_overview&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Types + diagnostics + anti-patterns for a file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Batch operations (multiple symbols, one call)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find_symbols_batch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resolve multiple symbol names at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_public_api_batch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public API of multiple types at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_symbol_detail_batch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Details of multiple symbols at once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  External &amp;amp; specialized
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resolve_external_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resolve NuGet/framework source via SourceLink or decompilation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_module_depends_on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[DependsOn]&lt;/code&gt; attribute graph for modular monoliths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;validate_conventions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Convention violation checker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  18 anti-pattern detectors
&lt;/h2&gt;

&lt;p&gt;RoslynLens doesn't just navigate code — it catches common .NET mistakes at query time. Detectors run on syntax trees with optional semantic model analysis, so they're fast and don't require a full compilation for basic checks.&lt;/p&gt;

&lt;h3&gt;
  
  
  General .NET
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AP001&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;async void&lt;/code&gt; methods (except event handlers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AP002&lt;/td&gt;
&lt;td&gt;Sync-over-async: &lt;code&gt;.Result&lt;/code&gt;, &lt;code&gt;.Wait()&lt;/code&gt;, &lt;code&gt;.GetAwaiter().GetResult()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AP003&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;new HttpClient()&lt;/code&gt; instead of &lt;code&gt;IHttpClientFactory&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AP004&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DateTime.Now&lt;/code&gt;/&lt;code&gt;UtcNow&lt;/code&gt; instead of &lt;code&gt;TimeProvider&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AP005&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;catch (Exception)&lt;/code&gt; without re-throw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AP006&lt;/td&gt;
&lt;td&gt;String interpolation in log calls (structured logging violation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AP007&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;#pragma warning disable&lt;/code&gt; without matching &lt;code&gt;restore&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AP008&lt;/td&gt;
&lt;td&gt;Async methods missing &lt;code&gt;CancellationToken&lt;/code&gt; parameter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AP009&lt;/td&gt;
&lt;td&gt;EF Core queries without &lt;code&gt;.AsNoTracking()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Domain-specific
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GR-GUID&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Guid.NewGuid()&lt;/code&gt; instead of &lt;code&gt;IGuidGenerator&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GR-SECRET&lt;/td&gt;
&lt;td&gt;Hardcoded passwords, connection strings, API keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GR-SYNC-EF&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SaveChanges()&lt;/code&gt; instead of &lt;code&gt;SaveChangesAsync()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GR-BADREQ&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TypedResults.BadRequest&amp;lt;string&amp;gt;()&lt;/code&gt; instead of &lt;code&gt;Problem()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GR-REGEX&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;new Regex()&lt;/code&gt; instead of &lt;code&gt;[GeneratedRegex]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GR-SLEEP&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Thread.Sleep()&lt;/code&gt; in production code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GR-CONSOLE&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Console.Write&lt;/code&gt;/&lt;code&gt;WriteLine&lt;/code&gt; instead of &lt;code&gt;ILogger&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GR-CFGAWAIT&lt;/td&gt;
&lt;td&gt;Missing &lt;code&gt;ConfigureAwait(false)&lt;/code&gt; in library code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GR-DTO&lt;/td&gt;
&lt;td&gt;Classes/records with &lt;code&gt;*Dto&lt;/code&gt; suffix (naming convention violation)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Adding a new detector is straightforward. For simple invocation matches (&lt;code&gt;Foo.Bar()&lt;/code&gt;), extend &lt;code&gt;InvocationDetectorBase&lt;/code&gt;. For &lt;code&gt;new Foo()&lt;/code&gt; patterns, extend &lt;code&gt;ObjectCreationDetectorBase&lt;/code&gt;. For complex logic, implement &lt;code&gt;IAntiPatternDetector&lt;/code&gt; directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deep dive: key v1.1 features
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fuzzy FQN resolution
&lt;/h3&gt;

&lt;p&gt;AI assistants often send imprecise symbol names. Instead of failing, RoslynLens uses a &lt;strong&gt;three-tier fallback strategy&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exact match&lt;/strong&gt; — case-insensitive, highest priority&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial namespace&lt;/strong&gt; — &lt;code&gt;MyService&lt;/code&gt; resolves to &lt;code&gt;MyApp.Services.MyService&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Levenshtein distance&lt;/strong&gt; — typo tolerance (1-2 edits depending on name length)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When multiple candidates match, the response includes a &lt;strong&gt;disambiguation list&lt;/strong&gt; with fully qualified names, file paths, and line numbers — so the AI can pick the right one without another round-trip.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duplicate code detection via AST fingerprinting
&lt;/h3&gt;

&lt;p&gt;Text-based diff tools miss renamed-but-identical logic. RoslynLens normalizes syntax trees by replacing identifiers with &lt;code&gt;ID&lt;/code&gt;, literals with &lt;code&gt;LIT&lt;/code&gt;, and types with &lt;code&gt;TYPE&lt;/code&gt;, then computes a &lt;strong&gt;SHA256 fingerprint&lt;/strong&gt; of the normalized structure. Methods with identical fingerprints are structurally equivalent — regardless of variable names.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;detect_duplicates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;projectFilter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MyProject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minStatements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="n"&gt;duplicate&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;locations&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Complexity metrics (SonarSource model)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;get_complexity_metrics&lt;/code&gt; computes four metrics per method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cyclomatic complexity&lt;/strong&gt; — counts decision points (if, while, for, switch, catch, &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;??&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cognitive complexity&lt;/strong&gt; — SonarSource model with nesting penalties&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nesting depth&lt;/strong&gt; — maximum block nesting level&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logical LOC&lt;/strong&gt; — excluding blanks and comments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Supports &lt;strong&gt;method&lt;/strong&gt;, &lt;strong&gt;type&lt;/strong&gt;, and &lt;strong&gt;project&lt;/strong&gt; scope with a configurable &lt;code&gt;threshold&lt;/code&gt; to surface only the worst hotspots.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data flow and control flow analysis
&lt;/h3&gt;

&lt;p&gt;Built on Roslyn's &lt;code&gt;SemanticModel.AnalyzeDataFlow()&lt;/code&gt; and &lt;code&gt;AnalyzeControlFlow()&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data flow&lt;/strong&gt;: variables declared, read inside, written inside, always assigned, captured by closures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control flow&lt;/strong&gt;: start/end reachability, return statement count, exit points&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  External source resolution
&lt;/h3&gt;

&lt;p&gt;When the AI needs to understand a NuGet package API, RoslynLens follows a resolution hierarchy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SourceLink&lt;/strong&gt; — check if the symbol has source locations in the solution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decompilation&lt;/strong&gt; — fallback via ICSharpCode.Decompiler (truncated to 60 lines for token efficiency)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No more telling the AI to "just check the NuGet documentation."&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: designed for large solutions
&lt;/h2&gt;

&lt;p&gt;RoslynLens is built to handle real-world .NET solutions with 50+ projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Background loading&lt;/strong&gt; — the solution loads asynchronously via a &lt;code&gt;BackgroundService&lt;/code&gt;. Tools return a "loading" status until the workspace is ready, then serve queries instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy compilation with LRU cache&lt;/strong&gt; — solutions with many projects compile on-demand. A configurable LRU cache (default: 20 compilations) keeps frequently accessed projects hot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File watchers&lt;/strong&gt; — &lt;code&gt;.cs&lt;/code&gt; changes trigger incremental text updates on the Roslyn workspace. &lt;code&gt;.csproj&lt;/code&gt; changes trigger a full reload. No restart needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs to stderr&lt;/strong&gt; — stdout is reserved for JSON-RPC (MCP protocol). All diagnostic output goes to stderr.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/RoslynLens/
├── Program.cs                  # Host + MCP stdio transport
├── SolutionDiscovery.cs        # BFS .sln/.slnx auto-discovery
├── WorkspaceManager.cs         # MSBuildWorkspace, LRU compilation cache
├── WorkspaceInitializer.cs     # Background solution loading
├── SymbolResolver.cs           # Cross-project resolution + fuzzy FQN
├── ComplexityAnalyzer.cs       # Cyclomatic/cognitive complexity
├── DuplicateCodeDetector.cs    # AST fingerprinting
├── ExternalSourceResolver.cs   # SourceLink + decompilation
├── Tools/                      # 28 MCP tool implementations
├── Analyzers/                  # 18 anti-pattern detectors
└── Responses/                  # Token-optimized DTOs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;Four environment variables for runtime tuning:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ROSLYN_LENS_TIMEOUT_SECONDS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Operation timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ROSLYN_LENS_MAX_RESULTS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;Maximum results per query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ROSLYN_LENS_CACHE_SIZE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;LRU compilation cache size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ROSLYN_LENS_LOG_LEVEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Information&lt;/td&gt;
&lt;td&gt;Log verbosity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Real-world impact: token savings
&lt;/h2&gt;

&lt;p&gt;Here's a comparison on a 35-project solution (~180,000 lines of C#):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Without RoslynLens&lt;/th&gt;
&lt;th&gt;With RoslynLens&lt;/th&gt;
&lt;th&gt;Savings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find all implementations of &lt;code&gt;IRepository&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Read 8 files (~12,000 tokens)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;find_implementations&lt;/code&gt; (95 tokens)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Understand &lt;code&gt;OrderService&lt;/code&gt; API&lt;/td&gt;
&lt;td&gt;Read full file (1,800 tokens)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_public_api&lt;/code&gt; (120 tokens)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check for anti-patterns in a module&lt;/td&gt;
&lt;td&gt;Read 15 files (~22,000 tokens)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;detect_antipatterns&lt;/code&gt; (200 tokens)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analyze complexity hotspots&lt;/td&gt;
&lt;td&gt;Manual review across projects&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_complexity_metrics&lt;/code&gt; (150 tokens)&lt;/td&gt;
&lt;td&gt;N/A (not feasible manually)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The compound tools push this further. &lt;code&gt;analyze_method&lt;/code&gt; replaces what would be 4-5 separate tool calls (or 4-5 file reads) with a single query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RoslynLens replaces file reading with semantic queries&lt;/strong&gt; — 30-150 tokens instead of 500-2,000+ per file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;28 tools&lt;/strong&gt; cover navigation, analysis, batch operations, and compound queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;18 anti-pattern detectors&lt;/strong&gt; catch common .NET mistakes at query time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuzzy FQN resolution&lt;/strong&gt; handles imprecise AI queries gracefully&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AST-based duplicate detection&lt;/strong&gt; finds renamed-but-identical logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works on large solutions&lt;/strong&gt; — lazy compilation, LRU cache, background loading&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt; RoslynLens

&lt;span class="c"&gt;# Register with Claude Code&lt;/span&gt;
claude mcp add &lt;span class="nt"&gt;--scope&lt;/span&gt; user &lt;span class="nt"&gt;--transport&lt;/span&gt; stdio roslyn-lens &lt;span class="nt"&gt;--&lt;/span&gt; roslyn-lens

&lt;span class="c"&gt;# Done. Navigate to any .NET project and Claude Code can query it.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/jfmeyers/roslyn-lens" rel="noopener noreferrer"&gt;jfmeyers/roslyn-lens&lt;/a&gt; | &lt;strong&gt;NuGet&lt;/strong&gt;: &lt;a href="https://www.nuget.org/packages/RoslynLens/" rel="noopener noreferrer"&gt;RoslynLens&lt;/a&gt; | &lt;strong&gt;License&lt;/strong&gt;: Apache-2.0&lt;/p&gt;

&lt;p&gt;Built by &lt;a href="https://github.com/jfmeyers" rel="noopener noreferrer"&gt;JF Meyers&lt;/a&gt;. Contributions welcome.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>csharp</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Your Enterprise Customer Just Asked for a SOC 2 Type 2 Report. Now What?</title>
      <dc:creator>JF Meyers</dc:creator>
      <pubDate>Sat, 28 Mar 2026 11:27:17 +0000</pubDate>
      <link>https://dev.to/jfmeyers/your-enterprise-customer-just-asked-for-a-soc-2-type-2-report-now-what-50eo</link>
      <guid>https://dev.to/jfmeyers/your-enterprise-customer-just-asked-for-a-soc-2-type-2-report-now-what-50eo</guid>
      <description>&lt;p&gt;You are three weeks from closing a six-figure deal. The customer's security team sends a vendor assessment form. Question 4: "Do you have a SOC 2 Type 2 report?"&lt;/p&gt;

&lt;p&gt;You don't.&lt;/p&gt;

&lt;p&gt;The deal goes on hold. Six months later, it dies.&lt;/p&gt;

&lt;p&gt;This is happening more and more. SOC 2 Type 2 is no longer just a nice-to-have for companies selling to US enterprise — it is a procurement gate. And for .NET teams, the path from "we should probably do this" to "we have the controls in place" is less obvious than it should be.&lt;/p&gt;

&lt;p&gt;This article maps the five SOC 2 &lt;strong&gt;Trust Service Criteria (TSC)&lt;/strong&gt; to concrete .NET controls, and shows how &lt;a href="https://granit-fx.dev" rel="noopener noreferrer"&gt;Granit&lt;/a&gt; — an open-source modular .NET 10 framework — covers most of the technical side out of the box.&lt;/p&gt;




&lt;h2&gt;
  
  
  SOC 2 in one paragraph
&lt;/h2&gt;

&lt;p&gt;SOC 2 is an AICPA standard. It comes in two flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Type 1&lt;/strong&gt;: your controls &lt;em&gt;exist&lt;/em&gt; at a point in time. Cheap to get. Low customer value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type 2&lt;/strong&gt;: your controls &lt;em&gt;operated effectively&lt;/em&gt; over a 6–12 month observation window. Expensive. What enterprise customers actually want.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The audit covers up to five Trust Service Criteria. Only &lt;strong&gt;Security (CC)&lt;/strong&gt; is mandatory. Most enterprise customers also require &lt;strong&gt;Availability&lt;/strong&gt; and &lt;strong&gt;Confidentiality&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The key thing to internalize: &lt;strong&gt;the auditor does not certify your code&lt;/strong&gt;. They certify that your controls operated and that your team followed procedures. Granit gives you the technical controls. The processes, runbooks, and evidence collection are still on you.&lt;/p&gt;




&lt;h2&gt;
  
  
  CC6 — Logical access controls
&lt;/h2&gt;

&lt;p&gt;This is the biggest control family. It covers authentication, authorization, and how you prevent unauthorized access.&lt;/p&gt;

&lt;h3&gt;
  
  
  CC6.1 — Strong authentication
&lt;/h3&gt;

&lt;p&gt;Granit's BFF module keeps tokens off the browser entirely. The &lt;code&gt;access_token&lt;/code&gt; and &lt;code&gt;refresh_token&lt;/code&gt; live server-side in an encrypted &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;SameSite=Strict&lt;/code&gt; session cookie. No XSS attack — including a compromised npm dependency — can steal a token the browser never had.&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="c1"&gt;// Program.cs&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;AddGranit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;granit&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;granit&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddModule&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GranitBffModule&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;AddModule&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GranitBffYarpModule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For machine-to-machine flows, &lt;code&gt;Granit.Authentication.DPoP&lt;/code&gt; implements RFC 9449 proof-of-possession tokens. Even if an attacker intercepts an access token, they cannot replay it without the client's private key.&lt;/p&gt;

&lt;p&gt;The full FAPI 2.0 security profile — PKCE (RFC 7636) + PAR (RFC 9126) + DPoP (RFC 9449) + &lt;code&gt;private_key_jwt&lt;/code&gt; (RFC 7523) — is available via &lt;code&gt;Granit.OpenIddict&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  CC6.3 — Authorization and least-privilege
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Granit.Authorization&lt;/code&gt; stores permissions in the database and evaluates them at runtime. No recompile cycle when your access model changes. Permissions follow a strict &lt;code&gt;[Group].[Resource].[Action]&lt;/code&gt; format enforced by architecture tests.&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="c1"&gt;// Endpoint declaration&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id:guid}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DeleteAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvoicePermissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Manage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-tenant permission policies are built-in via &lt;code&gt;Granit.MultiTenancy&lt;/code&gt; — the same endpoint can enforce different access rules for different customer organizations.&lt;/p&gt;

&lt;h3&gt;
  
  
  CC6.7 — Encryption at rest
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Granit.Encryption&lt;/code&gt; provides &lt;code&gt;IStringEncryptionService&lt;/code&gt; for field-level encryption. In development, it falls back to AES-256-CBC automatically. In production, &lt;code&gt;Granit.Vault.HashiCorp&lt;/code&gt; delegates to HashiCorp Vault Transit — the key never leaves Vault:&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;class&lt;/span&gt; &lt;span class="nc"&gt;PatientService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IStringEncryptionService&lt;/span&gt; &lt;span class="n"&gt;encryption&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Patient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CreateAsync&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;name&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;nationalId&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;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// nationalId is encrypted before it touches the database&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;encrypted&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;encryption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EncryptAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nationalId&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;.&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&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;encrypted&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 Vault module disables itself in &lt;code&gt;Development&lt;/code&gt; — no local Vault instance needed for day-to-day development.&lt;/p&gt;

&lt;h3&gt;
  
  
  CC6.8 — Malware and supply chain
&lt;/h3&gt;

&lt;p&gt;See the BFF section above. Tokens never in &lt;code&gt;localStorage&lt;/code&gt;. Supply chain XSS attacks are neutralized by design, not by CSP headers.&lt;/p&gt;




&lt;h2&gt;
  
  
  CC7 — System operations and monitoring
&lt;/h2&gt;

&lt;h3&gt;
  
  
  CC7.1 — Anomaly detection
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Granit.Observability&lt;/code&gt; wires Serilog + OpenTelemetry in one module registration:&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="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddGranit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;granit&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;granit&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddModule&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GranitObservabilityModule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every module emits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured logs&lt;/strong&gt; via &lt;code&gt;[LoggerMessage]&lt;/code&gt; source generation — no string interpolation, no accidental PII in log output&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics&lt;/strong&gt; via &lt;code&gt;IMeterFactory&lt;/code&gt; — standardized naming (&lt;code&gt;granit.authorization.permission.check&lt;/code&gt;, &lt;code&gt;granit.persistence.query.duration&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distributed traces&lt;/strong&gt; via &lt;code&gt;ActivitySource&lt;/code&gt; — one per module, correlated with logs via &lt;code&gt;trace_id&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Grafana LGTM stack (Loki + Grafana + Tempo + Mimir) ships as a Docker Compose overlay. A &lt;code&gt;trace_id&lt;/code&gt; on every request means you can reconstruct the exact sequence of events for any incident — which is what the auditor wants to see.&lt;/p&gt;

&lt;h3&gt;
  
  
  CC7.2 — Audit trail
&lt;/h3&gt;

&lt;p&gt;This is the one control teams most often try to implement manually and get wrong. With Granit, it is automatic:&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="c1"&gt;// Inheriting AuditedEntity gives you these four fields on every write:&lt;/span&gt;
&lt;span class="c1"&gt;// CreatedAt  → IClock.Now (UTC, always)&lt;/span&gt;
&lt;span class="c1"&gt;// CreatedBy  → ICurrentUserService.UserId (or "system" for jobs)&lt;/span&gt;
&lt;span class="c1"&gt;// ModifiedAt → IClock.Now&lt;/span&gt;
&lt;span class="c1"&gt;// ModifiedBy → ICurrentUserService.UserId&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;Invoice&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FullAuditedAggregateRoot&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&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;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&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;Invoice&lt;/span&gt; &lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="c1"&gt;// Audit fields set by interceptor inside SaveChangesAsync — not by application code&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AuditedEntityInterceptor&lt;/code&gt; runs inside the same database transaction as the business write. You cannot have a committed write without an audit record. You cannot accidentally skip it.&lt;/p&gt;

&lt;p&gt;For business-level events ("Invoice approved by Marie"), the &lt;code&gt;Timeline&lt;/code&gt; module adds a human-readable activity log with actor, timestamp, and Markdown body — independent of the field-level audit trail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Confidentiality TSC — crypto-shredding
&lt;/h2&gt;

&lt;p&gt;Here is a problem that comes up in every SOC 2 + GDPR scenario:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GDPR Art. 17&lt;/strong&gt; says you must be able to erase a user's data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SOC 2 CC7.2&lt;/strong&gt; says your audit trail must be immutable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These appear to contradict. If you delete rows to satisfy GDPR, your audit trail references records that no longer exist. If you keep the rows, you're not actually erasing the data.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Granit.Privacy&lt;/code&gt; resolves this with &lt;strong&gt;crypto-shredding&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Each tenant's sensitive fields are encrypted with a tenant-specific key stored in Vault.&lt;/li&gt;
&lt;li&gt;On erasure request, the key is destroyed in Vault.&lt;/li&gt;
&lt;li&gt;All encrypted fields become permanently unreadable — mathematically erased.&lt;/li&gt;
&lt;li&gt;The audit trail rows stay intact. The data they reference is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No rows deleted. Full erasure. Intact audit trail. Compliant with both.&lt;/p&gt;




&lt;h2&gt;
  
  
  Availability TSC
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Granit.RateLimiting&lt;/code&gt; protects against abusive traffic patterns. Standard &lt;code&gt;429 Too Many Requests&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt; header — easily validated in load tests for the auditor's evidence package.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Granit.MultiTenancy&lt;/code&gt; supports three isolation strategies. The &lt;code&gt;DatabasePerTenant&lt;/code&gt; strategy means one tenant's database issue cannot cascade to others — a common availability concern for SaaS audits.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Risk containment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SharedDatabase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Logical (query filter)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SchemaPerTenant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Schema-level separation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DatabasePerTenant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full physical isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Privacy TSC
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[LoggerMessage]&lt;/code&gt; source generation enforces that no PII can slip into log strings at compile time. The Roslyn analyzer flags violations before they reach CI.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IProcessingRestrictable&lt;/code&gt; adds a global EF Core query filter for processing restriction (GDPR Art. 18) — applied by &lt;code&gt;ApplyGranitConventions&lt;/code&gt;, never forgettable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IPersonalDataProvider&lt;/code&gt; aggregates personal data exports across all modules for data portability requests (GDPR Art. 15 / SOC 2 P4.1).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What you still need to do
&lt;/h2&gt;

&lt;p&gt;Granit handles the technical controls. The audit also requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Incident response runbook&lt;/strong&gt; — a documented procedure, not just Grafana dashboards&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access review cadence&lt;/strong&gt; — quarterly reviews of who has production access, with evidence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change management&lt;/strong&gt; — pull request policies, deployment approval gates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Employee security training&lt;/strong&gt; — awareness + phishing simulation records&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Annual penetration test&lt;/strong&gt; — with findings and remediation tracking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor due diligence&lt;/strong&gt; — security assessments for your own third-party integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The observation window starts when you engage your auditor. Starting with the technical controls already in place means your preparation budget goes to the operational side — which is where the actual audit risk lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  SOC 2 TSC compliance matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;Granit module&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CC6.1 — Authentication&lt;/td&gt;
&lt;td&gt;JWT Bearer + DPoP + PKCE&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Authentication&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.1 — Transport encryption&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RequireHttpsMetadata = true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Authentication&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.3 — Authorization&lt;/td&gt;
&lt;td&gt;Dynamic RBAC/ABAC, runtime permissions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Authorization&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.3 — Tenant isolation&lt;/td&gt;
&lt;td&gt;Query filters, 3 strategies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.MultiTenancy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.6 — Third-party auth&lt;/td&gt;
&lt;td&gt;FAPI 2.0 (PAR + DPoP + &lt;code&gt;private_key_jwt&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.OpenIddict&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.7 — Encryption at rest&lt;/td&gt;
&lt;td&gt;Field-level AES / Vault Transit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Granit.Encryption&lt;/code&gt;, &lt;code&gt;Granit.Vault&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.7 — Key management&lt;/td&gt;
&lt;td&gt;HashiCorp Vault, auto-rotation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Vault.HashiCorp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.8 — Token protection&lt;/td&gt;
&lt;td&gt;BFF pattern, HttpOnly cookies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Bff&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC7.1 — Anomaly detection&lt;/td&gt;
&lt;td&gt;Structured logs + metrics + traces&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Observability&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC7.2 — Audit trail&lt;/td&gt;
&lt;td&gt;Interceptor-based, immutable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Auditing&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A1.1 — Availability&lt;/td&gt;
&lt;td&gt;Rate limiting, distributed cache&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Granit.RateLimiting&lt;/code&gt;, &lt;code&gt;Granit.Caching&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A1.2 — Tenant isolation&lt;/td&gt;
&lt;td&gt;DB/schema/filter strategies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.MultiTenancy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C1.1 — Confidentiality&lt;/td&gt;
&lt;td&gt;Field encryption + crypto-shredding&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Granit.Encryption&lt;/code&gt;, &lt;code&gt;Granit.Privacy&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P4.1 — Data portability&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;IPersonalDataProvider&lt;/code&gt; aggregation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Privacy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P8.1 — Right to erasure&lt;/td&gt;
&lt;td&gt;Crypto-shredding via Vault key destruction&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Granit.Privacy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;SOC 2 Type 2 is a process audit, not a code audit. But the technical controls need to be in place and operating before the observation window starts.&lt;/p&gt;

&lt;p&gt;If you're building on .NET, &lt;a href="https://granit-fx.dev" rel="noopener noreferrer"&gt;Granit&lt;/a&gt; gives you CC6, CC7, and the Confidentiality/Privacy TSC controls out of the box: dynamic RBAC, automatic audit trails, field-level encryption with Vault key management, BFF token protection, and crypto-shredding for GDPR erasure without breaking your audit trail.&lt;/p&gt;

&lt;p&gt;The hard part — runbooks, access reviews, pen tests, employee training — is still on you. But starting with a framework that makes the compliant path the default path means you're spending your prep time on the right things.&lt;/p&gt;

&lt;p&gt;Full documentation: &lt;a href="https://granit-fx.dev" rel="noopener noreferrer"&gt;granit-fx.dev&lt;/a&gt;&lt;br&gt;
Source code: &lt;a href="https://github.com/granit-fx/granit-dotnet" rel="noopener noreferrer"&gt;github.com/granit-fx/granit-dotnet&lt;/a&gt; (Apache 2.0)&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>security</category>
      <category>compliance</category>
      <category>soc2</category>
    </item>
    <item>
      <title>Swashbuckle Is Dead. Here's How to Migrate to Scalar in .NET 10.</title>
      <dc:creator>JF Meyers</dc:creator>
      <pubDate>Sat, 28 Mar 2026 09:57:22 +0000</pubDate>
      <link>https://dev.to/jfmeyers/swashbuckle-is-dead-heres-how-to-migrate-to-scalar-in-net-10-155d</link>
      <guid>https://dev.to/jfmeyers/swashbuckle-is-dead-heres-how-to-migrate-to-scalar-in-net-10-155d</guid>
      <description>&lt;h2&gt;
  
  
  Why Swashbuckle is no longer the right choice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Swashbuckle.AspNetCore&lt;/strong&gt; was the de facto Swagger UI for .NET for years.&lt;br&gt;
Today it has three problems that are hard to work around:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No OpenAPI 3.1 support&lt;/td&gt;
&lt;td&gt;Can't use JSON Schema features (&lt;code&gt;const&lt;/code&gt;, webhooks, &lt;code&gt;$ref&lt;/code&gt; siblings)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upstream archived&lt;/td&gt;
&lt;td&gt;No security patches, no .NET compatibility updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bypasses native metadata&lt;/td&gt;
&lt;td&gt;Misses &lt;code&gt;.Produces&amp;lt;T&amp;gt;()&lt;/code&gt;, &lt;code&gt;.ProducesProblem()&lt;/code&gt; on Minimal APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Microsoft removed Swashbuckle from the &lt;code&gt;dotnet new webapi&lt;/code&gt; template in .NET 9 and&lt;br&gt;
replaced it with &lt;code&gt;Microsoft.AspNetCore.OpenApi&lt;/code&gt;. That was the official signal.&lt;/p&gt;


&lt;h2&gt;
  
  
  What replaced it
&lt;/h2&gt;

&lt;p&gt;Two components, one stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Microsoft.AspNetCore.OpenApi&lt;/code&gt;&lt;/strong&gt; — first-party document generator, ships with&lt;br&gt;
.NET 9+. Hooks directly into the ASP.NET Core endpoint data source. No XML comment&lt;br&gt;
parsing, no reflection gymnastics. Generates &lt;strong&gt;OpenAPI 3.1&lt;/strong&gt; from the metadata you&lt;br&gt;
already declared.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Scalar.AspNetCore&lt;/code&gt;&lt;/strong&gt; — open-source API reference portal (MIT). Reads any&lt;br&gt;
OpenAPI 3.1 document and renders an interactive UI with OAuth2/PKCE auth, code&lt;br&gt;
generation in 15+ languages, and a clean dark/light mode design.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are independent. You can use the native generator with a different UI, or Scalar&lt;br&gt;
with a document from NSwag. In practice, using them together is the obvious choice.&lt;/p&gt;


&lt;h2&gt;
  
  
  The migration — step by step
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Remove Swashbuckle
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet remove package Swashbuckle.AspNetCore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Also remove any &lt;code&gt;Swashbuckle.AspNetCore.*&lt;/code&gt; packages (annotations, filters, etc.).&lt;br&gt;
Delete the &lt;code&gt;AddSwaggerGen&lt;/code&gt; and &lt;code&gt;UseSwagger&lt;/code&gt; / &lt;code&gt;UseSwaggerUI&lt;/code&gt; calls from &lt;code&gt;Program.cs&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Add the new packages
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  3. Register document generation
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Scalar.AspNetCore&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;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Native OpenAPI document generation&lt;/span&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;AddOpenApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"v1"&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;=&amp;gt;&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="nf"&gt;AddDocumentTransformer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&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="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&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;OpenApiInfo&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Bookings API"&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"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Hotel booking management API."&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;CompletedTask&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  4. Map the endpoints
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&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="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsDevelopment&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Serves /openapi/v1.json&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;MapOpenApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/openapi/v1.json"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Serves Scalar UI at /scalar&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;MapScalarApiReference&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That is the minimum viable setup. Navigate to &lt;code&gt;/scalar&lt;/code&gt; and you have an interactive&lt;br&gt;
API explorer backed by your live OpenAPI 3.1 document.&lt;/p&gt;


&lt;h2&gt;
  
  
  Adding JWT Bearer authentication
&lt;/h2&gt;

&lt;p&gt;Swashbuckle had &lt;code&gt;AddSecurityDefinition&lt;/code&gt; / &lt;code&gt;AddSecurityRequirement&lt;/code&gt;. The native pipeline&lt;br&gt;
uses &lt;strong&gt;document transformers&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="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.AspNetCore.Authentication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.AspNetCore.OpenApi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.OpenApi.Models&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;internal&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;BearerSecuritySchemeTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IAuthenticationSchemeProvider&lt;/span&gt; &lt;span class="n"&gt;authenticationSchemeProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IOpenApiDocumentTransformer&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;TransformAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;OpenApiDocument&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;OpenApiDocumentTransformerContext&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;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&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;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;schemes&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;authenticationSchemeProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAllSchemesAsync&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;schemes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&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;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Components&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;OpenApiComponents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Components&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecuritySchemes&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;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&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;OpenApiSecurityScheme&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Components&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecuritySchemes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Bearer"&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="n"&gt;OpenApiSecurityScheme&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecuritySchemeType&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;,&lt;/span&gt;
            &lt;span class="n"&gt;Scheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;BearerFormat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"JWT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;In&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ParameterLocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Enter your JWT token."&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;Register it before &lt;code&gt;AddOpenApi&lt;/code&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="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="n"&gt;AddTransient&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BearerSecuritySchemeTransformer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&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;AddOpenApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"v1"&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;=&amp;gt;&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;AddDocumentTransformer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BearerSecuritySchemeTransformer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: the transformer must be registered in DI &lt;em&gt;before&lt;/em&gt; &lt;code&gt;AddOpenApi&lt;/code&gt; is called,&lt;br&gt;
or the DI resolution will fail at startup.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  OAuth2 Authorization Code + PKCE
&lt;/h2&gt;

&lt;p&gt;If your API is protected by an OAuth2 provider (Keycloak, Entra ID, Auth0), you can&lt;br&gt;
configure Scalar to authenticate directly in the browser — no Postman needed.&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="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapScalarApiReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bookings API"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthorizationCodeFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OAuth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flow&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;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithClientId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bookings-frontend-client"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithSelectedScopes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"openid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithPkce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Pkce&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="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;Scalar shows a &lt;strong&gt;Sign in&lt;/strong&gt; button. The flow runs entirely in the browser. The resulting&lt;br&gt;
token is automatically attached to every test request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a public (frontend) client.&lt;/strong&gt; Never configure your backend confidential client&lt;br&gt;
here — it would expose the client secret in a browser context.&lt;/p&gt;


&lt;h2&gt;
  
  
  Multi-version APIs
&lt;/h2&gt;

&lt;p&gt;One document per major version. Each call to &lt;code&gt;AddOpenApi&lt;/code&gt; registers an independent&lt;br&gt;
document:&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="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;AddOpenApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"v1"&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&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="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"v2"&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&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;MapOpenApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/openapi/v1.json"&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;MapOpenApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/openapi/v2.json"&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;MapScalarApiReference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEndpointPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/scalar/{documentName}"&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 default Scalar route (&lt;code&gt;/scalar/{documentName}&lt;/code&gt;) handles version switching in the UI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;.Produces&amp;lt;T&amp;gt;()&lt;/code&gt; is required — there is no fallback inference
&lt;/h3&gt;

&lt;p&gt;Swashbuckle could sometimes infer response types by inspecting the action return type.&lt;br&gt;
The native pipeline does not. If you omit &lt;code&gt;.Produces&amp;lt;T&amp;gt;()&lt;/code&gt;, the operation has no&lt;br&gt;
response schema.&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="c1"&gt;// Missing .Produces&amp;lt;BookingResponse&amp;gt;() → no response type in the OpenAPI document&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;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/bookings/{id:guid}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GetBookingAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GetBooking"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Returns a booking by ID."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProducesProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status404NotFound&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// .Produces&amp;lt;BookingResponse&amp;gt;() ← you must add this&lt;/span&gt;

&lt;span class="c1"&gt;// Correct&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;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/bookings/{id:guid}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GetBookingAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GetBooking"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Returns a booking by ID."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Produces&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BookingResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProducesProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status404NotFound&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Use &lt;code&gt;TypedResults&lt;/code&gt;, not &lt;code&gt;Results&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Results.Ok(value)&lt;/code&gt; returns &lt;code&gt;IResult&lt;/code&gt; at compile time — the native pipeline cannot&lt;br&gt;
determine the response type. &lt;code&gt;TypedResults.Ok(value)&lt;/code&gt; returns &lt;code&gt;Ok&amp;lt;T&amp;gt;&lt;/code&gt; — the type is&lt;br&gt;
known statically and shows up in the OpenAPI schema automatically.&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="c1"&gt;// BAD — type lost at compile time&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// GOOD — type preserved, OpenAPI schema complete&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Protect the docs endpoint in non-development environments
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;app.MapOpenApi()&lt;/code&gt; and &lt;code&gt;app.MapScalarApiReference()&lt;/code&gt; create unauthenticated endpoints&lt;br&gt;
by default. In staging or production, protect them explicitly:&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="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapOpenApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/openapi/v1.json"&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="s"&gt;"InternalDeveloper"&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;MapScalarApiReference&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="s"&gt;"InternalDeveloper"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or gate the entire block with &lt;code&gt;IsDevelopment()&lt;/code&gt; and add explicit staging config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transformers run in registration order
&lt;/h3&gt;

&lt;p&gt;Document transformers execute in the order they are registered. If transformer B&lt;br&gt;
depends on data set by transformer A, register A first. This tripped me up with a&lt;br&gt;
security scheme transformer that assumed &lt;code&gt;doc.Components&lt;/code&gt; was already initialized.&lt;/p&gt;


&lt;h2&gt;
  
  
  Scalar vs Swagger UI — feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Swagger UI (Swashbuckle)&lt;/th&gt;
&lt;th&gt;Scalar&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAPI version&lt;/td&gt;
&lt;td&gt;2.0 / 3.0&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.NET 9/10 native pipeline&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive request builder&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAuth2 PKCE in browser&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes, first-class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code generation&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;15+ languages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dark mode&lt;/td&gt;
&lt;td&gt;Third-party themes&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance status&lt;/td&gt;
&lt;td&gt;Archived&lt;/td&gt;
&lt;td&gt;Active (MIT)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;h2&gt;
  
  
  If you use a framework that already handles this
&lt;/h2&gt;

&lt;p&gt;Rolling the transformer pipeline manually gets repetitive across projects. If you work&lt;br&gt;
with the open-source &lt;strong&gt;&lt;a href="https://granit-fx.dev" rel="noopener noreferrer"&gt;Granit&lt;/a&gt;&lt;/strong&gt; framework for .NET, all of&lt;br&gt;
this is pre-wired in the &lt;code&gt;Granit.Http.ApiDocumentation&lt;/code&gt; module: security schemes,&lt;br&gt;
OAuth2 PKCE, multi-version documents, and production protection are all configured&lt;br&gt;
from a single &lt;code&gt;appsettings.json&lt;/code&gt; section.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ApiDocumentation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bookings API"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"MajorVersions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AuthorizationPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"InternalDeveloper"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"OAuth2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"AuthorizationUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://auth.example.com/.../auth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"TokenUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://auth.example.com/.../token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ClientId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bookings-frontend-client"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth a look if you are building a new modular .NET backend from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The migration from Swashbuckle to Scalar on .NET 10 is straightforward once you know&lt;br&gt;
the transformer pattern. The short version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove &lt;code&gt;Swashbuckle.AspNetCore&lt;/code&gt;, add &lt;code&gt;Microsoft.AspNetCore.OpenApi&lt;/code&gt; +
&lt;code&gt;Scalar.AspNetCore&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;AddSwaggerGen&lt;/code&gt; with &lt;code&gt;AddOpenApi&lt;/code&gt; + document transformer for metadata&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;UseSwagger&lt;/code&gt; / &lt;code&gt;UseSwaggerUI&lt;/code&gt; with &lt;code&gt;MapOpenApi&lt;/code&gt; + &lt;code&gt;MapScalarApiReference&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Switch all handlers from &lt;code&gt;Results.*&lt;/code&gt; to &lt;code&gt;TypedResults.*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;.Produces&amp;lt;T&amp;gt;()&lt;/code&gt; explicitly on every endpoint&lt;/li&gt;
&lt;li&gt;Protect the docs endpoints outside of development&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: OpenAPI 3.1, a modern interactive UI, native OAuth2 PKCE, and no more&lt;br&gt;
dependency on an archived package.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have questions or migration tips to share? Drop them in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Let AI Agents Call Your .NET API — MCP Server in 3 Steps</title>
      <dc:creator>JF Meyers</dc:creator>
      <pubDate>Thu, 26 Mar 2026 13:42:35 +0000</pubDate>
      <link>https://dev.to/jfmeyers/let-ai-agents-call-your-net-api-mcp-server-in-3-steps-4i73</link>
      <guid>https://dev.to/jfmeyers/let-ai-agents-call-your-net-api-mcp-server-in-3-steps-4i73</guid>
      <description>&lt;p&gt;Your REST API already serves frontend apps, mobile clients, and third-party integrations. But when an AI agent — Claude, Copilot, Cursor — needs to interact with your application, it's stuck parsing OpenAPI specs, generating HTTP calls, and hoping the auth tokens line up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Model Context Protocol (MCP)&lt;/strong&gt; changes this. An AI agent connects to your app, discovers available tools with typed parameters and descriptions, and invokes them directly. No glue code. No prompt engineering per endpoint.&lt;/p&gt;

&lt;p&gt;In this article, I'll show how to turn any .NET module into an MCP server — with real authorization, GDPR-safe output, and multi-tenant isolation. We'll go from zero to a working MCP server in under 50 lines of code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is MCP?
&lt;/h2&gt;

&lt;p&gt;MCP is an open protocol created by Anthropic and backed by Microsoft. Think of it as &lt;strong&gt;"USB-C for AI agents"&lt;/strong&gt; — a standard way for agents to discover and invoke capabilities exposed by your application.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Agent connects to &lt;code&gt;https://your-app/mcp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Agent calls &lt;code&gt;tools/list&lt;/code&gt; → gets a typed catalog of available tools&lt;/li&gt;
&lt;li&gt;Agent picks the right tool based on the user's intent&lt;/li&gt;
&lt;li&gt;Agent calls &lt;code&gt;tools/call&lt;/code&gt; with typed parameters&lt;/li&gt;
&lt;li&gt;Your app runs the tool, returns structured results&lt;/li&gt;
&lt;li&gt;Agent uses the results to answer the user
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User: "Show me unpaid invoices from last week"

Agent → tools/list
App   ← [SearchInvoices, GetInvoiceDetails, VoidInvoice, ...]

Agent → tools/call SearchInvoices { status: "unpaid", from: "2026-03-19" }
App   ← [{ id: 42, amount: 1250, customer: "Acme" }, ...]

Agent → User: "Found 3 unpaid invoices totaling €4,200..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No custom API integration. No documentation parsing. The agent reads tool descriptions and decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The official MCP C# SDK
&lt;/h2&gt;

&lt;p&gt;Microsoft and Anthropic ship the &lt;a href="https://github.com/modelcontextprotocol/csharp-sdk" rel="noopener noreferrer"&gt;official MCP C# SDK&lt;/a&gt; as NuGet packages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ModelContextProtocol&lt;/code&gt; — core server/client, stdio transport&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ModelContextProtocol.AspNetCore&lt;/code&gt; — HTTP transport for ASP.NET Core&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SDK handles the protocol. You declare tools as annotated 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WeatherTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;McpServerTool&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;"Gets the current weather for a city."&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="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;GetWeather&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;"City name"&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;city&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;$"Weather in &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: 22°C, sunny"&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;Register and map:&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="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="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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithToolsFromAssembly&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the hello-world. But production apps need more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the SDK doesn't solve
&lt;/h2&gt;

&lt;p&gt;When you move beyond a demo, you hit real problems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No auth per tool&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every tool is callable by anyone who reaches the endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PII in responses&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;JsonSerializer.Serialize(patient)&lt;/code&gt; sends email and SSN to the agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tool sprawl&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50+ tools confuse the agent — it picks wrong ones or wastes tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-tenancy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tenant-specific tools show up for all tenants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Error leakage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stack traces with connection strings reach the agent on errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No metrics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You can't tell which tools are called, how often, or by whom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We solved all of these in &lt;a href="https://granit-fx.dev" rel="noopener noreferrer"&gt;Granit&lt;/a&gt;, an open-source modular .NET framework (Apache-2.0, 200 packages). Here's how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Install and register
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Granit.Mcp.Server
&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="c1"&gt;// Program.cs&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;AddGranit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;granit&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;granit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddModule&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GranitMcpServerModule&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGranitMcpServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This wires up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streamable HTTP transport&lt;/strong&gt; at &lt;code&gt;/mcp&lt;/code&gt; (configurable)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDK authorization filters&lt;/strong&gt; — &lt;code&gt;[Authorize]&lt;/code&gt; works on tool classes and methods&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output sanitization pipeline&lt;/strong&gt; — PII redaction, error stripping, size limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool visibility filters&lt;/strong&gt; — tenant scope, module scope, opt-in discovery&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry metrics&lt;/strong&gt; — &lt;code&gt;granit.mcp.tools.invoked&lt;/code&gt;, request duration, active sessions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 RBAC permissions&lt;/strong&gt; — &lt;code&gt;Mcp.Server.Access&lt;/code&gt;, &lt;code&gt;Mcp.Tools.Execute&lt;/code&gt;, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One call. Everything wired into the SDK's native filter pipeline — no parallel abstractions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create a tool class
&lt;/h2&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="n"&gt;McpExposed&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;Policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Invoicing.Invoices.Read"&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;InvoiceMcpTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IInvoiceReader&lt;/span&gt; &lt;span class="n"&gt;reader&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;McpServerTool&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;"Search invoices by status and date range."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpToolOptions&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;true&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SearchInvoicesAsync&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;"Status: draft, sent, paid, overdue"&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;status&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;"Start date (ISO 8601)"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;DateOnly&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ICurrentTenant&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// DI-injected, invisible to agent&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="c1"&gt;// DI-injected, invisible to agent&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&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;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;invoices&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;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoices&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;McpServerTool&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;"Permanently voids an invoice."&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;Policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Invoicing.Invoices.Manage"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpToolOptions&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;true&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;VoidInvoiceAsync&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;"Invoice ID"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;invoiceId&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;"Reason for voiding"&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="n"&gt;CancellationToken&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;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VoidAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoiceId&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="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;$"Invoice &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; voided."&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;Key points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[McpExposed]&lt;/code&gt;&lt;/strong&gt; — opt-in discovery. In production, tools without this attribute stay hidden. Prevents accidental exposure of internal services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[Authorize(Policy = "...")]&lt;/code&gt;&lt;/strong&gt; — standard ASP.NET Core authorization. Same attribute you use on Minimal API endpoints. No custom MCP auth layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ICurrentTenant&lt;/code&gt;, &lt;code&gt;ClaimsPrincipal&lt;/code&gt;, &lt;code&gt;CancellationToken&lt;/code&gt;&lt;/strong&gt; — the SDK resolves these from DI automatically. They're invisible in the tool's JSON schema — the agent never sees them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[McpToolOptions(Destructive = true)]&lt;/code&gt;&lt;/strong&gt; — MCP clients (Claude Desktop, Cursor) prompt for confirmation before invoking destructive tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: There is no step 3
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GranitMcpModule&lt;/code&gt; auto-discovers all &lt;code&gt;[McpServerToolType]&lt;/code&gt; classes from your module assemblies. No manual registration. Add a tool class to any module, deploy, and agents see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  GDPR: PII never reaches the agent
&lt;/h2&gt;

&lt;p&gt;This is the one that keeps security teams up at night. Your &lt;code&gt;SearchInvoices&lt;/code&gt; tool returns customer data. What stops an LLM from ingesting email addresses?&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;[McpRedact]&lt;/code&gt; attribute:&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;InvoiceResponse&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;Guid&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;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;CustomerName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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;McpRedact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Strategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RedactionStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Omit&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;CustomerEmail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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;McpRedact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Strategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RedactionStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TaxId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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;Three strategies:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Use for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Omit&lt;/strong&gt; (default)&lt;/td&gt;
&lt;td&gt;Removes the property entirely&lt;/td&gt;
&lt;td&gt;Email, phone, address&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Replaces with stable SHA-256&lt;/td&gt;
&lt;td&gt;Correlation IDs (tax ID, SSN)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mask&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;j***@***.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UI-only scenarios&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why Omit is the default:&lt;/strong&gt; masked values like &lt;code&gt;j***@***.com&lt;/code&gt; waste LLM tokens and can trigger hallucinations — the model tries to "complete" the masked value. Omit is cleaner and safer.&lt;/p&gt;

&lt;p&gt;The sanitization runs in the SDK's &lt;code&gt;AddCallToolFilter&lt;/code&gt; pipeline. Every tool response passes through it before leaving your app. The &lt;code&gt;ErrorSanitizer&lt;/code&gt; also strips stack traces and connection strings from error responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenancy: tools follow the tenant
&lt;/h2&gt;

&lt;p&gt;Three independent visibility filters:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Opt-in discovery (&lt;code&gt;[McpExposed]&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;Explicit&lt;/code&gt; mode (default for production), tool classes need both &lt;code&gt;[McpServerToolType]&lt;/code&gt; and &lt;code&gt;[McpExposed]&lt;/code&gt;. A developer adding &lt;code&gt;[McpServerTool]&lt;/code&gt; on an internal service for testing won't accidentally expose it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Tenant scope (&lt;code&gt;[McpTenantScope]&lt;/code&gt;)
&lt;/h3&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="n"&gt;McpExposed&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpTenantScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RequireTenant&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;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;TenantReportTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IReportService&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// This tool only appears when a tenant context is active&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Module scope (configuration)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Mcp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"EnabledModules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Invoicing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Scheduling"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BlobStorage"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AI agents work better with fewer, well-described tools. If your app has 30 modules, expose only the 5 that matter for the use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consuming external MCP servers
&lt;/h2&gt;

&lt;p&gt;The flow works both ways. &lt;code&gt;Granit.Mcp.Client&lt;/code&gt; connects to MCP servers exposed by other services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Mcp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Client"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Connections"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"erp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://erp.internal/mcp"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"analyzer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"Transport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"Command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"Arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"data_analyzer_mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&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;IMcpClientFactory&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sp&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;IMcpClientFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&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;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"erp"&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="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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&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="n"&gt;cancellationToken&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Granit.AI.Mcp&lt;/code&gt; bridges these into AI workspaces — external MCP tools become &lt;code&gt;AITool&lt;/code&gt; instances in your &lt;code&gt;IChatClient&lt;/code&gt; pipeline. A &lt;code&gt;SamplingGuard&lt;/code&gt; prevents external servers from abusing your LLM budget (disabled by default, rate-limited when enabled).&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect your favorite MCP client
&lt;/h2&gt;

&lt;p&gt;Once deployed, connect from any MCP client:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Desktop&lt;/strong&gt; (&lt;code&gt;claude_desktop_config.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"my-app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://localhost:5001/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer &amp;lt;jwt&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;VS Code&lt;/strong&gt; (&lt;code&gt;.vscode/mcp.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"my-app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://localhost:5001/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer &amp;lt;jwt&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;4 packages, 31 tests, zero circular dependencies:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Granit.Mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-discovery, sanitization pipeline, OpenTelemetry metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Granit.Mcp.Server&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP transport, &lt;code&gt;[Authorize]&lt;/code&gt; integration, RBAC permissions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Granit.Mcp.Client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Named factory for external MCP servers (HTTP + stdio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Granit.AI.Mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bridge MCP tools into IChatClient pipelines, sampling guard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything integrates into the SDK's native filter pipeline (&lt;code&gt;AddCallToolFilter&lt;/code&gt;, &lt;code&gt;AddListToolsFilter&lt;/code&gt;). No wrapper abstractions — Granit adds value only where the SDK has no opinion: authorization, sanitization, multi-tenancy, observability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP lets AI agents discover and invoke your tools&lt;/strong&gt; — no custom integrations per agent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The C# SDK handles the protocol&lt;/strong&gt; — you declare tools as annotated methods&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production needs more&lt;/strong&gt;: auth, PII redaction, tenant isolation, error sanitization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard &lt;code&gt;[Authorize]&lt;/code&gt;&lt;/strong&gt; works on tool methods — no custom auth attributes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[McpRedact]&lt;/code&gt; with Omit&lt;/strong&gt; protects PII without wasting LLM tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opt-in discovery&lt;/strong&gt; prevents accidental exposure of internal services&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;a href="https://granit-fx.dev" rel="noopener noreferrer"&gt;Granit&lt;/a&gt; is an open-source modular .NET 10 framework (Apache-2.0) with 200 packages covering persistence, auth, messaging, GDPR, multi-tenancy, and now MCP.&lt;/p&gt;

&lt;p&gt;Full MCP documentation: &lt;a href="https://granit-fx.dev/dotnet/mcp/" rel="noopener noreferrer"&gt;granit-fx.dev/dotnet/mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source code: [github.com/granit-fx/granit-dotnet](&lt;a href="https://github.com/granit-fx/granit-dotnet" rel="noopener noreferrer"&gt;https://github.com/granit-fx/granit-dotnet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
