<?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: Matt Anderson</title>
    <description>The latest articles on DEV Community by Matt Anderson (@matt_anderson_6c6857dba01).</description>
    <link>https://dev.to/matt_anderson_6c6857dba01</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%2F2229475%2F72aa57fb-ffea-4aac-9733-09a55cd95807.jpg</url>
      <title>DEV Community: Matt Anderson</title>
      <link>https://dev.to/matt_anderson_6c6857dba01</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/matt_anderson_6c6857dba01"/>
    <language>en</language>
    <item>
      <title>Vibe Coding Is Not AI-Assisted Development (And Conflating Them Is Hurting Us Both)</title>
      <dc:creator>Matt Anderson</dc:creator>
      <pubDate>Sun, 22 Mar 2026 14:04:29 +0000</pubDate>
      <link>https://dev.to/matt_anderson_6c6857dba01/vibe-coding-is-not-ai-assisted-development-and-conflating-them-is-hurting-us-both-n2i</link>
      <guid>https://dev.to/matt_anderson_6c6857dba01/vibe-coding-is-not-ai-assisted-development-and-conflating-them-is-hurting-us-both-n2i</guid>
      <description>&lt;p&gt;There's a term doing the rounds right now that makes me twitch every time I see it used interchangeably with something I actually care about: &lt;em&gt;vibe coding&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I'm not here to gatekeep. Language evolves, terms get stretched, and honestly, if vibe coding gets more people writing software, that's probably net positive for the world. But I &lt;em&gt;am&lt;/em&gt; here to argue that conflating vibe coding with AI-assisted development is quietly doing damage — to how teams evaluate tooling, to how organisations set expectations, and to how we as engineers think about our own craft.&lt;/p&gt;

&lt;p&gt;These are not the same thing. They are not even on the same spectrum.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Vibe Coding Actually Is
&lt;/h2&gt;

&lt;p&gt;Karpathy coined the term earlier this year, and to his credit, he was honest about what it meant. You describe what you want, you accept what the model gives you, you don't particularly read it, and you move on. You're surfing a wave of plausible output. The &lt;em&gt;vibe&lt;/em&gt; is the product.&lt;/p&gt;

&lt;p&gt;This is a genuinely interesting mode of working. For prototyping, for throwaway scripts, for people who would never have written code at all — it unlocks things. I have zero contempt for it.&lt;/p&gt;

&lt;p&gt;But notice what it &lt;em&gt;isn't&lt;/em&gt;: it isn't a developer using AI to do their job better. It's a person using AI &lt;em&gt;instead of&lt;/em&gt; developing. The distinction sounds pedantic until you ask the obvious follow-up question: who is responsible for what ships?&lt;/p&gt;

&lt;p&gt;In vibe coding, the answer is murky by design. You're not making engineering decisions. You're making prompting decisions, which is a different skill with a different risk profile.&lt;/p&gt;




&lt;h2&gt;
  
  
  What AI-Assisted Development Actually Is
&lt;/h2&gt;

&lt;p&gt;AI-assisted development is what happens when a working software engineer uses AI tooling to operate at a higher level of abstraction without surrendering ownership of the output.&lt;/p&gt;

&lt;p&gt;You're still reading the code. You're still reasoning about the architecture. You're still the person who has to answer for the system's behaviour at 2am when it pages you. The AI is a force multiplier on your existing competence — not a replacement for it.&lt;/p&gt;

&lt;p&gt;Think about what this looks like in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're designing an integration layer. You sketch the contract, you have the model draft the implementation, you read it critically, you push back on the bits that smell wrong, and you ship something you could rewrite from scratch if you had to.&lt;/li&gt;
&lt;li&gt;You're writing tests for a complex interaction. You describe the scenario in plain language, the model produces a test skeleton, you recognise that the assertion is subtly wrong because you understand the domain, and you fix it.&lt;/li&gt;
&lt;li&gt;You're reviewing a pull request. You use AI to summarise the diff and flag patterns, but you make the actual decision about whether the change is safe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each of these cases, the AI is doing work &lt;em&gt;inside&lt;/em&gt; your engineering process, not &lt;em&gt;replacing&lt;/em&gt; it. You are still the engineer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Conflation Is Harmful
&lt;/h2&gt;

&lt;p&gt;When organisations can't tell these two things apart, predictable things start to go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expectation misalignment.&lt;/strong&gt; A team that's genuinely doing AI-assisted development — thinking hard, reviewing carefully, building maintainable systems — gets compared unfavourably to a team vibe-coding their way through a sprint at four times the velocity. Until the first production incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hiring and skills atrophy.&lt;/strong&gt; If vibe coding and AI-assisted development are the same thing, then foundational engineering knowledge stops mattering. Why understand how a database index works if the model can just write the query? This is a dangerous conclusion. The model's query is only as good as the engineer's ability to recognise a bad one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tooling evaluation gets muddled.&lt;/strong&gt; The tools that make vibe coding productive (fast generation, high acceptance rate, minimal friction) are not the same tools that make AI-assisted development productive. A serious engineering team needs tools with good diff views, reliable context handling, testability, and tight feedback loops. Choosing tooling based on vibe-coding benchmarks is like choosing a scalpel based on how well it slices bread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It flattens the skill curve.&lt;/strong&gt; Vibe coding is low-floor, low-ceiling. AI-assisted development is low-floor, &lt;em&gt;high&lt;/em&gt;-ceiling. When we treat them as equivalent, we lose the incentive to climb.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Ownership Question
&lt;/h2&gt;

&lt;p&gt;Here's the sharpest way I know to draw the line: &lt;strong&gt;who owns the output?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In vibe coding, the ownership is diffuse. If it breaks, you shrug and reprompt. This is fine when the stakes are low. It's a problem when the stakes aren't.&lt;/p&gt;

&lt;p&gt;In AI-assisted development, the engineer owns the output. Fully. The fact that an LLM drafted the function doesn't change that. You reviewed it. You committed it. You deployed it. It's yours.&lt;/p&gt;

&lt;p&gt;This isn't a philosophical point — it has practical consequences. Owning the output means you have to be able to reason about it. Which means you have to understand it. Which means the model's draft is the start of a conversation, not the end of one.&lt;/p&gt;

&lt;p&gt;That's a fundamentally different relationship with the tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Note on Ego
&lt;/h2&gt;

&lt;p&gt;I want to be careful here, because there's a version of this argument that slides into elitism. "Real developers don't vibe code" is not what I'm saying. Plenty of vibe coding happens in professional contexts and produces genuinely useful things.&lt;/p&gt;

&lt;p&gt;What I'm pushing back against is the industry narrative that AI has "democratised" software development in a way that makes the craft of engineering less relevant. It hasn't. It's lowered the floor, which is great. It has not lowered the ceiling, and it has not changed what's required to operate near it.&lt;/p&gt;

&lt;p&gt;The engineers I respect most right now are the ones who've leaned into AI tooling hardest &lt;em&gt;and&lt;/em&gt; maintained the strongest grip on what's actually happening in their systems. They're faster, they're more exploratory, they try more things. But they're still engineers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where This Leaves Us
&lt;/h2&gt;

&lt;p&gt;If you're vibe coding, do it consciously. Know what you're trading. Keep it in the right contexts. Don't confuse velocity for quality.&lt;/p&gt;

&lt;p&gt;If you're doing AI-assisted development, own the distinction. Push back when someone assumes you're just typing less. Explain why the review step isn't optional. Make the case for what you're actually doing.&lt;/p&gt;

&lt;p&gt;And if you're in a position to set team or organisational standards — please, draw the line clearly. The tools available to us right now are extraordinary. But they reward engineers who understand them more than they reward engineers who simply use them.&lt;/p&gt;

&lt;p&gt;The vibe is not the product. The product is the product. And someone has to be responsible for it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on test automation tooling — frameworks, MCP servers, and the infrastructure that makes AI-assisted development actually testable. If you're thinking seriously about how AI fits into a professional engineering workflow, I'd love to hear how you're drawing these lines in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>discuss</category>
    </item>
    <item>
      <title>ASP.NET Core Devs: The MCP Spec Has Four Primitives. You've Only Been Using One.</title>
      <dc:creator>Matt Anderson</dc:creator>
      <pubDate>Thu, 19 Mar 2026 21:07:15 +0000</pubDate>
      <link>https://dev.to/matt_anderson_6c6857dba01/aspnet-core-devs-the-mcp-spec-has-four-primitives-youve-only-been-using-one-4l6e</link>
      <guid>https://dev.to/matt_anderson_6c6857dba01/aspnet-core-devs-the-mcp-spec-has-four-primitives-youve-only-been-using-one-4l6e</guid>
      <description>&lt;p&gt;When I shipped the first public version of ZeroMCP, it did one thing: expose controller actions as MCP tools via a single attribute and two lines of setup. That was enough to prove the model worked — in-process dispatch through your real ASP.NET Core pipeline, no second service, no duplicated logic.&lt;/p&gt;

