<?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: Steef-Jan Wiggers</title>
    <description>The latest articles on DEV Community by Steef-Jan Wiggers (@steefjan_wiggers_34a415b).</description>
    <link>https://dev.to/steefjan_wiggers_34a415b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4108081%2Fbc7c1eee-c91f-4098-a748-7fd0e58a971f.jpg</url>
      <title>DEV Community: Steef-Jan Wiggers</title>
      <link>https://dev.to/steefjan_wiggers_34a415b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/steefjan_wiggers_34a415b"/>
    <language>en</language>
    <item>
      <title>The Embedding Rematch</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Wed, 14 Oct 2026 02:15:28 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/the-embedding-rematch-584i</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/the-embedding-rematch-584i</guid>
      <description>&lt;p&gt;The &lt;a href="https://dev.to/steefjan_wiggers_34a415b/the-shortlist-decides-first-1d5a"&gt;Shortlist Decides First&lt;/a&gt; ended with a caveat I promised to resolve: the retriever in that experiment was BM25, a lexical matcher, and its catastrophic 5 percent recall on paraphrased requests was exactly the failure lexical matching should produce. Would a semantic retriever change the verdict? That is a measurable question, so here is the rematch: the same 100 tools, the same 200 tasks, the same four description conditions and k-sweep, with one change. Instead of BM25, the retriever embeds every tool document and every request with text-embedding-3-small on Azure OpenAI and ranks by cosine similarity. The harness is measure/eval_recall_embed.py in the companion repo; nothing else moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;Recall of the correct tool into the shortlist, by description condition and task kind:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;condition&lt;/th&gt;
&lt;th&gt;vocab @3&lt;/th&gt;
&lt;th&gt;vocab @5&lt;/th&gt;
&lt;th&gt;vocab &lt;a class="mentioned-user" href="https://dev.to/10"&gt;@10&lt;/a&gt;
&lt;/th&gt;
&lt;th&gt;para @3&lt;/th&gt;
&lt;th&gt;para @5&lt;/th&gt;
&lt;th&gt;para &lt;a class="mentioned-user" href="https://dev.to/10"&gt;@10&lt;/a&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;terse&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;59%&lt;/td&gt;
&lt;td&gt;73%&lt;/td&gt;
&lt;td&gt;87%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;realistic&lt;/td&gt;
&lt;td&gt;91%&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;59%&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;94%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;verbose&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;realistic + aliases&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three findings, one win, one surprise, one standing verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The win: embeddings rescue paraphrase
&lt;/h2&gt;

&lt;p&gt;BM25 scored 5 percent on paraphrased requests at k=5. Embeddings score 76 percent on the same tasks with the same descriptions. That is the rematch headline, and it lands exactly where the theory says it should: a user who says "I want to cancel my booking" shares almost no tokens with a tool described as "deletes a reservation", but the two sentences live close together in embedding space.&lt;/p&gt;

&lt;p&gt;The k-sweep behaves differently now too, and this is worth noticing. With BM25, widening the shortlist from 3 to 10 bought paraphrase recall from 3 to 10 percent, because the correct tool usually was not even near the top of the ranking. With embeddings, the same widening buys 59 to 94 percent. Semantic misses are near misses; lexical misses were not misses at all, they were absences. When your retriever is at least semantically in the neighborhood, paying for a longer shortlist actually purchases something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprise: verbosity finally pays, and exactness costs
&lt;/h2&gt;

&lt;p&gt;The shortlist post concluded that verbosity is not coverage, because doubling description tokens moved BM25's paraphrase recall not at all. The rematch forces me to refine that: it is true lexically and less true semantically. Under embeddings, verbose descriptions beat terse ones on paraphrase at every k, 82 against 73 at k=5, because every extra sentence adds semantic surface for the query vector to land on. Verbosity is still a poor deal, you pay roughly triple the tokens per tool for nine points, but it is no longer a zero.&lt;/p&gt;

&lt;p&gt;The reverse surprise sits in the vocabulary columns. On requests that echo the tool's own vocabulary, BM25 scored 97 to 100 percent at k=3; embeddings score 91 to 97. Embeddings blur exact matches. When the user says precisely the words in the description, nothing beats matching those words literally, and a semantic retriever occasionally ranks a similar-sounding sibling above the exact hit. The two retrievers fail on opposite task populations, which is the textbook argument for hybrid retrieval, and now the series has the numbers for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standing verdict: the alias sentence still wins
&lt;/h2&gt;

&lt;p&gt;Look at the last row. With the 13-token alias sentence per tool, recall is 99 to 100 percent everywhere, at every k, and it was also 100 percent under BM25. The cheapest intervention in the entire series makes the retriever choice irrelevant.&lt;/p&gt;

&lt;p&gt;That reframes what an embedding index buys you. If you control the tool descriptions, the alias sentence gets you to perfect recall for about 13 tokens per tool and zero infrastructure. The embedding pipeline, an embedding model, an index, a lookup on every request, buys you from 5 to 76 percent on paraphrase when you cannot touch the descriptions, which is precisely the situation of a gateway or catalog fronting other teams' MCP servers. Both answers are right; they answer different ownership questions.&lt;/p&gt;

&lt;p&gt;The end-to-end multiplication from The Model Was Never the Problem updates accordingly. Recall times selection on paraphrased requests with realistic descriptions: BM25 gave 5 percent times 93 percent, roughly 5 percent of tasks ending in the right tool call. Embeddings give 76 times 93, roughly 71 percent. Aliases give 100 times 97, roughly 97, with either retriever underneath.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;One embedding model, text-embedding-3-small; a larger model or a hybrid lexical-plus-vector ranker would likely narrow both gaps, and the harness takes any Azure OpenAI embedding deployment if you want to try. The paraphrases are template-generated, which makes them systematic but tamer than real user phrasing. The catalog is 100 tools; at 1,000 the embedding index gets more attractive because the alias discipline gets harder to enforce across teams. And this measures retrieval in isolation; the selection stage was measured separately and multiplies on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The rematch confirms the retriever was the right suspect and the description was still the right fix. Embeddings turn paraphrase recall from a catastrophe into a tax, 5 to 76 percent, and they are the honest recommendation when the descriptions belong to someone else. But when the descriptions are yours, one alias sentence per tool beats the entire vector pipeline, at one ten-thousandth of the operational surface. Fix the words first. Add the index when the words stop being yours to fix.&lt;/p&gt;

&lt;p&gt;The harness, both retrievers, and the full results are in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;: measure/eval_recall.py for BM25, measure/eval_recall_embed.py for the rematch.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>api</category>
      <category>azure</category>
    </item>
    <item>
      <title>The Third Leg of the Parity Test</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Wed, 07 Oct 2026 12:31:29 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/the-third-leg-of-the-parity-test-j1b</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/the-third-leg-of-the-parity-test-j1b</guid>
      <description>&lt;p&gt;Months ago, a commenter on this series proposed a test: if the same backend really stands behind both doors, then auth, error behavior, and idempotency must be preserved across them. &lt;a href="https://dev.to/steefjan_wiggers_34a415b/authorization-stays-in-the-kitchen-p59"&gt;Authorization Stays in the Kitchen&lt;/a&gt; settled the first leg. The confusion telemetry and recovery messages &lt;a href="https://dev.to/steefjan_wiggers_34a415b/watching-the-contract-fail-in-production-2dnd"&gt;settled the second&lt;/a&gt;. This post settles the third, and it turns out to be the leg that matters most for agents, because agents retry.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsxw2sbbngpzkpbu3upv1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsxw2sbbngpzkpbu3upv1.png" alt="Diagram with two sequence panels. Left, the REST door: a developer client sends POST /orders with Idempotency-Key demo-1037, receives orderId 97e92454, retries the exact same request, receives the same orderId 97e92454 marked replayed, then sends the same key with quantity 5 and receives 422 idempotency_conflict. Right, the MCP door: an agent calls place_order with clientOrderId order-2ccc9bd3, receives orderId d120204a, retries with the same arguments and gets the same orderId replayed, then reuses the clientOrderId with quantity 5 and gets recovery prose with an order_conflict sentinel. A ribbon below states the dedupe store lives in the kitchen, mapping key to request fingerprint and original confirmation, in memory in the sample. An amber control strip shows the same retry without any key producing two confirmed orders, 9714f45b and 99523aed, both 23 euros." width="799" height="579"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents make this urgent
&lt;/h2&gt;

&lt;p&gt;Code retries when a developer writes a retry policy. Agents retry on their own judgment: a timeout, an ambiguous error, a truncated response, and the model decides the call probably failed and tries again. Nothing in the MCP protocol stops the second call from being a second order. The new driver in the companion repo proves the failure mode as a control case before proving the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3. retry without clientOrderId    PASS: orderId 9714f45b then 99523aed (two orders exist, as feared)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same restaurant, same item, same quantity, two calls, two orders. Every production incident involving an agent and a non-idempotent write looks like this line.&lt;/p&gt;

&lt;h2&gt;
  
  
  One decision, one place, again
&lt;/h2&gt;

&lt;p&gt;The series rule holds: neither door contains business logic, and deduplication is business logic. The kitchen keeps a store keyed on the caller's idempotency key, remembers a fingerprint of the request alongside the original confirmation, and answers one of four ways: placed, replayed, conflict, or invalid.&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;idempotencyKey&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_byIdempotencyKey&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="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fingerprint&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;Fingerprint&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="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOutcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Replayed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Confirmation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderOutcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conflict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A replayed key with the same request returns the original confirmation and creates nothing. The same key with a different request is a conflict, which is always a caller bug and deserves to be refused loudly. The doors only translate those outcomes into their caller's dialect, exactly as they did for authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The REST door speaks header
&lt;/h2&gt;

&lt;p&gt;HTTP already has a convention for this: the Idempotency-Key request header, specified in an &lt;a href="https://greenbytes.de/tech/webdav/draft-ietf-httpapi-idempotency-key-header-04.html" rel="noopener noreferrer"&gt;IETF httpapi draft&lt;/a&gt; that Stripe, Adyen, and most payment APIs shaped in practice. The draft says a completed operation replays its original result and a key reused with a different payload SHOULD get a 422, so that is what the door does.&lt;/p&gt;