&lt;p&gt;1.5 fills out the rest of the MCP specification surface. Here's what's new.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources and Resource Templates
&lt;/h2&gt;

&lt;p&gt;MCP distinguishes between tools (things clients can invoke) and resources (data clients can read at well-known URIs). ZeroMCP now supports both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"system/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;McpResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"system://status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"system_status"&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;"Current system status."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;MimeType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"application/json"&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;IActionResult&lt;/span&gt; &lt;span class="nf"&gt;GetSystemStatus&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;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resource templates extend this to parameterised URIs using RFC 6570 patterns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"products/{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;McpTemplate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"catalog://products/{id}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"product_resource"&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;"Returns a product by ID."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;MimeType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"application/json"&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;IActionResult&lt;/span&gt; &lt;span class="nf"&gt;GetProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&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;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clients discover resources via &lt;code&gt;resources/list&lt;/code&gt; and templates via &lt;code&gt;resources/templates/list&lt;/code&gt;, then fetch content with &lt;code&gt;resources/read&lt;/code&gt;. The dispatch model is identical to tools — the same synthetic &lt;code&gt;HttpContext&lt;/code&gt;, the same pipeline, the same DI scope.&lt;/p&gt;

&lt;p&gt;Both controller attributes and minimal API equivalents are supported:&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;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/status"&lt;/span&gt;&lt;span class="p"&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="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="k"&gt;new&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="s"&gt;"ok"&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"system://status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"system_status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Current system status."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Prompts
&lt;/h2&gt;

&lt;p&gt;Prompts are reusable, parameterised prompt templates. Clients call &lt;code&gt;prompts/list&lt;/code&gt; to discover them, then &lt;code&gt;prompts/get&lt;/code&gt; with arguments to render the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search_products"&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;"Search products by keyword and optional category."&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;IActionResult&lt;/span&gt; &lt;span class="nf"&gt;SearchProducts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;Required&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;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;category&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Search for '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' in category '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"all"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful when you want to give LLM clients a structured way to construct prompts from your domain data, rather than hardcoding prompt text in the client.&lt;/p&gt;




&lt;h2&gt;
  
  
  Streaming via IAsyncEnumerable
&lt;/h2&gt;

&lt;p&gt;Controller actions that return &lt;code&gt;IAsyncEnumerable&amp;lt;T&amp;gt;&lt;/code&gt; are now automatically detected as streaming tools at registration time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"stream"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"stream_orders"&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;"Streams all orders."&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;IAsyncEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;StreamOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EnumeratorCancellation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;ThrowIfCancellationRequested&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;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;250&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;yield&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&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;Over Streamable HTTP, each item is sent as a Server-Sent Event. Over stdio, each chunk is a separate JSON-RPC response line. Streaming tools appear with &lt;code&gt;"streaming": true&lt;/code&gt; in &lt;code&gt;tools/list&lt;/code&gt;. A &lt;code&gt;MaxStreamingItems&lt;/code&gt; safety cap (default 10,000) prevents runaway enumeration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Notifications and Subscriptions
&lt;/h2&gt;

&lt;p&gt;Two new opt-in capabilities handle the MCP notification model.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EnableListChangedNotifications&lt;/code&gt; causes ZeroMCP to advertise list change support during handshake and push &lt;code&gt;notifications/tools/list_changed&lt;/code&gt;, &lt;code&gt;notifications/resources/list_changed&lt;/code&gt;, and &lt;code&gt;notifications/prompts/list_changed&lt;/code&gt; to connected SSE clients when you call the corresponding notify methods.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EnableResourceSubscriptions&lt;/code&gt; lets clients subscribe to specific resource URIs and receive targeted &lt;code&gt;notifications/resources/updated&lt;/code&gt; events when content changes. The trigger side is yours to implement — call &lt;code&gt;NotifyResourceUpdatedAsync(uri)&lt;/code&gt; from a controller, background service, or message handler:&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;await&lt;/span&gt; &lt;span class="n"&gt;_notificationService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotifyResourceUpdatedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders://order/42"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Tool Versioning
&lt;/h2&gt;

&lt;p&gt;Tools can now be versioned, which lets you expose multiple versions of the same tool surface at distinct MCP endpoints.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_order"&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;"v2 of get_order."&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="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without versioning, only &lt;code&gt;/mcp&lt;/code&gt; is registered. With versioning, you get &lt;code&gt;/mcp/v1&lt;/code&gt;, &lt;code&gt;/mcp/v2&lt;/code&gt;, etc., with the unversioned &lt;code&gt;/mcp&lt;/code&gt; resolving to the highest version. The Inspector UI gains a version selector and version badges when multiple versions exist.&lt;/p&gt;

&lt;p&gt;This is primarily aimed at teams running phased client migrations where you can't update all consumers simultaneously.&lt;/p&gt;




&lt;h2&gt;
  
  
  Inspector UI
&lt;/h2&gt;

&lt;p&gt;The Inspector was introduced in an earlier release as a JSON endpoint (&lt;code&gt;GET /mcp/tools&lt;/code&gt;). 1.5 adds a browser-based invocation UI at &lt;code&gt;GET /mcp/ui&lt;/code&gt; — think Swagger UI, but for your MCP tool surface.&lt;/p&gt;

&lt;p&gt;From the UI you can browse tools, view their JSON Schema inputs, and invoke &lt;code&gt;tools/call&lt;/code&gt; with editable arguments directly in the browser. Streaming tools render results progressively and show a badge indicating their type.&lt;/p&gt;

&lt;p&gt;Both endpoints are enabled by default and should be disabled in production:&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnableToolInspector&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;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="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnableToolInspectorUI&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  stdio Transport
&lt;/h2&gt;

&lt;p&gt;The HTTP transport is still the primary path, but 1.5 ships first-class stdio support for local and desktop MCP clients that spawn your process directly:&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;if&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="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"--mcp-stdio"&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;RunMcpStdioAsync&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Claude Desktop configuration looks like this:&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-api"&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;"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;"dotnet"&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="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MyApi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--mcp-stdio"&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;Everything else — discovery, dispatch, auth forwarding, governance — works identically across transports.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Hasn't Changed
&lt;/h2&gt;

&lt;p&gt;The core model is unchanged. &lt;code&gt;[Mcp]&lt;/code&gt; still goes on individual controller actions or minimal API endpoints. Dispatch still creates a fresh DI scope and a synthetic &lt;code&gt;HttpContext&lt;/code&gt; routed through your real pipeline. Auth, validation, and filters still run as normal. The upgrade path from earlier versions is additive — existing tool configurations require no changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"ZeroMCP"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"1.*"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repository, wiki, and examples: &lt;a href="https://github.com/ZeroMcp/ZeroMCP.net" rel="noopener noreferrer"&gt;github.com/ZeroMcp/ZeroMCP.net&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aspnet</category>
      <category>mcp</category>
      <category>dotnet</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Testing Your MCP Server Like You Mean It</title>
      <dc:creator>Matt Anderson</dc:creator>
      <pubDate>Wed, 11 Mar 2026 11:46:35 +0000</pubDate>
      <link>https://dev.to/matt_anderson_6c6857dba01/testing-your-mcp-server-like-you-mean-it-3lom</link>
      <guid>https://dev.to/matt_anderson_6c6857dba01/testing-your-mcp-server-like-you-mean-it-3lom</guid>
      <description>&lt;p&gt;&lt;em&gt;The final part of the ZeroMcp series. Part one covered exposing your ASP.NET Core API as an MCP server. Part two covered everything that's grown since. Part three covered ZeroMcp.Relay for any OpenAPI-backed API. This one closes the loop on the question you should be asking by now: how do I test all of this?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I should probably have led with this from the start: by trade, I'm a test automation architect who specialises in tooling. That's not incidental context — it's why ZeroMcp exists the way it does, a way to provide consistent endpoints without the "human error" factor.&lt;/p&gt;

&lt;p&gt;When you spend a career building test infrastructure, a particular instinct gets hard-wired: you don't consider something &lt;em&gt;done&lt;/em&gt; until you can verify it works and detect when it stops working. Every tool in the ZeroMcp ecosystem was built with that instinct operating in the background. The &lt;code&gt;[Mcp]&lt;/code&gt; attribute runs your real pipeline so your real tests still pass. The Tool Inspector UI lets you execute tools and inspect results. Relay validates specs before it starts serving tools. But none of that answered the question that actually mattered to me: how do you write a test that proves your MCP server behaves correctly?&lt;/p&gt;

&lt;p&gt;There's a version of MCP development where you spin up your server, open Claude Desktop, and manually chat at it to see if the tools work. That gets you pretty far early on. It doesn't get you a CI pipeline, regression protection, or any confidence that a schema change didn't silently break tool outputs.&lt;/p&gt;

&lt;p&gt;TestKit is where the circle closes: &lt;strong&gt;ZeroMcp.TestKit&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: Two Repos, One Design Decision
&lt;/h2&gt;