&lt;p&gt;Verified live, the same curl twice:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;{"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;"97e92454","restaurantName":"Trattoria Valkhof","itemName":"Margherita","quantity":2,"total":23.00,"status":"confirmed"}&lt;/span&gt;

HTTP/1.1 200 OK
{"orderId":"97e92454","restaurantName":"Trattoria Valkhof","itemName":"Margherita","quantity":2,"total":23.00,"status":"confirmed"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same orderId, one order, and the client cannot tell the replay from the original, which is the whole point. Then the same key with quantity 5:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;422&lt;/span&gt; &lt;span class="ne"&gt;Unprocessable Entity&lt;/span&gt;
&lt;span class="na"&gt;{"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;"idempotency_conflict","detail":"This Idempotency-Key was already used for a different request. Send a new key for a new order."}&lt;/span&gt;
&lt;span class="s"&gt;The MCP door has no headers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the two doors genuinely differ, and the difference is instructive. A tool call carries a name and arguments, nothing else, so the idempotency key cannot ride in a header. It has to be an argument, which means it has to be in the contract, which means a sentence has to teach the agent to use 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="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpToolProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"clientOrderId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"An order reference that comes from you, the caller: invent a unique value, and reuse the same value if you retry this order after an error or timeout."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isRequired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;clientOrderId&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sentence is the mechanism. On the REST door, idempotency works because a developer read the docs and wrote a header into their client. On the MCP door, it works only if the description convinces the model to invent a reference and hold onto it across a retry. There is no SDK to do it for the agent; the prose is the SDK.&lt;/p&gt;

&lt;p&gt;The series tooling priced and policed the sentence immediately. The &lt;a href="https://dev.to/steefjan_wiggers_34a415b/linting-prose-in-ci-2ke0"&gt;contract linter&lt;/a&gt; flagged clientOrderId as an identifier parameter the moment it existed, so the description had to say where the value comes from, and this parameter gave the provenance rule its first unusual answer: it comes from you. The token budget rule priced the addition at 42 tokens, taking the contract from 302 to 344 against a budget of 350, so the next contract sentence will have to pay for itself by shortening another. And because the deployed description hash is computed from these same attributes, the descHash changed, and the &lt;a href="https://dev.to/steefjan_wiggers_34a415b/watching-the-contract-fail-in-production-2dnd"&gt;confusion telemetry&lt;/a&gt; can attribute any behavior shift to this exact wording.&lt;/p&gt;

&lt;p&gt;The driver verified both halves end to end against the deployed server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. retry with same clientOrderId  PASS: orderId d120204a then d120204a (one order exists)
2. same key, different request    PASS: The clientOrderId 'order-2ccc9bd3' was already used for a different order.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conflict answer is recovery prose, as this door's denials always are, and it goes out through the telemetry helper as a new order_conflict sentinel, so key misuse is observable next to every other contract failure. Replays log their own trace, which gives you a retry rate for free: how often agents actually double-fire is now a query, not a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;The store is in-memory, like everything in this sample, so the replay guarantee spans one instance and one process lifetime. A production version needs a durable store with a TTL, and on a plan that scales out, a shared one. The guarantee is also only as good as the agent's discipline: an agent that invents a fresh clientOrderId on every retry gets no protection, and nothing in the protocol forces it to comply. The description can only persuade. That makes idempotency on the MCP door a probabilistic defense in a way it never was for REST clients, and a good candidate for the eval harness to measure someday: given a simulated timeout, does the model actually reuse the key the description told it to reuse?&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The parity test now passes on all three legs, and each leg landed the same way: one decision in the kitchen, one dialect per door. For idempotency the dialects are a standard header on one side and a contract sentence on the other, and the asymmetry is the lesson. REST made retries safe with infrastructure; MCP has to make them safe with language. If your tools write anything that costs money, the deduplication store is the easy half. The sentence that gets the agent to use it is the half this series keeps measuring.&lt;/p&gt;

&lt;p&gt;The kitchen policy, both doors, and the double-fire driver are in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;; run measure/drive_idempotency.py against your own server and see whether check 3 scares you as much as it should.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>mcp</category>
      <category>azure</category>
    </item>
    <item>
      <title>Authorization Stays in the Kitchen</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Fri, 02 Oct 2026 09:50:06 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/authorization-stays-in-the-kitchen-p59</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/authorization-stays-in-the-kitchen-p59</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/steefjan_wiggers_34a415b/what-a-401-means-to-an-mcp-client-1a5i"&gt;What a 401 Means to an MCP Client&lt;/a&gt; ended on a deliberately unfinished note: the token asserts who the caller is, it does not decide what they may do once inside. That sentence has been an IOU ever since. This post pays it, with claims-based authorization implemented in the sample, the same denial rendered as a 403 JSON body on one door and as recovery prose on the other, and every state verified live, including two detours that taught me more than the happy path did.&lt;/p&gt;

&lt;h2&gt;
  
  
  One decision, one place
&lt;/h2&gt;

&lt;p&gt;The design rule from the first post was that neither door contains business logic, and authorization is business logic. So the policy lives in the kitchen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderPolicy&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;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;RequiredRole&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Orders.Place"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;MayPlaceOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CallerContext&lt;/span&gt; &lt;span class="n"&gt;caller&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;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RequiredRole&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrdinalIgnoreCase&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;Anyone may search restaurants and read menus; placing an order requires the Orders.Place role. The doors do exactly two things: gather identity however their protocol allows, and render the kitchen's decision in their caller's dialect. The REST door answers code, so it returns a machine-shaped refusal:&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"forbidden"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Placing orders requires the Orders.Place role."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"requiredRole"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Orders.Place"&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;The MCP door answers an agent, so it returns an instruction it can act on: "This caller is not allowed to place orders; that requires the Orders.Place role. Searching restaurants and reading menus remain available." And because that sentence goes out through the telemetry helper from the confusion-telemetry work, every denial is also an attributable order_forbidden row in Application Insights.&lt;/p&gt;

&lt;h2&gt;
  
  
  The REST door: a sentence in a token
&lt;/h2&gt;

&lt;p&gt;App Service authentication validates the bearer token and hands the function the caller's claims in the X-MS-CLIENT-PRINCIPAL header; the door parses out the roles and asks the kitchen. Verifying it took one Entra app role (value Orders.Place on the restaurant-mcp-auth registration), one role assignment to my own account, and produced the cleanest before-and-after in this series: the same curl, the same body, the same endpoint, a 403 with the role named in it, and then an order confirmation for 23 euros. The only thing that changed between the two was one sentence inside the token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhmswnmnd7tfw8twh9krl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhmswnmnd7tfw8twh9krl.png" alt="Terminal showing the orders endpoint refusing a token without the role, returning forbidden with requiredRole Orders.Place" width="800" height="52"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1mkhg3rpy6hwhjbmejji.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1mkhg3rpy6hwhjbmejji.png" alt="Terminal showing the token's roles claim containing Orders.Place and the same order request returning a confirmed order" width="800" height="43"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Getting that sentence took two detours worth telling. First, the role assignment needs an enterprise application to attach to, and the portal's one-click MCP auth button had created the app registration without a service principal; the "Managed application in local directory: Create Service Principal" link on the registration's overview was the missing step, and until it was clicked, the app simply did not exist in the Enterprise applications list to assign anyone to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fucdiqeio0vkvttz07cub.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fucdiqeio0vkvttz07cub.png" alt="The Microsoft Entra Create app role pane defining the Orders.Place role for users and groups" width="800" height="996"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Second, and more operationally important: after the assignment, the token stubbornly kept returning no roles. Not propagation, not misconfiguration, but two layers of caching, the Azure CLI reusing its still-valid cached token, and, more fundamentally, the fact that claims are minted at token issuance, not looked up live. A role you grant right now reaches a caller only when they next acquire a token. For human users that is an inconvenience; for agents, which hold tokens and run long sessions, it is an architectural fact: revoking or granting a role changes agent behavior at token-expiry speed, not immediately. Decode the token when in doubt; the roles claim either is or is not in there, and no amount of retrying the API changes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP door cannot see you
&lt;/h2&gt;

&lt;p&gt;Here is the honest half. The Functions MCP extension authenticates the request, Entra and Easy Auth both did their jobs in the previous posts, and then hands the tool code a ToolInvocationContext containing the tool name, the arguments, a session id, and transport info. Nothing about the caller. The platform knows exactly who is calling; the tool does not get told. Per-user authorization on the MCP door is therefore not possible at this layer today, the same category of gap as the clientInfo one from the telemetry post, and the feature request writes itself: forward the authenticated principal to the tool invocation.&lt;/p&gt;

&lt;p&gt;What remains honest in the meantime is door-level authorization: an MCP_DOOR_ROLES app setting grants roles to the door as a whole, an explicit operator decision about what the MCP channel may do, which is a real and common enterprise posture ("agents may read, agents may not transact") even though it is coarser than per-user claims.&lt;/p&gt;

&lt;p&gt;And the telemetry proved the gate works, in the most satisfying way this series has produced. The traffic driver's deliberately wrong order calls hit the door before the grant and landed in Application Insights as order_forbidden. One app setting later, the same calls landed as order_rejected, because authorization now passed and the invalid arguments failed business validation instead. Same requests, different sentinel, and the shift between the two is the authorization gate becoming visible in a Log Analytics table. (One footnote for your own dashboards: the isolated worker emits each log line twice through the host, so absolute counts double; dedupe on operation id or read ratios, not totals.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Door parity, completed
&lt;/h2&gt;

&lt;p&gt;The commenter's test from months ago asked whether both doors preserve the same auth, error, and idempotency behavior. The answer the sample now gives in full: the decision is identical because it is taken once, in the kitchen, from the same policy. The rendering is deliberately different per door, a status code for code, a sentence for an agent, and the denial is observable in both channels, as a 4xx in the request logs and as a sentinel in the confusion telemetry. Parity of decisions, dialect per door.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Authentication moved to the platform over the course of this series; authorization never left your code, and this post is the argument that it should not. Put the policy in the kitchen where both doors share it, feed it claims where the platform forwards them and explicit door grants where it does not yet, render denials in each caller's dialect, and log every denial with the same attributable telemetry as any other contract failure. And remember the quiet operational lesson from the token detour: claims are minted, not looked up, so authorization changes reach your agents exactly as fast as their tokens expire, and no faster.&lt;/p&gt;

&lt;p&gt;The policy, both doors, and the traffic driver are in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;; the whole implementation is one small policy class and a dozen lines per door.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>azure</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Linting Prose in CI</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Thu, 01 Oct 2026 06:25:36 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/linting-prose-in-ci-2ke0</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/linting-prose-in-ci-2ke0</guid>
      <description>&lt;p&gt;A commenter on &lt;a href="https://dev.to/steefjan_wiggers_34a415b"&gt;Watching the Contract Fail in Production&lt;/a&gt; put the conclusion of this whole series into one sentence: once agents consume tool descriptions as part of their decision-making, changing a sentence is no longer just documentation, it can be an interface change, so important wording changes should be treated like code changes. This post makes the build system agree. The companion repo now has a contract linter and a GitHub Action, and the first thing the linter did was fail its own repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four rules, each one earned
&lt;/h2&gt;

&lt;p&gt;The linter is about a hundred lines of dependency-free Python (measure/lint_contract.py), and every rule in it was paid for by a measurement earlier in the series:&lt;/p&gt;

&lt;p&gt;Provenance. Every identifier parameter must say where its value comes from ("as returned by search_restaurants"). This is the sentence a JSON schema cannot express, and the difference between an agent that chains tools and one that invents ids; the confusion telemetry showed exactly that failure as the menu_unknown_restaurant sentinel.&lt;/p&gt;

&lt;p&gt;When-to-call. Every tool description must contain a usage cue, not only a what-it-does sentence. Selection within a shortlist works because descriptions disambiguate between siblings, and when-to-call is where that disambiguation lives.&lt;/p&gt;

&lt;p&gt;Token budget. The serialized tools array must stay under a budget, counted with the same cl100k tokenizer the discovery post used. The budget here is 350 tokens; every description edit now has a visible price.&lt;/p&gt;

&lt;p&gt;Drift. The C# attribute descriptions and the APIM Bicep copies of the same tools must be character-for-character identical, because the contract post demonstrated that two copies of a contract fork within a week even with one author.&lt;/p&gt;

&lt;p&gt;A fifth rule, the alias sentence that took paraphrase recall from 5 to 100 percent, is a warning rather than an error: proven valuable in the retrieval experiments, advisory at three tools.&lt;/p&gt;

&lt;p&gt;The linter parses the contract straight out of the McpToolTrigger and McpToolProperty attributes, the authoritative copy, the same source the deployed description hash is computed from. One pleasant cross-check fell out of that: the parser's token count of the extracted contract was 277, exactly the number the discovery post measured from the live server's tools/list response. Two extraction paths, one contract, same count.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first run: red, deservedly
&lt;/h2&gt;

&lt;p&gt;I pushed the linter with the honest commit message "current contract fails its own lint," and the Action delivered:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiys1fu62zfyenhc02ry8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiys1fu62zfyenhc02ry8.png" alt="GitHub Actions run failing the contract lint with exit code 1 on the commit that introduced the linter" width="799" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing here was staged. The drift errors are the exact fork the contract post documented by eye weeks ago, still sitting in the repo, now caught mechanically. The when-to-call errors are real too: get_menu's description said what the tool does and nothing about when to reach for it, and I wrote that description believing descriptions were the contract. The linter's first catch was its own author, which is the strongest argument I can offer for having one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: 25 tokens, priced
&lt;/h2&gt;

&lt;p&gt;The fix commit added when-to-call clauses to two tools ("Call it after search_restaurants and before placing an order", "Use it only after get_menu has confirmed the exact item name") and aligned the three Bicep descriptions with the C#. The build went green, and the budget rule priced the edit: the contract grew from 277 to 302 tokens, 25 tokens for two sentences of disambiguation, well under budget and exactly the kind of spend the measurement posts said earns its keep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq3ofau32s2zdw3hqhcr1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq3ofau32s2zdw3hqhcr1.png" alt="Local run of the contract linter reporting zero errors and three alias warnings at 302 tokens" width="800" height="118"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The commit pair is the point of the whole exercise. One push failed CI over sentences; the next push fixed sentences and passed. Wording changes went through the same gate code goes through: a diff, a red build, a fix, a green build. And because the deployed description hash is computed from these same attributes, this fix also changed the descHash, so the confusion telemetry from the previous post can compare agent behavior before and after the new wording. The lint gate and the telemetry loop are two ends of one pipeline: CI checks what can be checked statically, the dashboard watches what only production can reveal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ecnstfe714z811k0zqf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ecnstfe714z811k0zqf.png" alt="GitHub Actions run passing the contract lint on the commit that fixed the wording" width="799" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gate can and cannot hold
&lt;/h2&gt;

&lt;p&gt;Worth being precise about the boundary. These rules check the mechanical half of description quality: presence of provenance, presence of a usage cue, total cost, copy consistency. None of them can tell you whether the sentence is good, whether "Use it only after get_menu has confirmed the exact item name" actually improves selection, that is what the eval harness measures and what the telemetry observes. The lint is the cheap gate that runs in six seconds on every push; the eval is the expensive gate you run when the cheap one says the shape is right. Most teams have neither; the order to add them in is lint first, because it catches the failures that need no model at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The contract is part of the system behavior, so it gets the treatment behavior gets: versioned by hash, observed in telemetry, and now gated in CI. A one-word description edit can fail your build, and that is not overengineering, that is the build system catching up with where your interface actually lives. The linter, the workflow, and both commits, the red one and the green one, are in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;; point measure/lint_contract.py at your own attributes and find out what your contract has been getting away with.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>azure</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Foundry Model Ledger: Which Model, at What Price, Until When, and Which of Mine</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:16:38 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/foundry-model-ledger-which-model-at-what-price-until-when-and-which-of-mine-oi2</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/foundry-model-ledger-which-model-at-what-price-until-when-and-which-of-mine-oi2</guid>
      <description>&lt;p&gt;Every model conversation with a team ends with the same four questions. Is the model available in our region? What can it do? What does it cost per million tokens? And when does Microsoft retire it? Then the platform team asks a fifth: which of our own deployments are affected? Microsoft’s &lt;a href="https://foundry-models.azurewebsites.net/explorer" rel="noopener noreferrer"&gt;Foundry Model Explorer&lt;/a&gt; answers the first question well. However, it does not answer the other four. It cannot, because the answers live in your subscription and in a price list that was never meant to be read by software. So I built the Foundry Model Ledger to read them together. The interesting part is what the data looks like once you do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufo99utmuchl3a3um4s8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufo99utmuchl3a3um4s8.png" alt="Sweden Central filtered to the gpt-5.6 family, prices in euro. Retirement dates come from the ARM catalog; the " width="799" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Foundry Model Explorer already does
&lt;/h2&gt;

&lt;p&gt;Microsoft’s explorer is a region availability reference. It shows one row per model and version, with lifecycle status, deployment SKUs, retirement date, and the list of regions that carry it, exportable to CSV. If your question is “where can I deploy gpt-5.6-terra”, it is the fastest answer there is. However, it is a periodic snapshot rather than a live read. It shows no prices in any currency. And it knows nothing about what you have deployed. Those three gaps are the Foundry Model Ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sources, one Foundry Model Ledger
&lt;/h2&gt;

&lt;p&gt;The model catalog lives in Azure Resource Manager. A single call, GET /subscriptions/{id}/providers/Microsoft.CognitiveServices/locations/{region}/models, returns every model and version a region carries. Each entry has its capabilities (chat completion, tool calling, embeddings, context window), its lifecycle status, its deployment SKUs, and its retirement date under deprecation.inference. This is the same data the Foundry portal shows. Moreover, it needs nothing more than Reader on the subscription.&lt;/p&gt;

&lt;p&gt;Prices live in the Azure Retail Prices API. It is public, needs no authentication, and returns every meter Azure bills. Filter on serviceName eq 'Foundry Models' and a region, and you get the list prices for tokens, images, and hours, in any currency the API knows.&lt;/p&gt;

&lt;p&gt;Your own deployments live under each Cognitive Services or Foundry account. At first I reached for Azure Resource Graph, which is the natural place to list resources across a subscription. It returned zero rows against a subscription with twelve accounts. Resource Graph does not index the accounts/deployments child type. So the Ledger lists the accounts through ARM and then calls each account’s /deployments endpoint, six at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Foundry Model Ledger is built
&lt;/h2&gt;

&lt;p&gt;The Foundry Model Ledger is a .NET 8 isolated Azure Function on Flex Consumption with a single-page UI. It deploys with azd up, in the same shape as the [INTERNAL LINK: RAG in 8 Steps on Azure] sample. A user-assigned managed identity with Reader on the subscription reads the first and third source. The second source needs no identity at all.&lt;/p&gt;

&lt;p&gt;Pick a region and a currency, and the table shows model, version, lifecycle, capabilities, and retirement date with a days-left bar. Next to those sit the input and output prices per million tokens. Click a row for every SKU with its capacity range, every capability ARM reports, and every price meter the tool matched. The same panel has a button that checks every other region for the same model and version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbopn3eehxoyg4o927pi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbopn3eehxoyg4o927pi.png" alt="The detail panel for gpt-5.6-terra: lifecycle, retirement date, every SKU with its capacity range, and the retail meters the matcher tied to this model. The area: EUR line under capabilities is the catalog’s own geography tag, not the currency switch." width="800" height="627"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Foundry Model Ledger detail panel for gpt-5.6-terra with lifecycle, deployment SKUs, matched retail price meters and region availability&lt;br&gt;
The detail panel for gpt-5.6-terra: lifecycle, retirement date, every SKU with its capacity range, and the retail meters the matcher tied to this model. The area: EUR line under capabilities is the catalog’s own geography tag, not the currency switch.&lt;/p&gt;

&lt;p&gt;Finally, a second tab joins your deployments to the catalog of their own region and sorts them by soonest retirement.&lt;/p&gt;

&lt;p&gt;Every model deployment in the subscription, listed through ARM per account and joined to the catalog of its own region, sorted by soonest retirement. Resource Graph returned zero rows for the same subscription.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F786rnvynbz6awohc7qor.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F786rnvynbz6awohc7qor.png" alt="Every model deployment in the subscription, listed through ARM per account and joined to the catalog of its own region, sorted by soonest retirement. Resource Graph returned zero rows for the same subscription." width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data says
&lt;/h2&gt;

&lt;p&gt;Sweden Central, on the day I took these screenshots, carried more than 300 catalog entries from 11 publishers. 216 were generally available, 66 in preview, and 16 marked as deprecating. 70 versions retire within 90 days. Furthermore, 30 are already past their retirement date but still listed. That last group matters. The catalog endpoint tells you what the region knows about, not what you can still deploy. For example, gpt-4o-mini has been closed to new deployments for a long time and still appears. The SKU list and the retirement date are the better signals.&lt;/p&gt;

&lt;p&gt;The same model and version often appears twice, once for account kind OpenAI and once for AIServices, each with its own SKU list. The Foundry Model Ledger merges those into one row. If you script against the endpoint yourself, expect the duplicates.&lt;/p&gt;

&lt;p&gt;The Retail Prices API returned 1,727 meters for Foundry Models in that one region. None of them carries a model identifier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prices are written for invoices
&lt;/h2&gt;

&lt;p&gt;A meter name is a billing label, not a key. The same model, gpt-5.6-sol, is spread across meters such as 5.6 sol ShortCo Inp Std Gl 1M Tokens, 5.6 sol LongCo Cd Wr PP DZ 1M Tokens, and 56sol ShCo Cd Wr Fl Gl 1M Tokens. Older meters read gpt 4.1 nano cached Inp glbl Tokens and bill per 1K tokens; newer ones bill per 1M. Similarly, grok-4.6 appears as 4.6 Inp DZ Tokens under the product Azure Grok Models, with no “grok” in the meter name at all.&lt;/p&gt;

&lt;p&gt;The abbreviations are their own dialect. Input is Inp, inpt, or in. Output is Outp, opt, outpt, or out. Cached input is Cd. Global, data zone, and regional are Gl, DZ, and regnl. Batch, priority, and flex tiers have their own tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the matcher works
&lt;/h2&gt;

&lt;p&gt;So the Foundry Model Ledger has a matcher. First, it narrows meters to the model’s publisher. Then it tokenizes both sides the same way and requires every token of the model name (minus the family prefix) to appear in the meter, in order. It also rejects meters that carry a sibling variant such as mini or pro the model does not have. When a meter carries a date token, o3 0416 or chat-latest 08062026, it pins the version. Next, it classifies direction, deployment type, tier, and context length, and normalizes the unit to a price per million tokens. Finally, it picks a headline: global standard, short context, uncached. Every price in the table carries a confidence label, exact, name, or loose. In addition, the detail panel shows every matched meter, so the headline number is never the only evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first live run got wrong
&lt;/h2&gt;

&lt;p&gt;On the first live run it priced 204 of the entries in Sweden Central. The misses were instructive. FLUX image models came out at “40,000 per million” because their meters bill per 1K images, and I had treated every 1K unit as tokens. Qwen sits under product Qwen models, which the publisher map did not know. Moreover, most of its meters are fine-tuning meters that must never become a headline price. And the Anthropic models, plus Cohere rerank and parse, are in the catalog with no Foundry Models meter in the region at all. The model exists; the public price does not.&lt;/p&gt;

&lt;p&gt;Foundry Model Ledger price meters tab showing raw Azure Retail Prices rows for gpt-5.6-sol in Sweden Central&lt;br&gt;
The raw Azure Retail Prices rows for gpt-5.6-sol in Sweden Central. Thirty-odd meters, no model identifier, and the abbreviations the matcher has to decode: ShortCo, Cd, PP, Gl, DZ.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdnpuqr776fi1puc95m91.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdnpuqr776fi1puc95m91.png" alt="The raw Azure Retail Prices rows for gpt-5.6-sol in Sweden Central. Thirty-odd meters, no model identifier, and the abbreviations the matcher has to decode: ShortCo, Cd, PP, Gl, DZ." width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tabs that came from using it
&lt;/h2&gt;

&lt;p&gt;The first version stopped at the tabs above. Two more followed within a day, because the first questions people asked were not the ones I had built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else is this model available
&lt;/h2&gt;

&lt;p&gt;The region check in the detail panel gives a list of names. That is correct and hard to read. So the Foundry Model Ledger now has an availability map. Every Azure region is plotted from the subscription’s own location metadata, on a world outline from Natural Earth. Type a model name, pick a version, and the regions that carry it light up in the brand blue. Regions without Azure AI services show as a dashed ring, and regions that host the service but not the model stay grey. Zoom presets for Europe, North America, Asia Pacific, the Middle East and Africa, and South America keep the labels readable where regions cluster. In Europe alone there are twenty. The data behind the map is one lightweight catalog call per region, cached, so a check across every region takes a few seconds the first time and is instant after that.&lt;/p&gt;

&lt;p&gt;Foundry Model Ledger availability map showing the Azure regions that carry a selected model version&lt;br&gt;
Where claude-haiku-4-5:2 is available. Blue is carried, grey is hosted but absent, dashed is no Azure AI services. The Europe preset separates the twenty European regions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcqulnwedqslst3oa6t2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcqulnwedqslst3oa6t2.png" alt="Where claude-haiku-4-5:2 is available. Blue is carried, grey is hosted but absent, dashed is no Azure AI services. The Europe preset separates the twenty European regions." width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What else does the same job for less
&lt;/h2&gt;

&lt;p&gt;The second question is the one that decides budgets. If we use this model for that workload, what else in the region can do the same job, and what would it cost instead? The Alternatives tab answers it with the data the Ledger already has. Pick the model you use or consider. Its capability flags from the catalog, such as chat completion, tool calling, JSON schema output, and image input, become chips, and every chip is required by default. Click one off if you do not need it and the list widens. Every other model in the region that reports all remaining capabilities is listed with its list price, what your monthly workload would cost on it, and the delta against your model, cheapest first. Enter the workload as millions of input and output tokens per month. Filters restrict the list to the same publisher or to generally available models, and retired versions are excluded. Other versions of the same model are listed last, so they do not pose as alternatives.&lt;/p&gt;

&lt;p&gt;The comparison is on capabilities, not on quality. A nano model will always look like a 99 percent saving next to a frontier model, and the table does not know whether it would pass your evaluation set. What it does give you is the shortlist and the price gap in one view, which is the part that used to take an afternoon with the pricing page open in three tabs.&lt;/p&gt;

&lt;p&gt;Foundry Model Ledger alternatives tab listing models with the same capabilities and their monthly cost delta&lt;br&gt;
Alternatives to claude-fable-5:1 with the same capability flags, priced for 10M input and 2M output tokens a month. Green is cheaper, orange is dearer. The match is on capabilities, not quality.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6zxlheoprlrtz8xu2jvq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6zxlheoprlrtz8xu2jvq.png" alt="Alternatives to claude-fable-5:1 with the same capability flags, priced for 10M input and 2M output tokens a month. Green is cheaper, orange is dearer. The match is on capabilities, not quality." width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Foundry Model Ledger is the wrong answer
&lt;/h2&gt;

&lt;p&gt;Do not budget on it. These are list prices, matched heuristically, in whatever currency you pick. The matcher will be wrong somewhere the day Microsoft renames a meter. It also cannot price provisioned throughput, which is billed per hour per unit rather than per model. Use it to see the shape of a decision, then confirm on the pricing page.&lt;/p&gt;

&lt;p&gt;Do not put it on the internet as is. The Function has no authentication, and it exposes your deployment list to anyone with the URL. Keep it internal, or put Easy Auth with Entra ID in front of it. If you already run model endpoints behind API Management, the same gateway can front the Ledger, with the policies from the APIM for AI Workloads series.&lt;/p&gt;

&lt;p&gt;And do not read the catalog as an availability promise. Listed does not mean deployable, and a retirement date is a floor, not a schedule. For a plain “where is it available” question, Microsoft’s explorer remains the quicker tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would build next
&lt;/h2&gt;

&lt;p&gt;The tab I did not plan is the one people ask about: my deployments, joined to the catalog, sorted by retirement. That is a governance question, not a browsing question. It sits next to the routing questions from Don’t Build Around Today’s Model. Build for the AI Control Plane. Consequently, the natural next steps are all on that side. All subscriptions in a tenant instead of one. An alert when a deployment sits on a version retiring within 90 days. A history, so you can see what appeared and what closed in a region since last month, which is a changelog Microsoft does not publish. And a cost delta for moving each deployment to its successor, which the Alternatives tab now does for one model at a time and should do for the whole deployment list.&lt;/p&gt;

&lt;p&gt;The Foundry Model Ledger repo is at &lt;a href="https://github.com/steefjan1/foundry-model-ledger" rel="noopener noreferrer"&gt;github.com/steefjan1/foundry-model-ledger.&lt;/a&gt; azd up deploys it; scripts/run-local.ps1 runs it against your az login; node tools/mock-server.mjs runs the UI on a sample snapshot without .NET or Azure. If the matcher misprices a model in your region, the detail panel shows why. An issue with that screenshot is the fastest way to get it fixed.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>azure</category>
      <category>foundry</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Watching the Contract Fail in Production</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Mon, 28 Sep 2026 06:00:40 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/watching-the-contract-fail-in-production-2dnd</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/watching-the-contract-fail-in-production-2dnd</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/steefjan_wiggers_34a415b/tool-descriptions-are-the-contract-5488"&gt;Tool Descriptions Are the Contract&lt;/a&gt; made a claim I had not tested: your MCP server's recovery messages are free confusion telemetry, because every time one is returned, you know exactly which misunderstanding just happened. The comment thread then designed the implementation better than I had: track each string alongside the tool version and a description hash, add the schema version as its own dimension, and treat a missing value as a first-class result, otherwise the cleanest dashboard is the one hiding the deployment that broke observability. This post builds exactly that, drives real traffic at it through the Entra-protected MCP door, and checks the dashboard against ground truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line change, and the hash that cannot drift
&lt;/h2&gt;

&lt;p&gt;Each of the sample's three recovery returns now routes through one helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;Confusion&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;sentinel&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;tool&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;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Contract confusion: {Sentinel} on {Tool} (descHash {DescHash}, schemaVersion {SchemaVersion})"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sentinel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ToolContract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DescriptionHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ToolContract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SchemaVersion&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;message&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 sentinels are stable slugs, one per recovery sentence: search_no_match, menu_unknown_restaurant, order_rejected. The structured parameters become customDimensions in Application Insights, so the log line is a queryable row, not prose.&lt;/p&gt;

&lt;p&gt;The description hash is the part worth stealing. The contract post showed that prose copies drift, so a hash maintained by hand would just be one more copy to forget. Instead, ToolContract computes it at startup by reflecting over the same McpToolTrigger and McpToolProperty attributes the MCP extension serves to clients, hashing every name and description in the deployed binary. The hash cannot disagree with what agents actually see, because it is derived from it. Edit one word of one description, redeploy, and every subsequent confusion row carries a new hash: the before and after of a contract change, joinable in KQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two doors before the traffic
&lt;/h2&gt;

&lt;p&gt;Driving traffic at the server turned into its own small verification of the &lt;a href="https://dev.to/steefjan_wiggers_34a415b/what-a-401-means-to-an-mcp-client-1a5i"&gt;401 post&lt;/a&gt;. The traffic driver is a Python script that speaks streamable-HTTP MCP directly, and since the server has built-in Entra authentication, the driver needs a bearer token. Getting one stopped at two separate gates.&lt;/p&gt;

&lt;p&gt;First, Entra refused to mint the token at all: AADSTS65001, because Azure CLI, the tool requesting it, was not on the app registration's preauthorized client list. Then, with that fixed, the function app answered 403, because App Service authentication keeps its own allowed-client-applications list, and the CLI was not on that one either. The known-clients control from the 401 post exists twice, independently: Entra decides who may start the flow, Easy Auth decides whose tokens the app accepts, and a client must pass both. My own traffic generator had to be admitted as a known client, twice, before it was allowed to confuse the server. For a regulated organization, that is not friction; that is the audit trail existing before you asked for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traffic, with ground truth attached
&lt;/h2&gt;

&lt;p&gt;The driver then made 60 calls: a rotation of requests that are wrong the way agents are actually wrong. An invented restaurant id, a restaurant name where an id belongs, a menu item from memory instead of from get_menu, a quantity past the cap, filters that match nothing, and valid calls as control. Because the rotation is deterministic, the console output is ground truth: exactly 16 menu_unknown_restaurant, 16 order_rejected, 14 search_no_match, and 14 clean results.&lt;/p&gt;

&lt;p&gt;Which makes the dashboard check a real test instead of a demo. In Application Insights:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;traces&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;startswith&lt;/span&gt; &lt;span class="nv"&gt;"Contract confusion"&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;extend&lt;/span&gt; &lt;span class="n"&gt;sentinel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tostring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDimensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sentinel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tostring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDimensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="n"&gt;descHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tostring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDimensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DescHash&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="n"&gt;schemaVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tostring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDimensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SchemaVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;summarize&lt;/span&gt; &lt;span class="n"&gt;confusions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;sentinel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;descHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schemaVersion&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;confusions&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;The result: order_rejected 16, menu_unknown_restaurant 16, search_no_match 14, every row carrying the same descHash and schemaVersion 1. The dashboard matches the driver's console exactly, so the pipeline is verified end to end, from a wrong argument in a JSON-RPC call to an attributable row in a Log Analytics table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the dashboard as a contract report
&lt;/h2&gt;

&lt;p&gt;Each sentinel names a failing sentence. A rising menu_unknown_restaurant count says the provenance sentence in get_menu's description, "as returned by search_restaurants", is not landing: agents are inventing ids. A rising search_no_match says callers expect a wider catalog than the descriptions promise. And because every row carries the descHash, the response to a bad trend is an experiment, not an argument: rewrite the sentence, redeploy, and compare the same sentinel across the two hashes. The description edit gets the same before-and-after treatment a performance fix gets.&lt;/p&gt;

&lt;p&gt;The commenters' missing-value rule matters here too. The healthy state of this dashboard is not empty, it is present-with-baseline: some confusion is normal while valid traffic flows. A sentinel that silently vanishes, or rows arriving without a descHash, means a deployment broke the telemetry, not that agents stopped being confused. The alert to write is on absence and on unknown dimension values, not only on spikes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still missing, honestly
&lt;/h2&gt;

&lt;p&gt;One dimension from the design is not in these rows: the client. The MCP initialize handshake carries clientInfo with a name and version, but the Functions MCP extension does not currently hand that to the tool method through ToolInvocationContext, so per-client confusion rates are not yet possible at this layer. That is a real gap; per-client breakdowns are how you distinguish one badly prompted agent from a contract problem affecting everyone. Until the extension exposes it, the client mix has to come from the platform's request telemetry instead of the tool's own rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;This is the cheapest contract test I know of: one logging helper, three stable sentinels, a hash computed from the deployed attributes so it can never drift, and a KQL query. The recovery messages were already written, the dashboard was already there; connecting them turns every agent mistake into an attributable data point against a specific version of your words. The prose is the interface, and now the interface has monitoring.&lt;/p&gt;

&lt;p&gt;The code, the traffic driver, and the queries are in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;; the whole change is one class and one helper in src/RestaurantBackend/Mcp/RestaurantMcpTools.cs, and measure/drive_confusion.py reproduces the traffic against any MCP server of your own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>api</category>
      <category>azure</category>
    </item>
    <item>
      <title>The Model Was Never the Problem</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Thu, 24 Sep 2026 08:07:57 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/the-model-was-never-the-problem-147e</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/the-model-was-never-the-problem-147e</guid>
      <description>&lt;p&gt;The shortlist post ended with a promise: stage two of the eval, selection within the shortlist, measured with a live model, scored so that a recall miss can never masquerade as a selection miss. This post pays that debt. The design came straight from the comment threads: same tool pools, same task pairs, shortlists from the same retriever, and every result row tagged with the description-set hash so the numbers stay attributable across changes. The runner is measure/eval_selection.py in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;, stdlib Python against any Azure OpenAI deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method
&lt;/h2&gt;

&lt;p&gt;Stage one showed recall failing at 5 percent on paraphrased requests, so scoring stage two on retrieved shortlists would mostly measure retrieval again. Instead, every shortlist here is forced: the correct tool plus its four strongest BM25 distractors, deterministically shuffled. That construction has a pleasant side effect. Because the distractors are whatever retrieval scores highest for the request, they are the correct tool's same-noun siblings. When the expected tool is approve_claim, the shortlist also contains cancel_claim, update_claim, and search_claim. The model does not get to coast on topic matching; it has to read verb semantics.&lt;/p&gt;

&lt;p&gt;The model is gpt-5.4-mini, deliberately the small and cheap one, at its default temperature, sixty tasks per condition, half vocabulary phrasing and half paraphrase, across the four description conditions from the recall post. Scored on two things: did the model pick the expected tool, and did the task's reference value (the INV-2005 in the request) land in the arguments of a correct pick. The argument check is deliberately lenient; exact schema validation would punish harmless formatting.&lt;/p&gt;

&lt;p&gt;One anecdote from setup that belongs in this series: the eval refused to run with an API key, because the Azure AI resource had disableLocalAuth set. The harness that measures keyless MCP tooling had to authenticate with an Entra bearer token itself. The 401 post's argument, arriving in person.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Selection accuracy, forced shortlist of five, sixty tasks per condition:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Descriptions&lt;/th&gt;
&lt;th&gt;Selection, vocabulary&lt;/th&gt;
&lt;th&gt;Selection, paraphrase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Terse&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realistic&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verbose&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realistic plus aliases&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table as flat, and I mean that as a finding, not a shrug. At thirty tasks per cell, every difference in it is one or two tasks, and one run at default temperature is a sample, not a truth. The honest summary is a single sentence: selection accuracy is 90 to 97 percent everywhere. A small model picks the right tool from a five-tool shortlist about nineteen times in twenty, whether the descriptions are terse or verbose, whether the user echoed the tool's vocabulary or paraphrased it into synonyms, and with the tool's own siblings crowding the list. Argument filling lands slightly lower, 87 to 97 percent, under the lenient scoring.&lt;/p&gt;

&lt;p&gt;Two null results hide in there, and both are useful. Description verbosity, which did nothing for recall, also does nothing for selection; terse held its own against descriptions three times its token price. And the paraphrase gap, which was catastrophic for recall, nearly vanishes at selection time: once the right tool is in front of it, the model bridges "sign off on the damage report" to approve_claim without difficulty. The vocabulary gap is fatal to retrieval and almost irrelevant to judgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiply the stages
&lt;/h2&gt;

&lt;p&gt;The two-stage arithmetic is the real headline. End to end, task success is roughly recall times selection. For a paraphrased request against one hundred realistic tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;Selection&lt;/th&gt;
&lt;th&gt;End to end&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Realistic descriptions&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;td&gt;~5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realistic plus aliases&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;~97%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6d9txaxs80oae6wk9yr6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6d9txaxs80oae6wk9yr6.png" alt="The entire end-to-end difference lives in stage one" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The entire end-to-end difference lives in stage one. Report one blended number, as most tool-calling benchmarks do, and you would conclude the model picks tools badly, when the model almost never saw the right tool at all. Measured separately, the model turns out to be nearly blameless, and the 13-token alias sentence from the recall post is carrying the whole system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes about the budget
&lt;/h2&gt;

&lt;p&gt;The reflex when an agent picks wrong tools is to reach for a bigger model. At this shortlist size, that money is misspent, because selection was never the failing stage. The failing stage was vocabulary coverage in the descriptions, which is fixed with words that cost thirteen tokens per tool, not with a model that costs ten times more per call. There will be scales where that stops being true; a shortlist of twenty near-duplicates or genuinely ambiguous intents will stress judgment in a way five siblings do not, and that is a measurable follow-up. But the burden of proof has moved. Before spending on model size for tool calling, measure your recall, because the odds are the model never saw the tool you are blaming it for not picking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caveats, as always
&lt;/h2&gt;

&lt;p&gt;One model, one run, sixty tasks per condition, differences within the table smaller than its noise floor. The forced shortlist isolates selection but flatters no condition, since all four saw identical lists. And the ceiling on the alias numbers inherits the recall post's caveat: my aliases match my paraphrases by construction. What survives all of that comfortably is the shape: selection high and flat, recall variable and decisive.&lt;/p&gt;

&lt;p&gt;The runner, the results with their description hashes, and everything needed to reproduce this against your own deployment are in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;repo&lt;/a&gt; under measure/; the whole run costs a few cents. Argue with the numbers directly, it is what the comment section of this series is for.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
      <category>azure</category>
    </item>
    <item>
      <title>The Shortlist Decides First</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Tue, 22 Sep 2026 06:21:29 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/the-shortlist-decides-first-1d5a</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/the-shortlist-decides-first-1d5a</guid>
      <description>&lt;p&gt;The eval this post runs was designed by the readers. A commenter on What the &lt;a href="https://dev.to/steefjan_wiggers_34a415b/what-the-agent-pays-for-discovery-221a"&gt;Agent Pays for Discovery&lt;/a&gt; reframed tool selection as a two-stage system: first recall, whether the correct tool makes it into the shortlist that deferred loading or retrieval produces, then selection, whether the model chooses it from that shortlist. He added the design constraint that stuck with me: a cheap context with the right tool absent is the worst outcome, because it fails silently. The model does not error; it improvises with what it has.&lt;/p&gt;

&lt;p&gt;That splits the eval into two experiments with very different price tags. Stage two needs a live model, many runs, and scripted tasks. Stage one needs no model at all. Deferred tool loading in practice means retrieving candidate tools by matching the user's request against tool names and descriptions, and retrieval quality is a property of the descriptions. That is measurable deterministically, so this post measures it. The harness lives in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; under measure/, next to the token counter from the discovery post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The tool pool is the same deterministic set of one hundred synthetic enterprise tools from the discovery post (search_invoice, approve_claim, and so on), at the same three description levels: terse, realistic, verbose. Retrieval is BM25 over each tool's serialized definition, name, description, and parameter descriptions, with a shortlist of five, which is the curated-manifest size from the break-even discussion.&lt;/p&gt;

&lt;p&gt;The tasks are the interesting part. Two hundred of them, one pair per tool, in two kinds. Vocabulary tasks echo the tool's own domain words: "Please approve the claim CLM-2041." Paraphrase tasks say the same thing the way a person who never read your API docs would: "Can you sign off on that damage report, number CLM-2041?" Same intent, same identifier, not one shared content word.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Recall at five, one hundred tools, two hundred tasks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Descriptions&lt;/th&gt;
&lt;th&gt;Vocabulary&lt;/th&gt;
&lt;th&gt;Paraphrase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Terse&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realistic&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verbose&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two findings, one comfortable and one not. When the user speaks the tool's vocabulary, retrieval is essentially solved; even terse descriptions score perfectly, because the tool name alone carries the match. When the user paraphrases, recall collapses to five percent, and here is the uncomfortable part: verbosity does nothing. The verbose tier costs 208 tokens per tool, roughly double realistic, and its recall is statistically identical. All those extra words are the same words repeated. More prose is not more retrieval surface if it keeps drawing from the same vocabulary.&lt;/p&gt;

&lt;p&gt;Which exposes the assumption hiding inside "just defer tool loading and retrieve on demand." Retrieval only sees what the description says. Your users say "bill", "delivery", "sign off". Your descriptions say "invoice", "shipment", "approve". Between those two vocabularies sits a 95 percent silent failure rate, and the failure mode is exactly the one the commenter flagged: the shortlist arrives cheap, plausible, and wrong, and the model does its best with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can a longer shortlist buy it back?
&lt;/h2&gt;

&lt;p&gt;The obvious counter is to widen the shortlist, so I swept it. The shortlist size is also a context budget, priced with the tokenizer from the discovery post: five realistic tools cost about 542 tokens per model call, ten cost 1,040.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shortlist&lt;/th&gt;
&lt;th&gt;Tokens per call&lt;/th&gt;
&lt;th&gt;Paraphrase recall (realistic)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;~362&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;~542&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;~1,040&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Doubling the price buys recall from five percent to ten. That is not a fix, that is a linear crawl toward a target the vocabulary gap keeps out of reach; extrapolate it and you are back to shipping all hundred tools, which is the situation deferred loading exists to avoid. Meanwhile the alias condition below sits at 100 percent already at a shortlist of three. You cannot buy your way out of a vocabulary gap with a longer list; you can only close the gap with words.&lt;/p&gt;

&lt;p&gt;The thirteen-token fix&lt;/p&gt;

&lt;p&gt;The fix follows directly from the diagnosis. If recall is vocabulary coverage, add vocabulary. I appended one sentence to each realistic description, naming its synonyms: "Users may also say 'sign off on' or 'damage report'." Then reran:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Descriptions&lt;/th&gt;
&lt;th&gt;Vocabulary&lt;/th&gt;
&lt;th&gt;Paraphrase&lt;/th&gt;
&lt;th&gt;Tokens per tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Realistic&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;~111&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realistic plus aliases&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;~124&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verbose&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;~208&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;The alias line costs 13.4 tokens per tool. The verbose tier costs 97 extra tokens per tool and buys nothing. Thirteen tokens of the right words beat ninety-seven tokens of ceremony, which sharpens the budget rule from the contract post: spend description tokens on disambiguation, on provenance, and now on the user's own vocabulary. An alias sentence is the cheapest line in the whole contract.&lt;/p&gt;

&lt;p&gt;One honest caveat, because this series has a rule about that. My alias sentences contain the same synonyms my paraphrase tasks use, so 100 percent is the ceiling case; it demonstrates the mechanism, not a production guarantee. In a real system, the aliases come from somewhere messier and better: the confusion telemetry from the contract post. Every recovery string an agent triggers, and every phrasing that produced a retrieval miss, is a user-vocabulary sample you did not have at design time. The telemetry loop and the alias line are the same feedback cycle, observed and then closed.&lt;/p&gt;

&lt;p&gt;A second caveat, aimed at the comment I expect first: the retriever here is BM25, which is lexical, and an embedding retriever handles synonyms better out of the box. It softens the cliff, but it does not repeal the mechanism, because any retriever can only match against the surface the description exposes, and embedding similarity between "approve claim" and "sign off on the damage report" is still weaker than a description that contains both phrasings. The alias line enriches the surface for either retriever. The harness's retriever is one pluggable class, so an embedding comparison is a fair follow-up experiment, and I would genuinely like to see the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stage one changes about stage two
&lt;/h2&gt;

&lt;p&gt;Stage two, selection within the shortlist, still needs the live model, and it is coming. But stage one has already changed what stage two must control for. If the correct tool misses the shortlist 95 percent of the time on paraphrased requests, then any end-to-end accuracy number that does not separate the stages is mostly measuring retrieval, not the model's judgment. Report one blended number, and you will conclude the model picks tools badly, when the model never saw the right tool at all. Recall misses get their own headline number, exactly as the comment demanded, or the eval lies to you.&lt;/p&gt;

&lt;p&gt;The stage-two design, for the record it will be measured against: the same tool pools and task pairs, shortlists produced by the same retriever, the model asked to choose and call a tool, scored on tool choice and argument correctness, per description variant, with every run tagged by the description hash from the telemetry thread. Selection accuracy conditional on successful recall, so the two failure modes stay separated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Before the model chooses, the shortlist decides, and the shortlist is only as good as the vocabulary in your descriptions. Verbosity is not coverage, and neither is shortlist size: doubling the description bill moved recall not at all, doubling the shortlist bought five points, and a thirteen-token alias sentence took paraphrase recall from five percent to perfect at the smallest shortlist tested. So treat user vocabulary as part of the tool contract: put aliases in the descriptions, feed them from telemetry, and lint for them in CI next to provenance and when-to-call clauses. The silent failure the commenter warned about is real, cheap to measure, and cheap to fix.&lt;/p&gt;

&lt;p&gt;The harness, the task set, and the results are in the repo under measure/; run python eval_recall.py and argue with the numbers directly.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>azure</category>
      <category>api</category>
    </item>
    <item>
      <title>What a 401 Means to an MCP Client</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Fri, 18 Sep 2026 11:06:43 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/what-a-401-means-to-an-mcp-client-1a5i</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/what-a-401-means-to-an-mcp-client-1a5i</guid>
      <description>&lt;p&gt;Twice in this series, a client refused to connect to a working MCP server, and both refusals looked like bugs until they turned out to be the specification doing its job.&lt;/p&gt;

&lt;p&gt;The first was in &lt;a href="https://dev.to/steefjan_wiggers_34a415b/mcp-vs-api-is-the-wrong-question-392f"&gt;MCP vs API Is the Wrong Question&lt;/a&gt;. The Functions-hosted MCP server was protected by its mcp_extension system key, and Claude Code, VS Code, and MCP Inspector all connected happily with the key in an x-functions-key header. Then I tried adding the same server as a connector in the Claude desktop app, and it failed with "Couldn't register with the sign-in service." The second was in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions/tree/main/infra/apim" rel="noopener noreferrer"&gt;APIM setup in the repo&lt;/a&gt;: MCP Inspector, pointed at the gateway without its subscription key attached, reported "Dynamic Client Registration rejected (HTTP 404)."&lt;/p&gt;

&lt;p&gt;Different clients, different servers, the same wall. This post explains what that wall is and how to take it down properly with Microsoft Entra ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 401 is not a request for a header
&lt;/h2&gt;

&lt;p&gt;To a developer, a 401 means "add credentials and retry." To a client that implements the &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;MCP authorization specification&lt;/a&gt;, a 401 means something more specific: start OAuth. The client fetches the server's Protected Resource Metadata to learn which authorization server protects it, registers itself with that authorization server (dynamic client registration), and walks the user through a sign-in and consent flow. No step in that sequence involves asking a human to paste a key into a header field.&lt;/p&gt;

&lt;p&gt;That is why both failures happened. My key-protected servers returned 401 without any Protected Resource Metadata behind them, the clients went looking for an OAuth story that did not exist, and the attempt died at registration with a 404. The clients were not broken. They were refusing to work around a server that spoke half a protocol.&lt;/p&gt;

&lt;p&gt;Which splits the MCP client world cleanly in two. Developer tools like Claude Code, VS Code, and MCP Inspector let you attach arbitrary headers, so a shared key works, the way it always has for developers who read documentation. End-user clients like the Claude desktop app deliberately offer no header escape hatch: the spec flow is the only flow. Keys are for tools; OAuth is for people.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F68yfoh0n5fqxhtqnvh6o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F68yfoh0n5fqxhtqnvh6o.png" alt="Two sequence diagrams side by side: a key-protected MCP server answering the OAuth discovery steps with 404s until the client gives up, and a server with built-in Entra ID authentication returning Protected Resource Metadata so the client signs the user in and connects with a bearer token" width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning on the real thing
&lt;/h2&gt;

&lt;p&gt;The Functions MCP extension supports built-in authentication with Entra ID as the identity provider, and the setup has become almost anticlimactic. In the Azure portal, the function app now has an AI (preview) tab with an Authentication section and a "Turn on MCP authentication" button. Give the Entra app registration a name, save, and it creates the registration, wires up App Service authentication, adds the required settings, and disables key-based access to the MCP endpoint in one step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9chtzip4hysvrf59pof.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9chtzip4hysvrf59pof.png" alt="The AI preview tab of the function app with the Turn on MCP authentication panel open, and the portal noting that turning it on disables key-based access to the server" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The manual route shows what that button actually does, and it is worth seeing once. Key access is turned off by setting the webhook authorization level to anonymous, because the platform's authentication layer now stands in front of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az functionapp config appsettings &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &amp;lt;app&amp;gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;rg&amp;gt; &lt;span class="nt"&gt;--settings&lt;/span&gt; &lt;span class="s2"&gt;"AzureFunctionsJobHost__extensions__mcp__system__webhookAuthorizationLevel=Anonymous"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then App Service authentication (the feature long known as Easy Auth) is configured with Microsoft as the identity provider, a new app registration, unauthenticated requests answered with 401, and the token store enabled. Finally, the server advertises its Protected Resource Metadata so that spec-compliant clients can discover the authorization server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az functionapp config appsettings &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &amp;lt;app&amp;gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;rg&amp;gt; &lt;span class="nt"&gt;--settings&lt;/span&gt; &lt;span class="s2"&gt;"WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES=&amp;lt;scope from Expose an API&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last setting is the difference between a dead end and a doorway. With it in place, the 401 comes with directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second wall: invalid_target
&lt;/h2&gt;

&lt;p&gt;Almost anticlimactic, I said. Then I connected VS Code and hit a second wall the tutorial does not mention. Discovery worked, the client found the metadata and the authorization server, and Entra refused the token request with AADSTS9010010 invalid_target: "The resource parameter provided in the request doesn't match with the requested scopes."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqmt9dfq4mueg0xiuc0rj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqmt9dfq4mueg0xiuc0rj.png" alt="VS Code MCP output showing successful metadata discovery followed by Entra rejecting the token request with AADSTS9010010 invalid_target" width="799" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cause is visible in the metadata itself. A spec-compliant client sends the RFC 8707 resource parameter, taken straight from the Protected Resource Metadata, and Entra now enforces that this resource matches the audience of the requested scope. The button-made setup fails that check twice over. The metadata advertised resource: https://.azurewebsites.net but a scope of api:///user_impersonation, two different names for the same app. And the document clients actually read is the path-specific one at /.well-known/oauth-protected-resource/runtime/webhooks/mcp, whose resource includes the full endpoint path.&lt;/p&gt;

&lt;p&gt;The fix is three alignments, so that resource and scope audience are character-for-character identical:&lt;/p&gt;

&lt;p&gt;On the app registration, under Expose an API, change the Application ID URI from the default api:// to the endpoint URL: https://.azurewebsites.net/runtime/webhooks/mcp.&lt;br&gt;
Update the advertised scope to match: set WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES to https://.azurewebsites.net/runtime/webhooks/mcp/user_impersonation.&lt;br&gt;
In App Service authentication, add the same URL to the allowed token audiences.&lt;/p&gt;

&lt;p&gt;Then fetch the path-specific metadata document and check that resource and the prefix of scopes_supported are the same string. When they were, VS Code connected on the next start: 401, discovery, sign-in, token, and "Discovered 3 tools" in the output, with no key anywhere in the configuration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8mx88aux48kws7yaldl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8mx88aux48kws7yaldl.png" alt="VS Code MCP output showing the server starting, discovering the protected resource metadata and authorization server, and ending with Discovered 3 tools" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is not a quirk of one sample. The same error is currently open against the Azure DevOps MCP server and VS Code, and there is a growing catalog of Entra and MCP authorization mismatches. The pattern is the post's thesis repeating one level down: a strict client, a server speaking half a dialect, and the client refusing to guess.&lt;/p&gt;

&lt;p&gt;The client connection, before and after&lt;/p&gt;

&lt;p&gt;Before, the client configuration carried a secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"restaurant-directory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://&amp;lt;app&amp;gt;.azurewebsites.net/runtime/webhooks/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x-functions-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;"&amp;lt;the system key&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After, it carries nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"restaurant-directory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://&amp;lt;app&amp;gt;.azurewebsites.net/runtime/webhooks/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;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0n7jsefzduz6sj6r6snl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0n7jsefzduz6sj6r6snl.png" alt="Copilot in VS Code calling the search_restaurants tool through the Entra-protected MCP server and returning two matching restaurants" width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On first connect, the client receives the 401 plus metadata, discovers Entra ID, and opens a browser window. You sign in as yourself, consent once, and the client holds a token that refreshes automatically. The credential in the picture is now an identity, revocable per person, subject to Conditional Access, visible in sign-in logs. Everything the system key was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Entra wrinkle: known clients only
&lt;/h2&gt;

&lt;p&gt;There is one honest caveat, and it is where the story gets interesting for enterprise architects. Entra ID does not do open dynamic client registration; it wants to know its clients. The tutorial flow handles this by preauthorizing specific client applications on the app registration: VS Code, for example, is client id aebc6443-996d-45c2-90f0-388ff96faa56, added under "Expose an API" so users are not prompted for admin consent.&lt;/p&gt;

&lt;p&gt;For clients outside the preauthorized list, you register a client application yourself and hand its id to the client. The Claude desktop connector anticipates exactly this: the error message from the first post already offered a field to "add an OAuth Client ID in the connector settings." What read as a workaround then is the designed path now: create the client registration in your tenant, preauthorize it against the server's app registration, give the connector the client id, and the sign-in flow completes inside your own identity perimeter.&lt;/p&gt;

&lt;p&gt;Seen from a governance angle, this is a feature wearing a limitation's clothes. Anonymous dynamic registration means any client anywhere can start a flow against your server. A preauthorized client list means your identity team decides which MCP clients exist in your estate. For a regulated organization, that is not friction; that is the control you would have had to build anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the token does and does not do
&lt;/h2&gt;

&lt;p&gt;The token asserts who the caller is, which tenant they belong to, and that your authorization rules let them reach the endpoint. It does not decide what they may do once inside. Tool-level authorization, the difference between a user who may search restaurants and one who may place orders, remains your code's job, and the claims in the token are the input to it. Authentication moved to the platform; authorization stayed in the kitchen, exactly where the first post's caveats said it lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;Keys for demos and developer tooling, where a header field is available and rotation is cheap. Entra ID with built-in MCP authentication for anything a human signs into, and for any server that should exist inside an organization's identity perimeter rather than beside it. The clients already enforce this split; the only choice you get is whether to be surprised by it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;sample repo&lt;/a&gt; has both doors and the APIM patterns this builds on; the &lt;a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-mcp-tutorial" rel="noopener noreferrer"&gt;Functions MCP tutorial&lt;/a&gt; has the full authentication walkthrough.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>azure</category>
      <category>api</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Tool Descriptions Are the Contract</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:37:43 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/tool-descriptions-are-the-contract-5488</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/tool-descriptions-are-the-contract-5488</guid>
      <description>&lt;p&gt;This series started with a claim: MCP and REST are two doors into the same kitchen. Three posts later, the comments have pushed the argument down to its foundation. One reader on dev.to said the useful test is whether both doors preserve the same auth, error, and idempotency behavior. Another warned about granularity drift and asked how anyone validates tool descriptions. Both are pointing at the same uncomfortable fact: in an MCP server, prose is load-bearing. The description decides whether the agent picks your tool. The parameter text decides whether it calls the tool correctly. The error message decides whether it recovers. That prose is the API contract now, and almost nobody treats it like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract already lives in three places
&lt;/h2&gt;

&lt;p&gt;Here is what I did not expect to find in my own &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;sample repo&lt;/a&gt;. The restaurant backend describes its behavior in prose three times. The Functions MCP door carries descriptions in C# attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;McpToolTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_restaurants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Searches the restaurant directory. Both filters are optional; call it without arguments to list every restaurant.&lt;/span&gt;&lt;span class="sh"&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 APIM REST door carries operation descriptions in Bicep. And the third pattern from the &lt;a href="https://dev.to/steefjan_wiggers_34a415b"&gt;APIM post&lt;/a&gt;, where the gateway manufactures an MCP server from the REST API, carries its own tool descriptions in Bicep too:&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="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Searches&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restaurant&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;directory.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Both&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cuisine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;filters&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;are&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;optional;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;call&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;without&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;list&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;every&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restaurant.'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read those two closely. They are not the same sentence. I wrote them two days apart, and they drifted: "Both filters" became "Both cuisine and city filters", "call it without" became "call without". Harmless here. But this is a three-tool sample maintained by one person for one week, and the copies already disagree. Scale that to fifty tools, three teams, and a year of changes, and the gateway's MCP door describes a backend that no longer behaves the way its C# door says it does. That is the granularity drift the commenter meant, in miniature: the contract forks the moment it exists in two files, and nothing in the toolchain notices.&lt;/p&gt;

&lt;p&gt;The REST door gets away with prose drift because its real contract is elsewhere: routes, status codes, an OpenAPI document that tooling can diff. The MCP doors have no elsewhere. The description is the interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a good description buys, at a known price
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/steefjan_wiggers_34a415b/what-the-agent-pays-for-discovery-221a"&gt;measurement post&lt;/a&gt; put numbers on the cost side: a realistic description runs about 111 tokens per tool, and a verbose one 208, paid on every model call. So the budget question is what each sentence buys. The sample's descriptions follow three rules, and each rule earns its tokens differently.&lt;/p&gt;

&lt;p&gt;Say what the tool does, in one sentence, in terms that distinguish it from its neighbors. Say when to call it, which is mostly about optionality: "both filters are optional; call it without arguments to list every restaurant" prevents the agent from inventing a filter just to have one. And say where values come from: "the restaurant id, as returned by search_restaurants (for example 'r1')". That last rule is the one a JSON schema cannot express. A schema says restaurantId is a required string. Only prose says the string must come from a previous search_restaurants call and must not be guessed. Provenance is the highest-value sentence in the whole contract, and it is the difference between an agent that chains tools and one that hallucinates identifiers.&lt;/p&gt;

&lt;p&gt;The verbose tier from the measurement post doubles the bill mostly with ceremony the model would infer anyway. Spend on disambiguation and provenance, cut the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Door parity is about decisions, not formats
&lt;/h2&gt;

&lt;p&gt;The commenter's test, applied to the sample: auth and idempotency live in the kitchen, in the shared service that both doors call, so neither door can drift on what is allowed or what happens on a retry. Error rendering is the one thing the doors deliberately do differently. The REST door returns machine-shaped errors, a 4xx status with a small JSON body, because its caller is code and code branches on status. The MCP door returns instructions:&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;return&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;$"Restaurant '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;restaurantId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' was not found. Use search_restaurants first to get a valid id."&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same fact, two renderings. That passes the parity test rather than failing it: parity means the doors agree on the decision, not that they speak the same dialect. The kitchen decides the order is invalid; each door reports it in the shape its caller can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your error strings are confusion telemetry
&lt;/h2&gt;

&lt;p&gt;Those recovery sentences do a second job that I have not seen anyone talk about. They are constants, so every time one is returned, you know exactly which misunderstanding just happened. "Use search_restaurants first to get a valid id" fires when an agent invented a restaurant id, which means the provenance sentence in get_menu's description did not land. Add one log line where each recovery string is returned and Application Insights turns into a description quality dashboard: a KQL query grouping by sentinel string, per tool, per client. A rising count on one string is not an outage. It is a failing sentence in your contract, located precisely. I know of no cheaper contract test than instrumenting the errors you already wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating the contract
&lt;/h2&gt;

&lt;p&gt;Which is the honest answer to the commenter's validation question: start by treating descriptions as artifacts that CI can check without any LLM. Lint that every identifier parameter states its provenance. Lint that every tool description contains a when-to-call clause. And budget-gate the whole array: the token harness from the measurement post lives in the repo under measure/, and pointing it at your tools array in CI turns "our context bill crept up" into a failing build with a number in it. Drift between copies is checkable the same way; if the gateway's Bicep descriptions and the code's attribute descriptions are both machine-readable, a test can diff them.&lt;/p&gt;

&lt;p&gt;What CI cannot check is whether the model picks the right tool, and I will not pretend otherwise. That needs the deferred experiment: scripted tasks with known correct tool sequences, run repeatedly against description variants. A commenter on the measurement post sharpened how to score it: as a two-stage system. Stage one is recall, whether the correct tool made it into the shortlist that deferred loading or retrieval produced. Stage two is selection, whether the model chose it from that shortlist and called it correctly. The stages fail differently: a recall miss is a retrieval or granularity problem, a selection miss is a description problem. And recall misses need their own headline number, because they fail silently; a cheap context with the right tool absent is the worst outcome, and the model will improvise something plausible instead of erroring. The measurement harness was structured so that this eval slots in on top, and it is the next piece of this series with numbers in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;The description is the interface. So give it the treatment interfaces get: one authoritative copy where you can manage it, review on every change, a token budget enforced in CI, provenance sentences on every identifier, and telemetry on the error strings that reveal where the contract fails. The doors were never the hard part. The words were.&lt;/p&gt;

&lt;p&gt;The sample, the APIM patterns, and the token harness are all in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>azure</category>
      <category>api</category>
    </item>
    <item>
      <title>What the Agent Pays for Discovery</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Wed, 16 Sep 2026 11:15:02 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/what-the-agent-pays-for-discovery-221a</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/what-the-agent-pays-for-discovery-221a</guid>
      <description>&lt;p&gt;A reader of &lt;a href="https://dev.to/steefjan_wiggers_34a415b/mcp-vs-api-is-the-wrong-question-392f"&gt;MCP vs API Is the Wrong Question&lt;/a&gt; asked the question I could not answer at the time: did you measure the token cost of the live MCP discovery round trip against handing the model a curated tool manifest up front, and where does the break-even sit? I answered from first principles then. This post answers with numbers, from a small measurement harness that now lives in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; under measure/.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tokens actually go
&lt;/h2&gt;

&lt;p&gt;First, the framing correction that makes the question answerable. The live discovery round trip is nearly free: an MCP client calls tools/list once per session, not per turn, and the payload that comes back is essentially the same manifest you would curate by hand. The cost that matters is different and larger: whatever tools the client ends up with, their definitions are injected into the model's context on every single call. A curated manifest pays that too. So "discovery versus manifest" is not the axis. The axis is how many tool definitions sit in context, how wordy they are, and how many model calls the task takes.&lt;/p&gt;

&lt;p&gt;That is measurable without an LLM in the loop, exactly, with a tokenizer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method
&lt;/h2&gt;

&lt;p&gt;The harness serializes a tools array the way it reaches a model (JSON: name, description, input schema with per-parameter descriptions) and counts cl100k tokens. The anchor is real: the three tools of the restaurant sample, exactly as the Functions MCP extension serves them. Around that anchor it generates synthetic enterprise tools (search_invoice, approve_claim, and so on, two to four parameters each) at three description levels: terse (a sentence fragment), realistic (purpose plus parameter guidance, modeled on the sample's actual descriptions), and verbose (usage guidance, examples, and edge-case notes on everything). One honest caveat: harnesses reformat definitions slightly differently, so treat the absolute numbers as close and the relative differences as solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The real three-tool restaurant server costs 277 tokens per model call. That is the whole standing bill, and it is why break-even at this scale is immediate: there is nothing meaningful to save.&lt;/p&gt;

&lt;p&gt;Scale changes the picture:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;Terse&lt;/th&gt;
&lt;th&gt;Realistic&lt;/th&gt;
&lt;th&gt;Verbose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;td&gt;362&lt;/td&gt;
&lt;td&gt;668&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;596&lt;/td&gt;
&lt;td&gt;1,040&lt;/td&gt;
&lt;td&gt;1,972&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;1,856&lt;/td&gt;
&lt;td&gt;3,233&lt;/td&gt;
&lt;td&gt;6,089&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;6,370&lt;/td&gt;
&lt;td&gt;11,132&lt;/td&gt;
&lt;td&gt;20,814&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;Two things jump out. The growth is linear in tool count, roughly 64 tokens per tool terse, 111 realistic, 208 verbose; there is no cliff, just a slope. And description style is its own multiplier: at every size, verbose costs about 1.9 times realistic and 3.3 times terse.&lt;/p&gt;

&lt;p&gt;The slope compounds per task, not per session. A hundred realistic tools cost 11,132 tokens on every model call, so a 20-call agent task pays roughly 223,000 tokens for definitions alone, before a word of conversation or a byte of tool output. The curated comparison the commenter asked about: five relevant tools picked out of that hundred cost 542 tokens per call, a 95 percent saving.&lt;/p&gt;

&lt;h2&gt;
  
  
  So where is the break-even?
&lt;/h2&gt;

&lt;p&gt;For a server like the sample, there is none to find; 277 tokens is noise. My reading of the curve: below roughly ten tools, do nothing. Around thirty realistic tools you are paying 3,200 tokens per call and about 65,000 per task, which is real money at fleet scale but rarely worth architectural surgery. At a hundred tools you must do something, and the interesting part is that a curated manifest is only one of four options, and the crudest one.&lt;/p&gt;

&lt;p&gt;A curated manifest is static tool selection: you save 95 percent of the context by deciding at design time which five tools matter, which is precisely the design-time knowledge MCP exists to avoid assuming. The alternatives keep discovery and cut the bill differently. Split the server by domain, so an agent connects to the invoice server or the shipment server rather than the everything server; this is the maître d' post's fleet argument wearing a cost hat. Use a client that defers tool loading and searches definitions on demand, which more agent runtimes now do. Or design coarser, intent-sized tools so a hundred resource-level operations become fifteen task-level ones, which a commenter on the first post predicted and the numbers now justify.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tension with post one
&lt;/h2&gt;

&lt;p&gt;The first post argued that tool descriptions are load-bearing: vague text makes vague agents. This post seems to argue that words cost money. Both are true, and the numbers say where the balance sits. Realistic descriptions, around 25 words of purpose and parameter guidance per tool, cost 111 tokens each; that is the price of an agent that picks the right tool and recovers from errors, and it is worth paying. The verbose tier doubles the bill mostly with prose the model would infer anyway. So the budget rule I take away: spend description tokens on disambiguation between tools and on where identifiers come from, never on ceremony.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not measure
&lt;/h2&gt;

&lt;p&gt;Selection accuracy. The other half of the commenter's question is whether the model picks the right tool less often at a hundred tools than at ten, and that needs a live model, many runs, and scripted tasks with known correct tool sequences. The harness is structured so that eval slots in next; that experiment, together with the description validation ideas from the comment thread, is its own post. What is measured here is the bill; whether the agent's judgment also degrades with the menu size is the follow-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Discovery is not what costs you; standing context is. The bill is linear in tool count, multiplied by description style, and paid on every model call. Below ten tools, ignore it. Above that, the fix is not abandoning discovery for a hand-curated list; it is scoping servers, deferring definitions, and sizing tools to intents, with descriptions that earn their tokens.&lt;/p&gt;

&lt;p&gt;The harness, the data, and the chart are in the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;repo&lt;/a&gt; under measure/; run python token_cost.py and argue with the numbers directly.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>mcp</category>
      <category>azure</category>
    </item>
    <item>
      <title>Same Kitchen, Two Doors, One Maître d'</title>
      <dc:creator>Steef-Jan Wiggers</dc:creator>
      <pubDate>Sun, 13 Sep 2026 14:10:21 +0000</pubDate>
      <link>https://dev.to/steefjan_wiggers_34a415b/same-kitchen-two-doors-one-maitre-d-510</link>
      <guid>https://dev.to/steefjan_wiggers_34a415b/same-kitchen-two-doors-one-maitre-d-510</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/steefjan_wiggers_34a415b/mcp-vs-api-is-the-wrong-question-392f"&gt;MCP vs API Is the Wrong Question&lt;/a&gt; I deployed one Azure Functions app that exposes the same backend twice: a REST door for callers who know the contract, and an MCP door for agents that discover it. The caveats section admitted, out loud, what the demo left open: the REST endpoints were anonymous, and the MCP endpoint was guarded by a system key that every client had to carry.&lt;/p&gt;

&lt;p&gt;A reader summarized the gap better than I did: the useful test is whether both doors preserve the same auth, error, and idempotency behavior, because otherwise the integration surface splits even when the backend does not. Auth parity is exactly what the demo lacked. This post closes that gap with Azure API Management, and it turns out APIM can play three different roles in front of this architecture. Sticking with the restaurant analogy: the kitchen cooks, the doors let people in, and APIM is the maître d'. It checks reservations, limits party size, and keeps the guest book, without touching a single pan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the doors need a maître d'
&lt;/h2&gt;

&lt;p&gt;Both doors in the sample share one property: they authenticate the caller mechanically, not organizationally. A function key or an mcp_extension system key says "this caller has the string" and nothing else. There is no identity, no revocation short of rotating the key for everyone, no per-caller limits, and no answer to the question every platform team eventually asks: who called what, how often, and should they be allowed to?&lt;/p&gt;

&lt;p&gt;That question is gateway work. And the more actions we let agents perform, the more urgent it becomes, because an agent retries, chains calls, and occasionally misreads a tool description. You want the blast radius of that behavior governed somewhere the agent cannot negotiate with.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Freoww0ncwqo3ihzgnflf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Freoww0ncwqo3ihzgnflf.png" alt="Three patterns for Azure API Management with MCP: in front of the REST door, as an OAuth passthrough for the Functions MCP door with key injection, and as the MCP door itself generated from REST operations, with the tool description ownership trade-off" width="800" height="709"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern one: APIM in front of the REST door
&lt;/h2&gt;

&lt;p&gt;The familiar pattern, and I will keep it short because it is the subject of my API Management for AI Workloads series. APIM fronts the /api endpoints: validate-jwt against Entra ID or subscription keys for consumers, rate limits and quotas per product, IP filtering where it applies, and the function app locked down so only the gateway reaches it. The REST door's contract does not change; it just stops being naked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern two: APIM in front of the MCP door
&lt;/h2&gt;

&lt;p&gt;APIM can also expose an existing MCP server as a passthrough, and a Functions-hosted MCP endpoint is an explicitly supported backend. The MCP traffic (streamable HTTP) flows through the gateway, and policies apply to it like any other API.&lt;/p&gt;

&lt;p&gt;This changes the security posture in one important way: the agent no longer holds the backend credential. The client authenticates to APIM properly, with OAuth against Entra ID or a subscription key, and a policy injects the x-functions-key toward the backend. Rotating the system key becomes an operation nobody outside the platform team notices. On top of that you get the gateway's usual gifts, now applied to tool calls: throttling per agent identity, request tracing that shows which tools are called at which rate, and since Build 2026 even MCP-aware content safety policies.&lt;/p&gt;

&lt;p&gt;In the first post I noted that developer tools happily send a key in a header while end-user MCP clients follow the authorization spec and demand a real OAuth flow. Pattern two is one of the two clean answers to that boundary; the Functions extension's built-in Entra auth is the other. With a gateway in front, the OAuth conversation happens at the maître d', where it belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern three: APIM as the MCP door
&lt;/h2&gt;

&lt;p&gt;The third option surprised me when I first tried it: APIM can expose a REST API as an MCP server by itself. You pick operations from an API already managed in the gateway, and APIM manufactures MCP tools from them. In that design the function app implements only the REST door, the MCP extension disappears from the codebase, and the second door is generated at the gateway.&lt;/p&gt;

&lt;p&gt;This is genuinely attractive for one class of backend: the one you cannot change. An enterprise has hundreds of APIs already registered in APIM, owned by teams with no bandwidth for an agent project. Pattern three gives every one of them an MCP door without a single code change, and the same gateway policies govern it from day one.&lt;/p&gt;

&lt;p&gt;But the first post argued that tool descriptions are load-bearing: the agent chooses tools based on that text, and vague descriptions produce vague agents. Pattern three moves those descriptions out of the code and into gateway configuration. The team that owns the business logic no longer owns the words the agent reads. In pattern two, the descriptions live in McpToolTrigger attributes, versioned in the same pull request as the logic they describe. That difference sounds small and is not; it decides who fixes the contract when the agent starts picking the wrong tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each pattern is the wrong answer
&lt;/h2&gt;

&lt;p&gt;Pattern one is never wrong so much as incomplete; it governs only the door that agents do not use.&lt;/p&gt;

&lt;p&gt;Pattern two is the wrong answer when there is no code to own. If the backend team cannot or will not adopt the MCP extension, insisting on in-code tools blocks the whole effort. It also costs you nothing to skip if no agent ever calls the system; an MCP door with no visitors is just surface area.&lt;/p&gt;

&lt;p&gt;Pattern three has sharper edges. APIM's MCP support is tools-only today: no MCP resources, no prompts. The Functions extension gained resource and prompt triggers at Build 2026, so if your server needs more than tools, the gateway cannot generate it. Auto-generated tools also inherit the granularity of the REST operations, and a fifty-operation API becomes a fifty-tool server, which is more choices than an agent should have to reason about. And every tool call pays the extra hop, which is fine for governance but worth knowing.&lt;/p&gt;

&lt;p&gt;The honest default for new systems: pattern two, with descriptions in code and the gateway doing identity, limits, and observability. Pattern three for the estate you already have. Pattern one always, underneath both.&lt;/p&gt;

&lt;h2&gt;
  
  
  What deploying it taught me
&lt;/h2&gt;

&lt;p&gt;Three things the documentation did not. The mcpProperties.endpoints contract wants an object keyed by endpoint name, while both the published examples and the Bicep type definitions say array; the deployment error is the only place the truth lives, which is what a preview API version means in practice. The client-facing URL is the API path plus an /mcp suffix, a small trap everyone will hit exactly once. And when a spec-compliant MCP client receives a 401 from the gateway, it does not ask for a header; it starts OAuth dynamic client registration, which APIM answers with a 404. Two different clients have now walked into that same wall from different directions in this series, and it is the strongest argument yet for doing the OAuth setup properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that remains
&lt;/h2&gt;

&lt;p&gt;The first post ended by replacing "MCP or API?" with a better question: where do I want discovery, and where do I want a hard contract? APIM adds the operational half: wherever you put discovery, put a gateway in front of it that knows who is discovering. The kitchen stays the kitchen. The doors stay honest. The maître d' remembers every guest.&lt;/p&gt;

&lt;p&gt;The companion repo will gain an infra/apim variant with the Bicep and policies for patterns two and three. Until then, the &lt;a href="https://github.com/steefjan1/mcp-vs-api-azure-functions" rel="noopener noreferrer"&gt;original sample&lt;/a&gt; is the substrate all of this builds on.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>azure</category>
      <category>mcp</category>
      <category>api</category>
    </item>
  </channel>
</rss>