&lt;p&gt;TestKit is split into two repositories, and that split is intentional and worth understanding before anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/ZeroMcp/ZeroMcp.TeskKitEngine" rel="noopener noreferrer"&gt;ZeroMcp.TeskKitEngine&lt;/a&gt;&lt;/strong&gt; is a standalone Rust binary called &lt;code&gt;mcptest&lt;/code&gt;. It accepts JSON test definitions, connects to any MCP server, runs the tests, and produces structured JSON results. It knows nothing about .NET, xUnit, or any specific language. It validates MCP protocol correctness, JSON Schema compliance, determinism, error paths, and tool metadata — for &lt;em&gt;any&lt;/em&gt; MCP server, regardless of what it's built with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/ZeroMcp/ZeroMcp.TestKit.dotnet" rel="noopener noreferrer"&gt;ZeroMcp.TestKit.dotnet&lt;/a&gt;&lt;/strong&gt; is a fluent C# DSL that wraps the engine. You write tests in idiomatic .NET, the DSL serializes them to JSON, shells out to &lt;code&gt;mcptest&lt;/code&gt;, and parses the results back. xUnit integration (&lt;code&gt;[McpFact]&lt;/code&gt;, &lt;code&gt;[McpTheory]&lt;/code&gt;, &lt;code&gt;McpAssert&lt;/code&gt;) makes tests show up in Visual Studio Test Explorer just like any other test project.&lt;/p&gt;

&lt;p&gt;The implication: &lt;code&gt;mcptest&lt;/code&gt; is the correctness oracle. The .NET DSL is a convenient way to talk to it. If you're building MCP servers in Python or Go, the engine works for you too — the Rust binary is the portable part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your xUnit test project
        │
        ▼
ZeroMcp.TestKit (.NET DSL)
        │  serializes to JSON, shells out
        ▼
mcptest (Rust binary)
        │  speaks MCP protocol
        ▼
Your MCP server (any language, any transport)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&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;ZeroMcp.TestKit&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;McpTest&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:8000/mcp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"hello"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectSchemaMatch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectDeterministic&lt;/span&gt;&lt;span class="p"&gt;()&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's a complete test. It connects to your MCP server, calls the &lt;code&gt;search&lt;/code&gt; tool with &lt;code&gt;{ "query": "hello" }&lt;/code&gt;, validates that the response conforms to the tool's declared JSON Schema, and asserts that calling it again produces the same result. If either check fails, &lt;code&gt;RunAsync()&lt;/code&gt; throws &lt;code&gt;McpTestException&lt;/code&gt; with the details.&lt;/p&gt;

&lt;p&gt;Install:&lt;/p&gt;

&lt;p&gt;Add ZeroMcp.TestKit and ZeroMcp.TestKit.Xunit via Nuget&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;mcptest&lt;/code&gt; binary is resolved automatically — first from &lt;code&gt;MCPTEST_PATH&lt;/code&gt;, then from the bin directory, then from NuGet native assets (&lt;code&gt;runtimes/{rid}/native/mcptest&lt;/code&gt;), then from your system &lt;code&gt;PATH&lt;/code&gt;. Most of the time you don't need to think about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fluent API
&lt;/h2&gt;

&lt;p&gt;The builder is designed to read like a spec of what you expect:&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;await&lt;/span&gt; &lt;span class="n"&gt;McpTest&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:8000/mcp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithDeterminismRuns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateProtocol&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateMetadata&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithAutoErrorTests&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"hello"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectSchemaMatch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectDeterministic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithIgnorePaths&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.result.timestamp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"create_order"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;customerName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Widget"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectSchemaMatch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_order"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;999&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectError&lt;/span&gt;&lt;span class="p"&gt;()&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;A few things worth pulling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.ValidateProtocol()&lt;/code&gt;&lt;/strong&gt; — checks the MCP handshake, session lifecycle, and JSON-RPC frame structure. This catches implementation bugs that work fine when an LLM is calling your tools but would fail with a strict client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.ValidateMetadata()&lt;/code&gt;&lt;/strong&gt; — checks that every tool has a name, description, and a valid &lt;code&gt;inputSchema&lt;/code&gt;. This is the check that enforces the contract the LLM depends on. Missing descriptions and malformed schemas are silent failures from the LLM's perspective; it just works less well. This makes them loud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.WithAutoErrorTests()&lt;/code&gt;&lt;/strong&gt; — automatically generates two additional tests: calling an unknown tool name, and calling a real tool with malformed parameters. Both should return proper JSON-RPC error responses. Surprisingly many MCP server implementations fail one or both of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.ExpectDeterministic()&lt;/code&gt; with &lt;code&gt;.WithIgnorePaths(...)&lt;/code&gt;&lt;/strong&gt; — calls the tool multiple times (default 3, configurable with &lt;code&gt;.WithDeterminismRuns()&lt;/code&gt;) and compares results. &lt;code&gt;WithIgnorePaths&lt;/code&gt; takes JSONPath expressions for fields that are legitimately non-deterministic, like timestamps or request IDs. Everything else must match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.ExpectError()&lt;/code&gt; / &lt;code&gt;.ExpectErrorCode(long)&lt;/code&gt;&lt;/strong&gt; — for testing your error paths explicitly. If &lt;code&gt;get_order&lt;/code&gt; with a non-existent ID should return an error, test that it does.&lt;/p&gt;




&lt;h2&gt;
  
  
  xUnit Integration
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;ZeroMcp.TestKit.Xunit&lt;/code&gt; and your MCP tests sit alongside your existing test suite:&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;ZeroMcp.TestKit&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;ZeroMcp.TestKit.Xunit&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;OrdersToolTests&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpFact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DisplayName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"get_order returns valid schema"&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;GetOrderSchemaValid&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;McpTest&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5000/mcp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_order"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectSchemaMatch&lt;/span&gt;&lt;span class="p"&gt;()&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;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpFact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DisplayName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"get_order returns error for unknown id"&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;GetOrderNotFound&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;McpTest&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5000/mcp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_order"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;99999&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectError&lt;/span&gt;&lt;span class="p"&gt;()&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;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpFact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DisplayName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"create_order is deterministic for same input"&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;CreateOrderDeterministic&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;McpTest&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5000/mcp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithDeterminismRuns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"create_order"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;customerName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Widget"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectSchemaMatch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectDeterministic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithIgnorePaths&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.result.orderId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"$.result.createdAt"&lt;/span&gt;&lt;span class="p"&gt;)&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;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;code&gt;[McpFact]&lt;/code&gt; is xUnit's &lt;code&gt;[Fact]&lt;/code&gt; with &lt;code&gt;DisplayName&lt;/code&gt; support tuned for MCP test naming. Tests appear in Test Explorer, run in CI with &lt;code&gt;dotnet test&lt;/code&gt;, and fail with clear messages when something breaks.&lt;/p&gt;

&lt;p&gt;For cases where you want assertion-style checks rather than throw-on-failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&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;McpTest&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5000/mcp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"hello"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;ExpectSchemaMatch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunWithoutThrowAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;McpAssert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Passed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;McpAssert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToolPassed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;McpAssert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SchemaValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;McpAssert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Deterministic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What the Engine Actually Validates
&lt;/h2&gt;

&lt;p&gt;It's worth being explicit about what &lt;code&gt;mcptest&lt;/code&gt; checks, because some of these aren't things you'd think to test manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protocol validation&lt;/strong&gt; goes beyond "does it respond to tool calls." It checks the &lt;code&gt;initialize&lt;/code&gt; / &lt;code&gt;initialized&lt;/code&gt; handshake sequence, that JSON-RPC frames have correct &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;method&lt;/code&gt;, and &lt;code&gt;jsonrpc&lt;/code&gt; fields, and that error responses use the right error code structure. An MCP server that works with Claude but fails with a strict client — or a future version of the protocol — will show up here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema validation&lt;/strong&gt; checks that tool &lt;em&gt;outputs&lt;/em&gt; conform to the tool's declared &lt;code&gt;inputSchema&lt;/code&gt;. This is the contract your LLM depends on to understand what a tool returns. Schema drift — where the actual response shape diverges from what the tool advertises — is a silent regression. Your LLM starts hallucinating about what fields exist. This test catches that drift at the schema level before it manifests as confusing model behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Determinism validation&lt;/strong&gt; is about the LLM's ability to reason reliably about tool results. If calling &lt;code&gt;get_order&lt;/code&gt; with the same ID returns different shapes on different calls, the model can't build a coherent mental model of what the tool does. This matters most for read-heavy tools that query live data — you exclude the volatile fields with &lt;code&gt;WithIgnorePaths&lt;/code&gt; and validate that the structure itself is stable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Baseline diffing&lt;/strong&gt; — available via the &lt;code&gt;mcptest diff&lt;/code&gt; command — lets you capture a known-good response and compare future runs against it. This is the regression detection story: after a deployment, run &lt;code&gt;mcptest diff&lt;/code&gt; against your baselines and get a clear diff of anything that changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recording and Replay
&lt;/h2&gt;

&lt;p&gt;One practical feature for CI: session recording.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mcptest run &lt;span class="nt"&gt;--file&lt;/span&gt; tests.json &lt;span class="nt"&gt;--server&lt;/span&gt; http://localhost:8000/mcp &lt;span class="nt"&gt;--record&lt;/span&gt; session.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then replay offline, without a running server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mcptest run &lt;span class="nt"&gt;--file&lt;/span&gt; tests.json &lt;span class="nt"&gt;--replay&lt;/span&gt; session.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters for a few scenarios. If your MCP server connects to external APIs (via ZeroMcp.Relay, for example), you don't want CI making real calls to Stripe on every push. Record a session against a real server once, commit &lt;code&gt;session.json&lt;/code&gt;, replay it in CI. You get the same correctness checks without the external dependency or the cost.&lt;/p&gt;

&lt;p&gt;It's also useful for debugging: record a session where something went wrong, then replay it as many times as you need to understand the failure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scaffolding Tests
&lt;/h2&gt;

&lt;p&gt;If you're adding TestKit to an existing MCP server, you don't have to write test definitions from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate stubs for all tools&lt;/span&gt;
mcptest generate &lt;span class="nt"&gt;--scaffold&lt;/span&gt; &lt;span class="nt"&gt;--server&lt;/span&gt; http://localhost:8000/mcp &lt;span class="nt"&gt;--out&lt;/span&gt; tests.json

&lt;span class="c"&gt;# Generate known-good baselines from real responses&lt;/span&gt;
mcptest generate &lt;span class="nt"&gt;--known-good&lt;/span&gt; &lt;span class="nt"&gt;--server&lt;/span&gt; http://localhost:8000/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--params&lt;/span&gt; search:&lt;span class="s1"&gt;'{"query":"hello"}'&lt;/span&gt; &lt;span class="nt"&gt;--out&lt;/span&gt; baseline.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--scaffold&lt;/code&gt; gives you a test definition with every tool stubbed out and placeholders for params. Fill in real values and you have a starting point. &lt;code&gt;--known-good&lt;/code&gt; actually calls the tools and captures the responses as approved baselines — commit those and you have regression detection from day one.&lt;/p&gt;




&lt;h2&gt;
  
  
  ZeroMcp Integration
&lt;/h2&gt;

&lt;p&gt;If you're using ZeroMcp (the library) for your own ASP.NET Core API, the natural pattern is to start the test host in-process and point TestKit at 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;McpToolIntegrationTests&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAsyncLifetime&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;WebApplication&lt;/span&gt; &lt;span class="n"&gt;_app&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="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;_serverUrl&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;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;InitializeAsync&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;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;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;AddControllers&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;AddEndpointsApiExplorer&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;AddZeroMcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServerName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Test"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServerVersion&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="p"&gt;});&lt;/span&gt;
        &lt;span class="c1"&gt;// register your real services here&lt;/span&gt;

        &lt;span class="n"&gt;_app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;_app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapControllers&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;MapZeroMcp&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;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;GetFreePort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;_serverUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"http://localhost:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;port&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="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;StartAsync&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;McpFact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DisplayName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"get_order tool schema is valid"&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;GetOrderSchemaValid&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;McpTest&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_serverUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/mcp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateMetadata&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_order"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExpectSchemaMatch&lt;/span&gt;&lt;span class="p"&gt;()&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;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;DisposeAsync&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;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;StopAsync&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;Your real DI container, your real services, your real pipeline — TestKit drives it through the MCP protocol the same way Claude would. The difference is you control the assertions.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Thing This Solves
&lt;/h2&gt;

&lt;p&gt;The honest version of MCP development without a test harness looks like this: you make a change, restart your server, open Claude, describe what you want it to do, and see if the tool call works. If it doesn't, you're reading logs and guessing. If it does, you ship. Then something breaks in production because a response shape changed and the model started interpreting a field that no longer exists.&lt;/p&gt;

&lt;p&gt;TestKit is the thing that makes MCP tools first-class citizens of your existing software engineering practices. Schema validation catches drift before deployment. Determinism checks catch flakiness before the LLM notices it. Protocol validation catches spec violations before a client upgrade exposes them. Baseline diffing catches regressions in CI before they reach users.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's the same category of testing you already do for your HTTP endpoints — just expressed in terms of what an MCP client expects rather than what an HTTP client expects.&lt;/p&gt;

&lt;p&gt;And honestly, for me, it's also the inevitable destination. You don't spend a career building test infrastructure without developing a strong opinion that tooling without a test harness is a prototype, not a product. Every design decision across ZeroMcp — running the real pipeline, forwarding real headers, enforcing real auth — was made so that the things you'd want to test would be worth testing. TestKit is what makes that true. It's not the last piece bolted on; it's the piece the rest of the ecosystem was always pointing toward.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engine:&lt;/strong&gt; &lt;a href="https://github.com/ZeroMcp/ZeroMcp.TeskKitEngine" rel="noopener noreferrer"&gt;github.com/ZeroMcp/ZeroMcp.TeskKitEngine&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NuGet (core):&lt;/strong&gt; &lt;code&gt;dotnet add package ZeroMcp.TestKit&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NuGet (xUnit):&lt;/strong&gt; &lt;code&gt;dotnet add package ZeroMcp.TestKit.Xunit&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;.NET DSL:&lt;/strong&gt; &lt;a href="https://github.com/ZeroMcp/ZeroMcp.TestKit.dotnet" rel="noopener noreferrer"&gt;github.com/ZeroMcp/ZeroMcp.TestKit.dotnet&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both repos are early, and I am looking at putting together DSL packages for Python, Rust and Node. Issues and PRs welcome — the MCP testing ecosystem for .NET is a blank slate and there's a lot of ground to cover.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Drop me a comment below if you do try it, and let me know how you get on&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>testing</category>
      <category>ai</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Turn Any OpenAPI Spec into an MCP Server with One Command (or how I used other people's APIs as a personal MCP)</title>
      <dc:creator>Matt Anderson</dc:creator>
      <pubDate>Mon, 09 Mar 2026 23:00:23 +0000</pubDate>
      <link>https://dev.to/matt_anderson_6c6857dba01/turn-any-openapi-spec-into-an-mcp-server-with-one-command-introducing-zeromcprelay-24ck</link>
      <guid>https://dev.to/matt_anderson_6c6857dba01/turn-any-openapi-spec-into-an-mcp-server-with-one-command-introducing-zeromcprelay-24ck</guid>
      <description>&lt;p&gt;&lt;em&gt;Part three of the ZeroMcp series. &lt;a href="https://dev.to/matt_anderson_6c6857dba01/your-existing-aspnet-core-api-is-already-an-mcp-server-you-just-dont-know-it-yet-ojb"&gt;Part one&lt;/a&gt; covered exposing your own ASP.NET Core API as an MCP server. Part two covered everything that's grown since then. This one is about a different problem entirely.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;ZeroMcp started with a specific premise: you have an ASP.NET Core API, and you want AI clients to use it without rewriting anything. Slap &lt;code&gt;[Mcp]&lt;/code&gt; on your controller actions, add two lines of setup, done.&lt;/p&gt;

&lt;p&gt;But what about APIs you &lt;em&gt;don't&lt;/em&gt; own?&lt;/p&gt;

&lt;p&gt;What about Stripe, GitHub, your internal CRM, a third-party logistics API, a partner's REST service? You have credentials, you have documentation, maybe you have an OpenAPI spec URL — but you don't have source code to decorate with attributes.&lt;/p&gt;

&lt;p&gt;That's the problem ZeroMcp.Relay solves.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Is
&lt;/h2&gt;

&lt;p&gt;ZeroMcp.Relay is a standalone &lt;code&gt;dotnet tool&lt;/code&gt;. You install it globally, point it at one or more OpenAPI spec URLs, configure authentication, and it immediately starts serving those APIs as MCP tools — no code, no compilation, no framework knowledge required.&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;-g&lt;/span&gt; ZeroMcp.Relay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command is &lt;code&gt;mcprelay&lt;/code&gt;. The concept is simple: ingest a spec, generate tools, relay calls.&lt;/p&gt;

&lt;p&gt;The two tools in the ZeroMcp ecosystem cover complementary territory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ZeroMcp          ← your own ASP.NET Core APIs (in-process, NuGet package)
ZeroMcp.Relay    ← any API with an OpenAPI spec (outbound HTTP relay, dotnet tool)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They speak the same MCP protocol and can be used side-by-side.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Start
&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;# 1. Scaffold a config file&lt;/span&gt;
mcprelay configure init

&lt;span class="c"&gt;# 2. Add an API&lt;/span&gt;
mcprelay configure add &lt;span class="nt"&gt;-n&lt;/span&gt; petstore &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-s&lt;/span&gt; https://petstore3.swagger.io/api/v3/openapi.json

&lt;span class="c"&gt;# 3. Run with the visual UI for setup&lt;/span&gt;
mcprelay run &lt;span class="nt"&gt;--enable-ui&lt;/span&gt;
&lt;span class="c"&gt;# → open http://localhost:5000/ui&lt;/span&gt;

&lt;span class="c"&gt;# 4. Or run headless for production&lt;/span&gt;
mcprelay run

&lt;span class="c"&gt;# 5. Or run in stdio mode for Claude Desktop&lt;/span&gt;
mcprelay run &lt;span class="nt"&gt;--stdio&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole workflow. Three commands from nothing to a working MCP server over any documented API.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Config File
&lt;/h2&gt;

&lt;p&gt;Everything lives in &lt;code&gt;relay.config.json&lt;/code&gt;. Here's a realistic multi-API setup:&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;"$schema"&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://zeromcp.dev/schemas/relay.config.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"serverName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My API Relay"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"serverVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apis"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"source"&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://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"auth"&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;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"env:STRIPE_SECRET_KEY"&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;"exclude"&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;"test_helpers.*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"radar.*"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"crm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"source"&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://internal-crm.company.com/swagger/v1/swagger.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"crm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"auth"&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;"apikey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"header"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"X-Api-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"env:CRM_API_KEY"&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;"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;"X-Tenant-Id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"source"&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://github.com/github/rest-api-description/raw/main/descriptions/api.github.com/api.github.com.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"auth"&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;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"env:GITHUB_TOKEN"&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;Notice &lt;code&gt;env:STRIPE_SECRET_KEY&lt;/code&gt; — any credential value prefixed with &lt;code&gt;env:&lt;/code&gt; is resolved from the environment at startup. If the variable isn't set, that API is disabled with a warning rather than starting with invalid credentials. You can also load a &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mcprelay run &lt;span class="nt"&gt;--env&lt;/span&gt; .env.production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Authentication
&lt;/h2&gt;

&lt;p&gt;Relay supports the auth patterns you'll actually encounter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bearer token&lt;/strong&gt; — &lt;code&gt;Authorization: Bearer {token}&lt;/code&gt; on every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API key (header)&lt;/strong&gt; — any named header, e.g. &lt;code&gt;X-Api-Key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API key (query parameter)&lt;/strong&gt; — appended to every URL, for APIs that expect &lt;code&gt;?api_key=...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP Basic&lt;/strong&gt; — &lt;code&gt;Authorization: Basic {base64(user:pass)}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;None&lt;/strong&gt; — for public APIs or internal services with network-level auth.&lt;/p&gt;

&lt;p&gt;Per-API auth configuration means you can mix all of these in a single relay instance. Your Stripe integration uses bearer, your legacy internal API uses basic auth, your partner's API uses a query parameter — Relay handles each correctly without any cross-contamination.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Tool Names Work
&lt;/h2&gt;

&lt;p&gt;Tool names are &lt;code&gt;{prefix}_{operationId}&lt;/code&gt;, lowercased, with non-alphanumeric characters replaced by underscores:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;operationId: ChargeCreate  →  stripe_charge_create
operationId: GetCustomer   →  crm_get_customer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Operations without an &lt;code&gt;operationId&lt;/code&gt; (which is unfortunately common) get a generated name from the HTTP method and path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET  /customers/{id}  →  crm_get_customers_id
POST /orders          →  crm_post_orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prefix is the key to multi-API sanity. When an LLM sees &lt;code&gt;stripe_charge_create&lt;/code&gt; and &lt;code&gt;crm_get_customer&lt;/code&gt; and &lt;code&gt;gh_repos_list&lt;/code&gt;, it has an immediate signal about which system each tool belongs to. Duplicate prefixes cause a startup error — you can't accidentally collide two APIs' tool namespaces.&lt;/p&gt;




&lt;h2&gt;
  
  
  Include / Exclude Filtering
&lt;/h2&gt;

&lt;p&gt;Real-world OpenAPI specs are big. Stripe's spec has hundreds of operations. GitHub's has over 500. You probably don't want to expose all of them as MCP tools — both because it's overwhelming for the LLM and because some operations shouldn't be accessible from an AI client at all.&lt;/p&gt;

&lt;p&gt;Use glob patterns to control exactly what gets exposed:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"include"&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;"stripe_charge_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe_customer_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe_invoice_*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exclude"&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;"stripe_*_test_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe_radar_*"&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;code&gt;include&lt;/code&gt; (if non-empty) is a whitelist. &lt;code&gt;exclude&lt;/code&gt; then removes from whatever &lt;code&gt;include&lt;/code&gt; allows. Both empty means all operations are included.&lt;/p&gt;

&lt;p&gt;This is also where you enforce boundaries. An AI client probably shouldn't have access to your billing API's deletion endpoints, your admin reporting operations, or anything that touches test data. Exclude them at the Relay level and they don't exist as far as the LLM is concerned.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Config UI
&lt;/h2&gt;

&lt;p&gt;When you pass &lt;code&gt;--enable-ui&lt;/code&gt;, Relay serves a visual interface at &lt;code&gt;/ui&lt;/code&gt; for managing your configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mcprelay run &lt;span class="nt"&gt;--enable-ui&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI is deliberately opt-in and only available when explicitly enabled. A plain &lt;code&gt;mcprelay run&lt;/code&gt; doesn't register the endpoint at all — not even a 404. This matters for production deployments where you don't want a management interface exposed.&lt;/p&gt;

&lt;p&gt;The intended workflow is: use the UI during local setup to configure your APIs and get everything working, then run without &lt;code&gt;--enable-ui&lt;/code&gt; in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the UI gives you:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding a new API walks you through name, spec URL, auth, prefix, timeout, and include/exclude patterns. The standout feature is &lt;strong&gt;Fetch Spec&lt;/strong&gt;: before you save, you can preview the spec — title, version, operation count, and any warnings (missing operationIds, malformed schemas, unresolvable &lt;code&gt;$ref&lt;/code&gt;s). You know exactly what you're getting before it's committed to config.&lt;/p&gt;

&lt;p&gt;The tool browser lets you search and filter across all configured APIs, click into any tool to see its full input schema, and &lt;strong&gt;invoke tools directly from the UI&lt;/strong&gt; — fill in arguments, hit execute, see the raw response. This is the same "Swagger UI for MCP" experience that the Tool Inspector brings to ZeroMcp proper, now available for externally-relayed APIs too.&lt;/p&gt;

&lt;p&gt;Status indicators on the API list give you immediate visibility: green for loaded and healthy, yellow for disabled, red for spec fetch failure or missing auth credentials.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two Modes: HTTP and stdio
&lt;/h2&gt;

&lt;h3&gt;
  
  
  HTTP Server Mode
&lt;/h3&gt;

&lt;p&gt;The default. Relay starts an ASP.NET Core server and exposes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /mcp          JSON-RPC 2.0 — MCP protocol
GET  /mcp          Server info
GET  /mcp/tools    Tool list JSON
GET  /health       Per-API status and tool counts
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The health endpoint is worth highlighting:&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"degraded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apis"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"toolCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;147&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"crm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"toolCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;34&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"logistics"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Spec fetch failed"&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="nl"&gt;"totalTools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;181&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;If one spec fails to load, the others keep working. &lt;code&gt;degraded&lt;/code&gt; means some APIs are unavailable; &lt;code&gt;error&lt;/code&gt; means all of them are. You can wire this into your existing health monitoring infrastructure — it's just an HTTP endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  stdio Mode
&lt;/h3&gt;

&lt;p&gt;Pass &lt;code&gt;--stdio&lt;/code&gt; and Relay reads JSON-RPC from stdin and writes to stdout, with all logging going to stderr. This is what you use for Claude Desktop and Claude Code:&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;"relay"&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;"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;"mcprelay"&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="s2"&gt;"run"&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;"env"&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;"STRIPE_SECRET_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk_live_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"CRM_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;Or with a project-specific config:&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;"relay"&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;"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;"mcprelay"&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="s2"&gt;"run"&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="s2"&gt;"--config"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/project/relay.config.json"&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;By default, all specs are fetched and validated before Relay starts reading from stdin. If you have many APIs and startup latency is a concern, &lt;code&gt;--lazy&lt;/code&gt; defers spec fetching to the first tool call for each API.&lt;/p&gt;




&lt;h2&gt;
  
  
  CI Validation
&lt;/h2&gt;

&lt;p&gt;One addition I'm particularly happy about: &lt;code&gt;mcprelay validate&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mcprelay validate &lt;span class="nt"&gt;--strict&lt;/span&gt; &lt;span class="nt"&gt;--config&lt;/span&gt; relay.config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fetches all spec URLs, parses them, resolves all environment variable references, and reports problems — missing &lt;code&gt;operationId&lt;/code&gt;s, malformed schemas, unresolvable &lt;code&gt;$ref&lt;/code&gt;s, missing secrets. Exit code 0 on success, 1 on failure.&lt;/p&gt;

&lt;p&gt;In a GitHub Actions workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate relay config&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcprelay validate --strict --config relay.config.json&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.STRIPE_SECRET_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;CRM_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CRM_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone accidentally commits a config pointing at a dead spec URL, or forgets to add a new secret to the CI environment, the pipeline catches it before deployment. This is the kind of thing that tends to get discovered at 2am in production otherwise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Local Developer (stdio)
&lt;/h3&gt;

&lt;p&gt;The simplest path: install globally, point Claude Desktop at your local config.&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;-g&lt;/span&gt; ZeroMcp.Relay
mcprelay configure init
mcprelay run &lt;span class="nt"&gt;--enable-ui&lt;/span&gt;   &lt;span class="c"&gt;# set up your APIs visually&lt;/span&gt;
&lt;span class="c"&gt;# then add to claude_desktop_config.json with --stdio&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Team Server (HTTP)
&lt;/h3&gt;

&lt;p&gt;Run Relay as a shared HTTP server your team's MCP clients connect to. Everyone gets the same API access without managing local credentials.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; mcr.microsoft.com/dotnet/runtime:9.0&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; ZeroMcp.Relay
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PATH="$PATH:/root/.dotnet/tools"&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; relay.config.json /app/relay.config.json&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["mcprelay", "run", "--host", "0.0.0.0", "--port", "8080"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_live_... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;CRM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="se"&gt;\&lt;/span&gt;
  myrelay:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TLS termination goes in front of Relay (nginx, Caddy, Traefik — whatever you already use).&lt;/p&gt;




&lt;h2&gt;
  
  
  Filling the stdio Gap in ZeroMcp
&lt;/h2&gt;

&lt;p&gt;There's one more use case for Relay that deserves its own callout, because it's not obvious at first: &lt;strong&gt;Relay in stdio mode is also the answer for your own ZeroMcp APIs when you need stdio&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;ZeroMcp (the library) speaks Streamable HTTP only. That's intentional — it runs in-process inside your ASP.NET Core app and dispatches through your real pipeline. But it means Claude Desktop and Claude Code, which expect a stdio process, can't connect to it directly.&lt;/p&gt;

&lt;p&gt;Relay closes that gap. Point Relay at your own app's &lt;code&gt;/mcp&lt;/code&gt; endpoint — the one ZeroMcp exposes — and run Relay in stdio mode:&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-api"&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;"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;"mcprelay"&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="s2"&gt;"run"&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="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 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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-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;"source"&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://localhost:5000/mcp/tools"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"myapi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth"&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;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"env:MY_API_KEY"&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;Relay reads from ZeroMcp's tool inspector endpoint to get the spec, then proxies tool calls through to &lt;code&gt;/mcp&lt;/code&gt;. Claude Desktop gets a stdio process; your app gets normal in-process dispatch through its full pipeline. Both sides are happy.&lt;/p&gt;

&lt;p&gt;This makes the full picture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Desktop / Claude Code (stdio)
        │
        ▼
  mcprelay --stdio           ← ZeroMcp.Relay, reads JSON-RPC from stdin
        │
        │  HTTP  POST /mcp
        ▼
  Your ASP.NET Core app      ← ZeroMcp, in-process dispatch
        │
        ▼
  Your controllers / endpoints
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;ZeroMcp.Relay and ZeroMcp proper solve adjacent but distinct problems — and Relay bridges the one gap ZeroMcp leaves open.&lt;/p&gt;

&lt;p&gt;ZeroMcp is about your own APIs: your source code, your pipeline, your auth filters running in-process. The value is zero duplication and full fidelity to your existing implementation. Its constraint is transport: HTTP only.&lt;/p&gt;

&lt;p&gt;ZeroMcp.Relay is about reach: any API with an OpenAPI spec, any transport. Third-party services, internal APIs you don't own, and — via the stdio bridge pattern above — your own ZeroMcp APIs when you need to connect a stdio client.&lt;/p&gt;

&lt;p&gt;Together, they cover the full range. Build your internal capabilities with ZeroMcp. Connect to external services and stdio clients with ZeroMcp.Relay. Use both from the same MCP client.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Install:&lt;/strong&gt; &lt;code&gt;dotnet tool install -g ZeroMcp.Relay&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ZeroMcp/ZeroMcp.Relay" rel="noopener noreferrer"&gt;github.com/ZeroMcp/ZeroMcp.Relay&lt;/a&gt; (latest: v0.1.1)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ZeroMcp (the library):&lt;/strong&gt; &lt;a href="https://github.com/ZeroMcp/ZeroMCP.net" rel="noopener noreferrer"&gt;github.com/ZeroMcp/ZeroMCP.net&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is early — v0.1.1 — and there will be rough edges, especially around OpenAPI specs that use unusual patterns or non-standard extensions. If something doesn't work, open an issue with the spec URL (or a minimal reproduction) and I'll take a look.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tags: #mcp #dotnet #webdev #llm #openapi&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>dotnet</category>
      <category>openapi</category>
      <category>ai</category>
    </item>
    <item>
      <title>ZeroMCP Has Grown Up: From a Single Attribute to a Production-Ready MCP Platform</title>
      <dc:creator>Matt Anderson</dc:creator>
      <pubDate>Mon, 09 Mar 2026 19:11:22 +0000</pubDate>
      <link>https://dev.to/matt_anderson_6c6857dba01/zeromcp-has-grown-up-from-a-single-attribute-to-a-production-ready-mcp-platform-2fe2</link>
      <guid>https://dev.to/matt_anderson_6c6857dba01/zeromcp-has-grown-up-from-a-single-attribute-to-a-production-ready-mcp-platform-2fe2</guid>
      <description>&lt;p&gt;&lt;em&gt;A follow-up to &lt;a href="https://dev.to/matt_anderson_6c6857dba01/your-existing-aspnet-core-api-is-already-an-mcp-server-you-just-dont-know-it-yet-ojb"&gt;"Your Existing ASP.NET Core API is Already an MCP Server — You Just Don't Know It Yet"&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;When I published the original ZeroMCP article a few weeks ago, the pitch was simple: tag a controller action with &lt;code&gt;[Mcp]&lt;/code&gt;, add two lines of setup, and your existing ASP.NET Core API becomes an MCP server. Zero duplication, zero new process, zero rewriting.&lt;/p&gt;

&lt;p&gt;The response was encouraging — enough that I've kept building. And ZeroMCP has grown considerably since then.&lt;/p&gt;

&lt;p&gt;This post is an honest look at what's changed: what got added, why, and what's coming next.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where We Started
&lt;/h2&gt;

&lt;p&gt;The v1.0 story was about the core insight: your controller pipeline is already doing exactly what an MCP server needs to do. Instead of duplicating it into a separate &lt;code&gt;[McpServerTool]&lt;/code&gt; class (where your auth filters don't run, your ModelState doesn't validate, your DI scope is wrong), ZeroMCP dispatches through &lt;code&gt;IActionInvokerFactory&lt;/code&gt; — the real pipeline — using a synthetic &lt;code&gt;HttpContext&lt;/code&gt; built from the LLM's tool arguments.&lt;/p&gt;

&lt;p&gt;That core mechanic hasn't changed. It's still the heart of everything.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;has&lt;/em&gt; changed is everything built on top of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Observability and Governance
&lt;/h2&gt;

&lt;p&gt;The first thing that became obvious after dogfooding the library on real APIs: you need to be able to &lt;em&gt;see&lt;/em&gt; what's happening.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured Logging
&lt;/h3&gt;

&lt;p&gt;Every MCP request now emits structured log entries with a scope containing &lt;code&gt;CorrelationId&lt;/code&gt;, &lt;code&gt;JsonRpcId&lt;/code&gt;, and &lt;code&gt;Method&lt;/code&gt;. Tool invocations log &lt;code&gt;ToolName&lt;/code&gt;, &lt;code&gt;StatusCode&lt;/code&gt;, &lt;code&gt;IsError&lt;/code&gt;, &lt;code&gt;DurationMs&lt;/code&gt;, and &lt;code&gt;CorrelationId&lt;/code&gt;. If you're using Serilog or any structured logging provider, these show up as first-class fields — filterable, queryable, alertable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Correlation IDs
&lt;/h3&gt;

&lt;p&gt;Send &lt;code&gt;X-Correlation-ID&lt;/code&gt; on the MCP request and ZeroMCP echoes it in the response, propagates it to the synthetic request's &lt;code&gt;TraceIdentifier&lt;/code&gt;, and includes it in every log entry. If you don't send one, a GUID is generated. This sounds like a small thing but it makes debugging agentic workflows — where a single user action can trigger dozens of tool calls — dramatically easier.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenTelemetry
&lt;/h3&gt;

&lt;p&gt;Set &lt;code&gt;EnableOpenTelemetryEnrichment = true&lt;/code&gt; and ZeroMCP tags &lt;code&gt;Activity.Current&lt;/code&gt; with &lt;code&gt;mcp.tool&lt;/code&gt;, &lt;code&gt;mcp.status_code&lt;/code&gt;, &lt;code&gt;mcp.is_error&lt;/code&gt;, &lt;code&gt;mcp.duration_ms&lt;/code&gt;, and &lt;code&gt;mcp.correlation_id&lt;/code&gt;. Your existing OpenTelemetry pipeline picks it up automatically — no new instrumentation needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pluggable Metrics
&lt;/h3&gt;

&lt;p&gt;Implement &lt;code&gt;IMcpMetricsSink&lt;/code&gt; and register it after &lt;code&gt;AddZeroMcp()&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyMetricsSink&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IMcpMetricsSink&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;RecordToolInvocation&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;toolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;durationMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Push to Prometheus, Datadog, Application Insights — whatever you use&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 is a no-op, so there's no overhead if you don't need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Role and Policy-Based Tool Visibility
&lt;/h3&gt;

&lt;p&gt;Phase 1 also solidified the governance story. Tools can now be restricted by role or policy directly on the &lt;code&gt;[Mcp]&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="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"admin_report"&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;"Runs admin report."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Roles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Admin"&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;ActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Report&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetAdminReport&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;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sensitive_export"&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;"Exports customer PII."&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;"RequireDataSteward"&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;ActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExportResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExportData&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;Tools not visible to the current user don't appear in &lt;code&gt;tools/list&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; are rejected if called directly. The LLM never knows they exist. And because this is built on ASP.NET Core's standard &lt;code&gt;IAuthorizationService&lt;/code&gt;, your existing auth policies and role claims work without any ZeroMCP-specific configuration.&lt;/p&gt;

&lt;p&gt;For discovery-time filtering (e.g. strip out internal tools in production), use &lt;code&gt;ToolFilter&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolFilter&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;=&amp;gt;&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="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"internal_"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For per-request filtering (e.g. show beta tools only to beta users), use &lt;code&gt;ToolVisibilityFilter&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolVisibilityFilter&lt;/span&gt; &lt;span class="p"&gt;=&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;ctx&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Beta-Features"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&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;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"beta_"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Result Enrichment, Follow-Ups, and Streaming
&lt;/h2&gt;

&lt;p&gt;I then focussed on about making the LLM smarter about what to do with tool results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result Enrichment
&lt;/h3&gt;

&lt;p&gt;Enable &lt;code&gt;EnableResultEnrichment = true&lt;/code&gt; and tool call results include metadata alongside the payload: &lt;code&gt;statusCode&lt;/code&gt;, &lt;code&gt;durationMs&lt;/code&gt;, &lt;code&gt;correlationId&lt;/code&gt;, and optional hints. The LLM can use this to reason about whether a call succeeded, how long it took, and what to try next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Suggested Follow-Ups
&lt;/h3&gt;

&lt;p&gt;This is the feature I'm most excited about. Set &lt;code&gt;EnableSuggestedFollowUps = true&lt;/code&gt; and implement &lt;code&gt;SuggestedFollowUpsProvider&lt;/code&gt; to return a list of suggested next tools after each invocation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SuggestedFollowUpsProvider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&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;toolName&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"create_order"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"get_order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"list_customer_orders"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s"&gt;"get_customer"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"list_customer_orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"update_customer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM gets a &lt;code&gt;suggestedFollowUps&lt;/code&gt; field in the result. Whether it uses them is up to the model, but in practice this significantly improves multi-step workflow completion — the model has a map of "what makes sense to do next" rather than having to infer it from the tool list alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming Tool Results
&lt;/h3&gt;

&lt;p&gt;For large results (think: export endpoints, report generation, search results), you can now stream:&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnableStreamingToolResults&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StreamingChunkSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results are returned as chunks with &lt;code&gt;chunkIndex&lt;/code&gt; and &lt;code&gt;isFinal&lt;/code&gt; fields. MCP clients that support streaming can start processing immediately rather than waiting for the full payload.&lt;/p&gt;

&lt;h3&gt;
  
  
  XmlDoc support
&lt;/h3&gt;

&lt;p&gt;If you use Swagger for your existing APIs, the chances are you have already crafted descriptions of your Methods. If you do not specify a description in your [MCP] attribute, ZeroMCP will extract the description from your XmlDoc comments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rich Tool Metadata
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;[Mcp]&lt;/code&gt; attribute now supports &lt;code&gt;Category&lt;/code&gt;, &lt;code&gt;Examples&lt;/code&gt;, and &lt;code&gt;Hints&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="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Mcp&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;"create_order"&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;"Creates a new order and returns the created record."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Category&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Examples&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Create order for Alice, 2 Widgets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"New order: Bob, 1 Gadget, rush"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;Hints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"idempotent:false"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cost:low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"side-effect:write"&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;code&gt;Examples&lt;/code&gt; give the LLM concrete demonstrations of how to use the tool. &lt;code&gt;Hints&lt;/code&gt; are free-form AI-facing signals — use them however makes sense for your domain. &lt;code&gt;Category&lt;/code&gt; helps with grouping in tool inspectors and future filtering features.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tool Inspector — Including a Full UI
&lt;/h2&gt;

&lt;p&gt;ZeroMCP now ships a browser-based tool inspector UI at &lt;code&gt;/{routePrefix}/tools/ui&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've used Swagger UI, you already know what this feels like. Navigate to &lt;code&gt;/mcp/tools/ui&lt;/code&gt; in a browser and you get a visual interface listing every registered MCP tool — name, description, category, input schema, tags, required roles, examples. And like Swagger UI, you can &lt;strong&gt;execute tools directly from the browser&lt;/strong&gt;: fill in the arguments, hit invoke, and see the result come back.&lt;/p&gt;

&lt;p&gt;This is genuinely useful in ways the JSON endpoint alone isn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;During development&lt;/strong&gt;, you can verify your &lt;code&gt;[Mcp]&lt;/code&gt; attributes were picked up correctly and that your schema looks right, without setting up a full MCP client&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When debugging&lt;/strong&gt;, you can reproduce a tool call the LLM made and inspect the response directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When onboarding teammates&lt;/strong&gt;, you can hand them a URL and say "here are all the things the AI can do with this API" — no tooling required on their end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The underlying JSON endpoint (&lt;code&gt;GET {routePrefix}/tools&lt;/code&gt;) is still there for programmatic use:&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;"serverName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My Orders 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;"serverVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"protocolVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-11-05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"toolCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tools"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create_order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Creates a new order and returns the created record."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"httpMethod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"route"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"examples"&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;"Create order for Alice, 2 Widgets"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inputSchema"&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="err"&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;Both the UI and the JSON endpoint are controlled by the same option:&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnableToolInspector&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="c1"&gt;// default; exposes /tools and /tools/ui&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Disable it in production if your tool list is sensitive:&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnableToolInspector&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The analogy to Swagger UI is deliberate. Swagger solved a real discoverability problem for REST APIs — before it, you had to read source code or documentation to understand what an API could do. ZeroMCP's inspector solves the same problem for MCP tools: it makes your AI-facing API surface visible, browsable, and testable without an AI client in the loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  Four Production-Ready Examples
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;examples/&lt;/code&gt; folder now ships four standalone projects that represent the range of real-world configurations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;What It Shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Minimal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One controller action, one minimal API, no auth — the fastest path to working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WithAuth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API-key auth, role-based tool visibility, &lt;code&gt;[Authorize]&lt;/code&gt; filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WithEnrichment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Result enrichment, suggested follow-ups, streaming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enterprise&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Auth + enrichment + observability + ToolFilter + ToolVisibilityFilter together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run any of them with &lt;code&gt;dotnet run&lt;/code&gt; from its folder. The Enterprise example is the reference implementation for production deployments — it shows how all the pieces fit together without being a contrived demo.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Benchmark Suite
&lt;/h2&gt;

&lt;p&gt;ZeroMCP now ships &lt;code&gt;ZeroMCP.Benchmarks&lt;/code&gt;, a BenchmarkDotNet project that measures dispatch overhead, schema generation cost, and throughput at various tool counts. I'll publish numbers in a dedicated post, but the short version: the overhead of the synthetic &lt;code&gt;HttpContext&lt;/code&gt; approach is negligible compared to the cost of a real HTTP round-trip, which is what you'd be paying if you separated your MCP server from your API.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Still Missing (And What's Next)
&lt;/h2&gt;

&lt;p&gt;Being direct about current limitations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;stdio transport isn't fully supported.&lt;/strong&gt; ZeroMCP does support stdio, you just need to run the API endpoint locally, which is usually out of scope for this kind of implementation. A standalone relay solution is currently being developed to handle this. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimal API parameter binding is limited.&lt;/strong&gt; Route parameters work. Query and body binding on minimal APIs is constrained by what the route pattern exposes. Controller actions have full binding support.&lt;/p&gt;

&lt;p&gt;The two highest-impact next additions are stdio transport support and richer minimal API parameter binding — both listed in the Contributing section if you want to take a swing at either.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;Something has shifted in how I think about this project since the original article.&lt;/p&gt;

&lt;p&gt;ZeroMCP started as a brownfield story: &lt;em&gt;you have an existing API, here's how to add MCP with minimal disruption.&lt;/em&gt; That's still true. But what's become clearer is that for greenfield APIs, designing with &lt;code&gt;[Mcp]&lt;/code&gt; from the start changes how you think about your endpoints.&lt;/p&gt;

&lt;p&gt;When your API endpoint is also an AI tool, you start writing descriptions that matter to a model, not just a human developer reading Swagger. You think about what "suggested follow-ups" make sense after each operation. You consider what hints help the model understand side effects and cost. You think about which tools should be visible to which roles — not just from an access control perspective, but from a &lt;em&gt;task completion&lt;/em&gt; perspective.&lt;/p&gt;

&lt;p&gt;That's a different design discipline, and I think it's a good one. APIs that are legible to AI agents tend to be better designed APIs in general: cleaner semantics, more explicit contracts, better documentation.&lt;/p&gt;

&lt;p&gt;ZeroMCP is becoming a library for building that kind of API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NuGet:&lt;/strong&gt; &lt;a href="https://www.nuget.org/packages/ZeroMcp/" rel="noopener noreferrer"&gt;nuget.org/packages/ZeroMcp&lt;/a&gt; (latest: v1.3.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ZeroMcp/ZeroMCP.net" rel="noopener noreferrer"&gt;github.com/ZeroMcp/ZeroMCP.net&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wiki:&lt;/strong&gt; Full configuration reference and enterprise usage guide&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're using ZeroMCP in a real project, I'd love to hear about it in the GitHub Discussions. And if something doesn't work, open an issue — the MCP ecosystem for .NET is still early, and the rough edges are worth fixing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tags: #mcp #aspdotnet #webdev #llm #dotnet&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>aspnet</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your Existing ASP.NET Core API is Already an MCP Server — You Just Don't Know It Yet</title>
      <dc:creator>Matt Anderson</dc:creator>
      <pubDate>Fri, 27 Feb 2026 00:52:48 +0000</pubDate>
      <link>https://dev.to/matt_anderson_6c6857dba01/your-existing-aspnet-core-api-is-already-an-mcp-server-you-just-dont-know-it-yet-ojb</link>
      <guid>https://dev.to/matt_anderson_6c6857dba01/your-existing-aspnet-core-api-is-already-an-mcp-server-you-just-dont-know-it-yet-ojb</guid>
      <description>&lt;p&gt;If you've been following the AI tooling space lately, you've probably heard about MCP — the Model Context Protocol. It's the standard that lets AI clients like Claude connect to external tools and APIs. And if you're a .NET developer, you've probably also had the thought: &lt;em&gt;"How do I expose my existing API as MCP tools without rewriting everything?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer most tutorials give you looks something like this:&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;class&lt;/span&gt; &lt;span class="nc"&gt;OrderTools&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;readonly&lt;/span&gt; &lt;span class="n"&gt;OrderService&lt;/span&gt; &lt;span class="n"&gt;_orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OrderTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderService&lt;/span&gt; &lt;span class="n"&gt;orders&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;_orders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&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;"Retrieves a single order by ID."&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;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&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;await&lt;/span&gt; &lt;span class="n"&gt;_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByIdAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="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;"Creates a new order."&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;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CreateOrder&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;customerName&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;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;quantity&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;await&lt;/span&gt; &lt;span class="n"&gt;_orders&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="n"&gt;customerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&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;You're duplicating your controller logic. Your auth filters don't run. Your ModelState validation doesn't run. Your existing DI pipeline is bypassed. You're maintaining two surfaces for the same functionality.&lt;/p&gt;

&lt;p&gt;There's a better way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Introducing ZeroMCP
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ZeroMcp/ZeroMCP.net" rel="noopener noreferrer"&gt;ZeroMCP&lt;/a&gt; is a .NET library that exposes your existing ASP.NET Core API as an MCP server with a single attribute and two lines of setup. No separate process. No code duplication. No rewriting.&lt;/p&gt;

&lt;p&gt;Here's what it looks like:&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;ApiController&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api/[controller]"&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;OrdersController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ControllerBase&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{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;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_order"&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;"Retrieves a single order by ID."&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;ActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&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;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"create_order"&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;"Creates a new order."&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;ActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CreateOrder&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;FromBody&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;CreateOrderRequest&lt;/span&gt; &lt;span class="n"&gt;request&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;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{id}"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="c1"&gt;// No [Mcp] — invisible to MCP clients&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;IActionResult&lt;/span&gt; &lt;span class="nf"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Your existing controller, your existing logic, your existing pipeline — now also an MCP server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Install
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"ZeroMcp"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"1.*"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Register services
&lt;/h3&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;AddZeroMcp&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;ServerName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"My Orders API"&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;ServerVersion&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Map the endpoint
&lt;/h3&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;MapZeroMcp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// registers GET and POST /mcp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Connect your MCP client
&lt;/h3&gt;

&lt;p&gt;Add this to your &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-api"&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;"http://localhost:5000/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="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;That's the entire setup. Point Claude at your &lt;code&gt;/mcp&lt;/code&gt; endpoint and it will see your tagged actions as tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Approach Is Different
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Your entire pipeline runs
&lt;/h3&gt;

&lt;p&gt;When an MCP client calls one of your tools, ZeroMCP doesn't call your method directly. It creates a fresh DI scope, builds a synthetic &lt;code&gt;HttpContext&lt;/code&gt; with the correct route values, query string, and body, and dispatches through &lt;code&gt;IActionInvokerFactory&lt;/code&gt; — the same pipeline a real HTTP request uses.&lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[Authorize]&lt;/code&gt; works — auth filters run normally&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ModelState&lt;/code&gt; validation works — validation errors return as proper MCP errors&lt;/li&gt;
&lt;li&gt;Exception filters work — unhandled exceptions are caught and returned gracefully&lt;/li&gt;
&lt;li&gt;Your DI services, repositories, and business logic are called as-is&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Parameters are merged automatically
&lt;/h3&gt;

&lt;p&gt;ZeroMCP merges route params, query params, and body properties into a single flat JSON Schema that the LLM fills in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{id}/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;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"update_order_status"&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;"Updates an order's status."&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;IActionResult&lt;/span&gt; &lt;span class="nf"&gt;UpdateStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;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;FromBody&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;UpdateStatusRequest&lt;/span&gt; &lt;span class="n"&gt;req&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;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UpdateStatusRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&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;Status&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;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="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Produces this MCP input schema automatically:&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;"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;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"id"&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;"integer"&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;"status"&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;"string"&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;"reason"&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;"string"&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="nl"&gt;"required"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status"&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;h3&gt;
  
  
  Minimal APIs are supported too
&lt;/h3&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;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/health"&lt;/span&gt;&lt;span class="p"&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="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="k"&gt;new&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="s"&gt;"ok"&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsMcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"health_check"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Returns API health status."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Built for Production
&lt;/h2&gt;

&lt;p&gt;ZeroMCP isn't just a quick demo library. It ships with features that enterprise teams actually need.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auth forwarding
&lt;/h3&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;AddZeroMcp&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;ForwardHeaders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&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;Headers are copied from the MCP request to the synthetic dispatch request, so your existing JWT or API key auth just works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Role and policy-based tool visibility
&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="nf"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"admin_report"&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;"Runs admin report."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Roles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Admin"&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;ActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Report&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetAdminReport&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;Tools not visible to the current user won't appear in &lt;code&gt;tools/list&lt;/code&gt; at all — and direct calls to hidden tools are also rejected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-request tool filtering
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolVisibilityFilter&lt;/span&gt; &lt;span class="p"&gt;=&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;ctx&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Beta-Features"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&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;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"beta_"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Observability out of the box
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Structured logging with correlation IDs, tool name, status code, and duration&lt;/li&gt;
&lt;li&gt;OpenTelemetry enrichment via &lt;code&gt;EnableOpenTelemetryEnrichment = true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pluggable metrics sink via &lt;code&gt;IMcpMetricsSink&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Brownfield Story
&lt;/h2&gt;

&lt;p&gt;This is where ZeroMCP really shines. If you have a 5-year-old ASP.NET Core API with hundreds of endpoints, complex auth, custom filters, and business logic that's been battle-tested in production — you don't need to rewrite any of it to participate in the MCP ecosystem.&lt;/p&gt;

&lt;p&gt;You add a NuGet package, two lines of setup, and &lt;code&gt;[Mcp]&lt;/code&gt; attributes to whichever endpoints make sense to expose. Your existing tests still pass. Your existing auth still works. Your existing monitoring still fires.&lt;/p&gt;

&lt;p&gt;That's the zero in ZeroMCP.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NuGet:&lt;/strong&gt; &lt;a href="https://www.nuget.org/packages/ZeroMcp/" rel="noopener noreferrer"&gt;nuget.org/packages/ZeroMcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ZeroMcp/ZeroMCP.net" rel="noopener noreferrer"&gt;github.com/ZeroMcp/ZeroMCP.net&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PRs and issues welcome. The MCP ecosystem for .NET is still early — if you try it and hit something that doesn't work, open an issue.&lt;/p&gt;

&lt;p&gt;If you would like to discuss this more - raise a discussion on the GitHub, especially if you have further questions!!!&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>aspdotnet</category>
      <category>webdev</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
