<?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: Suresh B</title>
    <description>The latest articles on DEV Community by Suresh B (@jarvisbitztech).</description>
    <link>https://dev.to/jarvisbitztech</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%2F4131430%2F59e300f7-ee7b-469d-8df5-546f6f8033bb.png</url>
      <title>DEV Community: Suresh B</title>
      <link>https://dev.to/jarvisbitztech</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jarvisbitztech"/>
    <language>en</language>
    <item>
      <title>How to Build an MCP Server for Internal Tools: Architecture, Authentication and Error Handling</title>
      <dc:creator>Suresh B</dc:creator>
      <pubDate>Fri, 25 Sep 2026 07:25:58 +0000</pubDate>
      <link>https://dev.to/jarvisbitztech/how-to-build-an-mcp-server-for-internal-tools-architecture-authentication-and-error-handling-1if2</link>
      <guid>https://dev.to/jarvisbitztech/how-to-build-an-mcp-server-for-internal-tools-architecture-authentication-and-error-handling-1if2</guid>
      <description>&lt;p&gt;Most MCP tutorials stop at a local server that prints the weather. That is a fine way to see the protocol work, and a bad model for anything that touches your company's data.&lt;/p&gt;

&lt;p&gt;An internal tools server is different. It runs somewhere other than your laptop, it answers to more than one user, and a mistake in it exposes real records. The protocol changed in 2026 to make that kind of server easier to build, and most of what you will find online still describes the old one.&lt;/p&gt;

&lt;p&gt;This walks through the shape that holds up: what the server owns, how a model calls it, how you decide who is allowed to do what, and how errors should read to something that cannot ask a follow-up question.&lt;/p&gt;

&lt;p&gt;Everything here runs on the MCP Python SDK 2.x against the 2026-07-28 protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server owns, and what it does not
&lt;/h2&gt;

&lt;p&gt;The most useful rule I know for MCP servers: the server is an adapter, not a place to put business logic.&lt;/p&gt;

&lt;p&gt;Your internal API already knows how to look up an order, apply a discount or close a ticket. It already has the permission rules. The MCP server's job is to describe a few of those capabilities in a form a model can use, pass the call through, and translate the answer back.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One tool per business capability&lt;/strong&gt;, not one tool per endpoint. &lt;code&gt;search_orders&lt;/code&gt; is a capability. &lt;code&gt;GET /v2/orders?filter=&lt;/code&gt; is an endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No new permission model.&lt;/strong&gt; Decide authorization from the validated token on each call, using the rules your internal service already has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No hidden state.&lt;/strong&gt; If a piece of work spans two calls, the second call should carry everything it needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A server built this way stays boring, and boring is the goal. It also stays auditable: every tool maps to one capability, and every call carries an identity.&lt;/p&gt;

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

&lt;p&gt;Three roles, and they are easy to mix up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;host&lt;/strong&gt; is the application the user talks to, and the thing that owns the model.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;client&lt;/strong&gt; lives inside the host and speaks MCP to exactly one server.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;server&lt;/strong&gt; is what you are building.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For transport you have two realistic choices. &lt;strong&gt;stdio&lt;/strong&gt; is for a server running as a local subprocess on the same machine as the host: no network, no authentication, ideal while developing. &lt;strong&gt;Streamable HTTP&lt;/strong&gt; is what you want for anything shared, because it is an ordinary HTTP endpoint your platform team already knows how to run.&lt;/p&gt;

&lt;p&gt;That last point is worth dwelling on, because it changed in 2026. Protocol sessions are gone from Streamable HTTP. There is no &lt;code&gt;initialize&lt;/code&gt; handshake and no session id, which means every request is self-describing and any request can land on any instance. The &lt;a href="https://dev.to/jarvisbitztech/mcp-in-2026-what-changed-in-the-2026-07-28-specification-and-how-to-design-production-integrations-16c4"&gt;full list of what the 2026-07-28 revision changed&lt;/a&gt; is worth reading before you port an older server. Your server sits behind a normal load balancer with no sticky routing, scales horizontally, and needs no shared session store.&lt;/p&gt;

&lt;p&gt;If your server does need state across calls, make it explicit: return a handle from the first tool and accept it as an argument on the next. A handle is just a string your application understands, with an expiry and an owner. It survives a request landing on a different instance, which a session never did.&lt;/p&gt;

&lt;h2&gt;
  
  
  A server that does something real
&lt;/h2&gt;

&lt;p&gt;Install the SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"mcp[cli]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a server with two tools over a pretend internal orders service. The structure is what matters; substitute your own API calls.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MCPServer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.mcpserver.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolError&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internal-tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Internal order identifier.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Current fulfilment status.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Order total in cents.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Order id, for example A-1001.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Look up a single order by its identifier.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;internal_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ToolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No order &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;. Order ids look like A-1001. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Use search_orders if you only know the customer.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Customer account name.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Maximum rows to return.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Find orders belonging to one customer account.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;internal_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ToolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No orders for customer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things in that snippet do more work than they appear to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The type hints are the input schema.&lt;/strong&gt; The SDK builds it from your signature, so &lt;code&gt;limit: int&lt;/code&gt; with &lt;code&gt;ge=1, le=50&lt;/code&gt; becomes a constraint the SDK enforces before your function body runs. A model asking for 999 rows gets a validation error back and never reaches your internal API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The docstring is the description.&lt;/strong&gt; This is the text the model reads when deciding whether to call your tool. It is documentation for a reader who cannot ask you what you meant, so write it for that reader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The return type is the output schema.&lt;/strong&gt; Declaring &lt;code&gt;-&amp;gt; Order&lt;/code&gt; makes the SDK publish an &lt;code&gt;output_schema&lt;/code&gt; alongside the input schema in &lt;code&gt;tools/list&lt;/code&gt;, before the tool is ever called. The host gets machine-readable data rather than a sentence it has to parse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Annotated[..., Field(description=...)]&lt;/code&gt;&lt;/strong&gt; puts a description on individual arguments. Worth doing for anything whose meaning is not obvious from the name.&lt;/p&gt;

&lt;p&gt;One detail to be aware of when you return a list: the structured result is wrapped in a &lt;code&gt;result&lt;/code&gt; key, because a bare list is not a JSON object. &lt;code&gt;search_orders&lt;/code&gt; above comes back as &lt;code&gt;{"result": [{"order_id": "A-1001", ...}]}&lt;/code&gt;. Harmless once you know, confusing when you do not.&lt;/p&gt;

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

&lt;p&gt;For a shared server, this is the part worth slowing down for.&lt;/p&gt;

&lt;p&gt;The model is not your user. The host is not your user. A token arrives with each request, and everything you decide flows from validating it yourself.&lt;/p&gt;

&lt;p&gt;The SDK has no opinion about what a valid token looks like, which is correct: yours probably comes from your own identity provider. You implement one method.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AnyHttpUrl&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MCPServer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.provider&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TokenVerifier&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AuthSettings&lt;/span&gt;

&lt;span class="n"&gt;RESOURCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://tools.internal.example.com/mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyTokenVerifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TokenVerifier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;identity_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;introspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
            &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RESOURCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internal-tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token_verifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;MyTokenVerifier&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AuthSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;issuer_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AnyHttpUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.internal.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;resource_server_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AnyHttpUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RESOURCE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;required_scopes&lt;/span&gt;&lt;span class="o"&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;orders:read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;validate_token_resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;None&lt;/code&gt; rejects the request. That is the whole contract.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthSettings&lt;/code&gt; describes your server as a resource server. &lt;code&gt;issuer_url&lt;/code&gt; is the authorization server that issues tokens, &lt;code&gt;resource_server_url&lt;/code&gt; is this endpoint's public URL, &lt;code&gt;required_scopes&lt;/code&gt; must all be present on every token, and &lt;code&gt;validate_token_resource=True&lt;/code&gt; refuses any token whose audience is not this server.&lt;/p&gt;

&lt;p&gt;Turn that last one on. Without it, a token minted for a different service with the same signature is accepted by yours, which is the confused deputy problem wearing a hat. The SDK will make it the default for resource servers in 3.0; there is no reason to wait.&lt;/p&gt;

&lt;p&gt;From those settings, the SDK serves an RFC 9728 Protected Resource Metadata document, which is how a client that has never seen your server discovers where to get a token:&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;"resource"&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://tools.internal.example.com/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;"authorization_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="s2"&gt;"https://auth.internal.example.com/"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"orders:read"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bearer_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"header"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scopes are not enough
&lt;/h3&gt;

&lt;p&gt;Scopes answer "may this caller use this tool". They do not answer "may this caller see this row".&lt;/p&gt;

&lt;p&gt;That second question has to be answered inside the tool, per call, from the validated identity:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.middleware.auth_context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_access_token&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Find orders belonging to one customer account.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_access_token&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;internal_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;acting_as&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# the internal API applies its own rules
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push the identity down to the service that already owns the permission rules, rather than reimplementing them in the adapter. Filter before you fetch, not after: data the caller may not see should never be loaded into a variable in your process, let alone into a model's context. That is the same constraint that shapes &lt;a href="https://dev.to/jarvisbitztech/rag-architecture-in-2026-a-production-blueprint-for-retrieval-augmented-generation-139e"&gt;retrieval design in production RAG systems&lt;/a&gt;, where the permission filter has to live inside the query rather than in a pass over the results afterwards. It is the kind of &lt;a href="https://www.jarvisbitz.com/" rel="noopener noreferrer"&gt;authorization aware retrieval&lt;/a&gt; work that is far cheaper to design in than to retrofit, and it is worth more than any amount of prompt-level guarding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error handling
&lt;/h2&gt;

&lt;p&gt;Errors in an MCP server have an unusual audience: a model, which cannot ask you a clarifying question and will try something else based only on what you wrote.&lt;/p&gt;

&lt;p&gt;There are three outcomes, and the difference matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raise &lt;code&gt;ToolError&lt;/code&gt;&lt;/strong&gt; when the tool ran correctly and the answer is a problem the caller could fix. The request succeeds at the protocol level and returns a result with &lt;code&gt;is_error&lt;/code&gt; set to true, carrying your message where the model reads it. The structured content is empty, because a failed call has no value to structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raise &lt;code&gt;MCPError&lt;/code&gt;&lt;/strong&gt; when the protocol itself is the problem, not the tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let anything else escape&lt;/strong&gt; and it is a crash. The model learns only that the call failed; your log gets the traceback. That is the correct behaviour for an unexpected bug, and the wrong outcome for anything you could have anticipated.&lt;/p&gt;

&lt;p&gt;The difference between a good and bad tool error is entirely in the message. Compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error executing tool get_order: 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error executing tool get_order: No order 'NOPE'. Order ids look like
A-1001. Use search_orders if you only know the customer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are one line. The second tells the model what went wrong, what the right shape looks like, and which tool to try instead. It will often recover on the next turn without the user seeing anything.&lt;/p&gt;

&lt;p&gt;Write error messages as instructions to a competent colleague who cannot see your code.&lt;/p&gt;

&lt;p&gt;Two more cases worth handling deliberately: an expired handle should say so explicitly rather than returning empty results, and a timeout against a slow internal service should say the work may still be running, because a model that assumes failure will retry an operation that already happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;For local development against a host on the same machine:&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;# stdio
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a shared deployment:&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;streamable-http&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 SDK also exposes a Streamable HTTP ASGI application, so you can mount the server inside an existing FastAPI or Starlette service rather than running a separate process. For internal tooling that is often the least disruptive option: the MCP endpoint lives beside the API it wraps, behind the same ingress and the same auth infrastructure.&lt;/p&gt;

&lt;p&gt;Test with the Inspector before you point a model at it. &lt;code&gt;mcp dev server.py&lt;/code&gt; starts your server with an interactive UI where you can read the generated schemas and call tools by hand. Most schema mistakes are obvious there and invisible once a model is in the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes worth knowing about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buffering proxies.&lt;/strong&gt; Streamed responses depend on the proxy passing events through as they arrive. An nginx-style proxy that buffers will make a working server look dead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool name collisions.&lt;/strong&gt; A host aggregating several servers may see two &lt;code&gt;search&lt;/code&gt; tools. Name yours for the domain, not the verb.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema drift.&lt;/strong&gt; Your tool signature is a published contract. Changing an argument's meaning without changing its name breaks callers silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting annotations.&lt;/strong&gt; Tool annotations from another server are hints, not security. Never let one decide whether an action is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forwarded tokens.&lt;/strong&gt; Do not pass the caller's token through to unrelated downstream services. Exchange it for one scoped to the specific thing you are calling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin validation.&lt;/strong&gt; For HTTP servers, validate the &lt;code&gt;Origin&lt;/code&gt; header and bind local servers to localhost, or a web page can reach a server that was only meant to be local.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Readiness checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Ready when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;One tool per business capability; no business logic that belongs in the internal API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schemas&lt;/td&gt;
&lt;td&gt;Input constraints on every argument; a declared return type; descriptions written for a model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State&lt;/td&gt;
&lt;td&gt;No dependence on a session; cross-call state is an explicit handle with an expiry and an owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;Tokens verified on every call; audience validation on; scopes required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization&lt;/td&gt;
&lt;td&gt;Row-level decisions made per call from the validated identity, by the service that owns the rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ToolError&lt;/code&gt; for anything the caller can fix, with a message naming the fix; crashes logged, not leaked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;stdio locally, Streamable HTTP when shared; Origin validated; proxies pass events unbuffered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;Rate limits; idempotency on writes; logs that record identity and tool, not payloads&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Build the smallest server that exposes one capability, wire the token check, and call it from a real host before adding a second tool. The gap between a demo server and an internal one is almost entirely in the parts that have nothing to do with the model, which is the same gap the &lt;a href="https://dev.to/jarvisbitztech/production-llm-application-architecture-in-2026-an-engineering-checklist-beyond-the-demo-1942"&gt;production readiness checklist for LLM applications&lt;/a&gt; covers for the system around it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and reviewed, edited and approved by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>python</category>
      <category>tutorial</category>
      <category>backend</category>
    </item>
    <item>
      <title>Production LLM Application Architecture in 2026: An Engineering Checklist Beyond the Demo</title>
      <dc:creator>Suresh B</dc:creator>
      <pubDate>Wed, 23 Sep 2026 10:06:32 +0000</pubDate>
      <link>https://dev.to/jarvisbitztech/production-llm-application-architecture-in-2026-an-engineering-checklist-beyond-the-demo-1942</link>
      <guid>https://dev.to/jarvisbitztech/production-llm-application-architecture-in-2026-an-engineering-checklist-beyond-the-demo-1942</guid>
      <description>&lt;p&gt;The demo worked. That is the problem.&lt;/p&gt;

&lt;p&gt;A demo usually proves one narrow path under friendly conditions. Production exposes the same feature to unfamiliar inputs, real load, dependency failures and changes you did not schedule.&lt;/p&gt;

&lt;p&gt;The gap is not only a model problem. Much of it is ordinary application, security and operations engineering.&lt;/p&gt;

&lt;p&gt;This is a checklist for closing it. It is organised by how things fail, not by vendor feature, because the vendors keep renaming everything and the failure classes stay put.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the demo proves nothing
&lt;/h2&gt;

&lt;p&gt;This checklist groups the main production failure modes into seven classes. A demo usually exercises only a small part of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure class&lt;/th&gt;
&lt;th&gt;The question nobody asked during the demo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input&lt;/td&gt;
&lt;td&gt;What happens when the input is hostile, enormous, or in the wrong language?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval&lt;/td&gt;
&lt;td&gt;What happens when the right document is not found, or the wrong user's document is?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;What happens when the provider is slow, down, or retires the model?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;What happens when the model returns something your code cannot parse?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;What happens when the model does something real and wrong?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;What happens when usage grows ten times?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;What happens when it breaks and nobody knows why?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Work through them and you have a useful production readiness baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that survives contact with users
&lt;/h2&gt;

&lt;p&gt;A useful production architecture can be thought of as six core pieces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gateway.&lt;/strong&gt; One place where requests enter, get authenticated, rate limited and tagged. Without this you cannot answer "who spent that money" or "which tenant caused that spike".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An orchestrator.&lt;/strong&gt; The thing that decides what happens in what order: classify, retrieve, call, validate, respond. This is your application logic. It belongs in code you own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval.&lt;/strong&gt; Whatever finds the facts. Its most important property is not similarity scoring. It is knowing what the current user is allowed to see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A model adapter.&lt;/strong&gt; One internal interface that keeps provider specific API details in a single place. It should make model migrations testable, let you pin a production model or snapshot where the provider supports it, and give the team a documented migration or fallback path.&lt;/p&gt;

&lt;p&gt;A second active provider is optional. It can be worth the cost when business continuity requirements justify it, but it also brings a second set of behaviours to evaluate and more surface to keep current. Decide it on requirements rather than by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A policy layer.&lt;/strong&gt; Schema validation, content checks, permission checks, approval gates. Everything that decides whether the model's output gets to become an action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telemetry.&lt;/strong&gt; Traces, costs and evaluation results. If you cannot see it, you cannot fix it.&lt;/p&gt;

&lt;p&gt;Notice what is not on that list: a framework. You may use one. You will still need all six.&lt;/p&gt;

&lt;p&gt;The flow through them is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
  |
Gateway .......... authentication, rate limits, request ID
  |
Orchestrator
  |---- authorization scoped retrieval
  |---- model adapter
  |
Output validation and policy
  |
Approval when required
  |
Tools and side effecting actions
  |
Response

Telemetry, cost, security and evaluation metadata wrap the full path.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read only work such as retrieval and lookups can happen earlier in that path. The ordering that matters is that a high impact action does not execute before the policy and approval gate it requires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls that earn their place
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Authorize before you retrieve
&lt;/h3&gt;

&lt;p&gt;One serious class of RAG failure is applying authorization only after retrieval.&lt;/p&gt;

&lt;p&gt;The rule worth holding onto: restricted content should not become model context unless the current principal is authorized to access it. Prefer authorization aware or pre retrieval filtering where your datastore and access model support it.&lt;/p&gt;

&lt;p&gt;When the retrieval layer can apply the authorization filter efficiently, it can also reduce the candidate set and the amount of search work. Treat tenant isolation as a first class concern rather than a configuration detail, because a leak across tenants is the kind of incident that ends contracts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treat external content as data, not instructions
&lt;/h3&gt;

&lt;p&gt;Anything the model reads can try to instruct it. That includes the obvious case of a user typing an attack into a chat box, and the less obvious case of instructions hidden inside a retrieved document, a scraped web page, an uploaded file or a tool response. The second kind is harder to spot, because the text arrives through a channel you trusted.&lt;/p&gt;

&lt;p&gt;Design on the assumption that some of it gets through. Enforce authorization and tool permissions in application code rather than trusting a prompt to hold the line. Give each tool the narrowest scope that lets it work. Keep tenant boundaries enforced at the data layer.&lt;/p&gt;

&lt;p&gt;Be deliberate about what leaves the system too. Secrets and sensitive data should not end up in prompts, logs or traces without a reason, and captured prompts, documents and outputs need retention and redaction rules that match the product.&lt;/p&gt;

&lt;p&gt;Finally, bound consumption. Put limits on tokens, tool calls, agent steps and expensive operations, so a hostile or malformed input cannot turn into an unbounded bill or an infinite loop. &lt;a href="https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/" rel="noopener noreferrer"&gt;OWASP's GenAI security material&lt;/a&gt; is the practical reference here, and its current edition is worth reading end to end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treat model output as untrusted input
&lt;/h3&gt;

&lt;p&gt;Your code is going to parse this. Validate it against a schema before anything downstream touches it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://platform.openai.com/docs/guides/structured-outputs" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt; and &lt;a href="https://ai.google.dev/gemini-api/docs/structured-output" rel="noopener noreferrer"&gt;Gemini&lt;/a&gt; both support schema constrained structured outputs. When the feature is supported and the response completes normally, it can enforce the expected structure far more reliably than prompting for JSON alone. You still need to handle refusals, incomplete responses and application level validation.&lt;/p&gt;

&lt;p&gt;Schema compliance is not semantic correctness. The provider can constrain the shape; your code still has to check the values, the permissions and the business rules. A perfectly valid JSON object can still contain a refund amount nobody is authorised to issue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Put explicit approval in front of high impact actions
&lt;/h3&gt;

&lt;p&gt;Not everything needs approval. Reading a document does not.&lt;/p&gt;

&lt;p&gt;Explicit user confirmation or human approval earns its friction when an action is destructive, externally visible, financially significant, privilege changing, legally or compliance sensitive, or difficult to reverse. Reading and retrieval normally need far less.&lt;/p&gt;

&lt;p&gt;Design can lower the bar. Staged operations, reversible changes and compensating actions sometimes reduce what needs approving in the first place, which is usually better than adding another confirmation dialog.&lt;/p&gt;

&lt;p&gt;Whatever you gate, log it: who or what requested the action, what was approved, the resulting operation, and the final state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability is mostly boring
&lt;/h2&gt;

&lt;p&gt;Timeouts on every external call. Bounded retries, not infinite ones. A fallback that degrades honestly rather than pretending.&lt;/p&gt;

&lt;p&gt;Five failure modes catch people out repeatedly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial streams.&lt;/strong&gt; The connection drops halfway through a response. Your UI shows half an answer and your database saves it as complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale caches.&lt;/strong&gt; You cached an answer that was true last week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema drift.&lt;/strong&gt; Your code, SDK or provider response evolves and an old parser assumption quietly becomes wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry storms.&lt;/strong&gt; The provider slows down, every client retries, and you complete the outage for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsafe retries.&lt;/strong&gt; Retries around side effecting operations need idempotency or state reconciliation. A timeout does not prove the original operation failed, and repeating it can create a second charge, a second booking or a second email.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are AI problems. They are the same distributed systems problems that have always existed, which is good news, because the fixes are well understood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost, latency and the thing you cannot see
&lt;/h2&gt;

&lt;p&gt;Attribute cost per request, per tenant and per feature from day one. Retrofitting this is miserable, and without it your only lever when the bill arrives is panic.&lt;/p&gt;

&lt;p&gt;For latency, measure the median and the 95th percentile separately. Averages hide exactly the users who are having the worst time and telling other people about it.&lt;/p&gt;

&lt;p&gt;One example from a client system we rebuilt. A document assistant was answering in roughly 10 to 30 seconds depending on the query, across a knowledge base of more than 120 GB and approximately 40,000 PDF documents. The rebuild put dynamic intent detection and routing in front of retrieval, made document retrieval authorization aware, and added caching and optimised context selection. First token response came down to under three seconds in typical interactions. This project illustrates the production AI engineering work &lt;a href="https://jarvisbitz.com" rel="noopener noreferrer"&gt;JarvisBitz Engineering&lt;/a&gt; focuses on.&lt;/p&gt;

&lt;p&gt;The transferable lesson is about method rather than numbers: measure latency by stage before blaming the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evaluation is a release gate, not a research project
&lt;/h3&gt;

&lt;p&gt;Start with a small representative set covering common requests, known failure cases, edge cases and adversarial inputs.&lt;/p&gt;

&lt;p&gt;Not every task has one known good answer. Depending on the task, use reference answers, expected outcomes, or explicit acceptance criteria. A summarisation feature and a booking agent need different definitions of correct.&lt;/p&gt;

&lt;p&gt;Add real production failures to the set as they appear. The bugs your users find are the most valuable test cases you will ever get for free.&lt;/p&gt;

&lt;p&gt;Run the relevant evaluations before any change to prompts, retrieval, models, tools or workflow logic that could affect behaviour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make every change reversible
&lt;/h3&gt;

&lt;p&gt;Version your prompts. Record the exact model or model snapshot where the provider exposes one. Record the retrieval or index version, and the application configuration that shaped the request.&lt;/p&gt;

&lt;p&gt;For risky changes, use feature flags, a staged rollout or a canary deployment, and compare behaviour against your evaluation gates before going wide. Keep a rollback path you have actually tested, not one you assume works.&lt;/p&gt;

&lt;p&gt;The goal is easy to state and easy to neglect: when a bad response turns up in a support ticket, you should be able to trace it to the exact model, prompt, retrieval and configuration versions that produced it. Without that, every investigation starts from guesswork.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make traces answer questions
&lt;/h3&gt;

&lt;p&gt;A useful production trace should be able to identify, where relevant: a request or trace ID, a tenant or principal reference that does not expose unnecessary personal data, the model and version, the prompt or configuration version, the retrieval or index version, tool calls with their results or status, retries, fallbacks, token usage, cost, per stage latency, and the final outcome or evaluation signal.&lt;/p&gt;

&lt;p&gt;That is not a licence to log everything. Do not indiscriminately capture raw prompts, retrieved documents or secrets. Apply redaction and retention controls appropriate to the product and the data it handles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moving parts are genuinely moving
&lt;/h2&gt;

&lt;p&gt;This year made the case better than any argument could.&lt;/p&gt;

&lt;p&gt;OpenAI's Assistants API shut down on 26 August 2026, one year after notice, with the Responses and Conversations APIs as the replacement direction. Its transcription models were deprecated the same day, with removal set for 26 February 2027. A batch of legacy audio and realtime models goes on 20 January 2027. All of those dates are on OpenAI's &lt;a href="https://developers.openai.com/api/docs/deprecations" rel="noopener noreferrer"&gt;deprecations page&lt;/a&gt;, which is worth a bookmark.&lt;/p&gt;

&lt;p&gt;Google made its &lt;a href="https://ai.google.dev/gemini-api/docs/interactions-overview" rel="noopener noreferrer"&gt;Interactions API&lt;/a&gt; generally available in June 2026 and recommends it for all new projects, with the original generateContent API now considered legacy but still fully supported. Separately, Vertex AI Platform became Gemini Enterprise Agent Platform, taking most of the surrounding product names with it, which Google documents in a &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/vertex-ai-name-changes" rel="noopener noreferrer"&gt;name changes table&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;OWASP published a new &lt;a href="https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/" rel="noopener noreferrer"&gt;Top 10 for LLM Applications&lt;/a&gt; on 3 August 2026, with updated rankings and research drawn from real-world AI security incidents.&lt;/p&gt;

&lt;p&gt;Two conclusions follow. Keep a deprecation calendar with real dates and a named owner, because these announcements do not arrive when it is convenient. And keep provider specific code behind your adapter, because "we will migrate later" ages badly when later has a published date attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Ready when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input&lt;/td&gt;
&lt;td&gt;Length limits, content checks, and a known answer for hostile input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval&lt;/td&gt;
&lt;td&gt;Authorization applied before content becomes context; tenant isolation enforced; a defined behaviour for "nothing found"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;Provider specific code isolated; production model or snapshot pinned where possible; migration or fallback path tested; deprecation calendar owned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;Schema enforced by the provider and validated again by your code, including values and business rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;Risk based approval for destructive, externally visible, financially significant, privilege changing or hard to reverse actions, with an audit trail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliability&lt;/td&gt;
&lt;td&gt;Timeouts, bounded retries, honest degradation, idempotency or state reconciliation on writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;Prompt injection has a threat model; tool scopes are least privilege; tenant and data boundaries enforced; sensitive data has retention and redaction rules; token, tool and step consumption bounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Attributed per request, tenant and feature; alerts on the trend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;Median and 95th percentile per stage, not just end to end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation&lt;/td&gt;
&lt;td&gt;A representative set that gates changes to prompts, retrieval, models, tools and workflow logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release&lt;/td&gt;
&lt;td&gt;Prompt, model and configuration versions recorded; evaluations gate changes; risky changes can be staged; rollback tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;Searchable traces, alerts and SLOs, version metadata, and an owner with a runbook who can diagnose failures&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What you can safely leave until later
&lt;/h2&gt;

&lt;p&gt;Not everything needs doing before launch, and pretending otherwise is how features never ship.&lt;/p&gt;

&lt;p&gt;Fine tuning can often wait until evaluation evidence shows that prompting, retrieval, model choice or workflow changes are not closing the gap. A multi agent architecture can wait until a concrete need appears and one agent is demonstrably not enough. Self hosting a model can wait until you have a specific reason: cost at scale, data residency, a control requirement, or a capability no provider offers.&lt;/p&gt;

&lt;p&gt;For a production feature handling real users, data or actions, each checklist area should have an explicit answer before launch, even if the first answer is deliberately simple. "We cap input length at 4,000 characters and reject the rest" is a real answer. "We have not thought about it" is not.&lt;/p&gt;

&lt;p&gt;Take an existing feature and run it through the failure class table before you build anything new. The gaps you find there are usually worth more than the next feature on the roadmap.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and reviewed, edited and approved by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>production</category>
    </item>
    <item>
      <title>Production Voice AI Architecture in 2026: How Realtime Conversational Agents Are Built</title>
      <dc:creator>Suresh B</dc:creator>
      <pubDate>Tue, 22 Sep 2026 13:52:32 +0000</pubDate>
      <link>https://dev.to/jarvisbitztech/production-voice-ai-architecture-in-2026-how-realtime-conversational-agents-are-built-558p</link>
      <guid>https://dev.to/jarvisbitztech/production-voice-ai-architecture-in-2026-how-realtime-conversational-agents-are-built-558p</guid>
      <description>&lt;p&gt;Every vendor documents its own path and calls it "how to build a voice agent". Read three of them and you have three incompatible mental models, none of which tells you which one you should be using. That is the actual first decision, and it is an architecture decision rather than a vendor one: &lt;strong&gt;where does reasoning live, and where do tools run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A useful way to group current production voice-agent designs is into three shapes. A single realtime model that hears speech, decides what to do and speaks back. A full-duplex conversational model that handles the conversation and delegates the thinking to a backend you choose. And a chained pipeline where speech-to-text, an agent and text-to-speech are separate stages you own. Plenty of real systems are hybrids of these, so treat the grouping as a way to reason about the tradeoffs rather than a closed taxonomy. Each shape is a legitimate production choice, each fails in a different way, and the components you need around them are almost identical.&lt;/p&gt;

&lt;p&gt;None of them wins outright. Which one is right depends on where your business logic already lives, which is why the decision belongs to you rather than to whichever vendor page you happened to read first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture 1: a single realtime model
&lt;/h2&gt;

&lt;p&gt;Speech goes in, speech comes out, and the same model interprets audio, decides what to do, calls your tools and generates the reply. In OpenAI's Agents SDK a common setup pairs a &lt;code&gt;RealtimeAgent&lt;/code&gt; with a &lt;code&gt;RealtimeSession&lt;/code&gt;: the session connects over WebRTC in a browser or WebSocket from a server and handles audio turns, tools, interruptions and handoffs, while the agent carries tools, handoffs, guardrails and business logic. Underneath that, at the Realtime API itself, the central abstraction is the realtime session and the conversation events flowing through it. Google's Live API is the same shape over a stateful WebSocket connection, either server-to-server (your backend relays audio) or client-to-server (the browser connects directly, which Google notes generally offers better streaming performance because it skips the relay through your backend, but should use ephemeral tokens rather than API keys in production).&lt;/p&gt;

&lt;p&gt;What you get for that is one model, one session, the fewest moving parts, and a model that hears tone and hesitation instead of a flattened transcript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it breaks.&lt;/strong&gt; Long tool calls are the first problem. The conversation is inside the session, so a tool that takes several seconds risks dead air unless the platform can say something while it waits. Both vendors have an answer, and they are different. OpenAI uses preambles, the short spoken updates that tell the caller work is happening, and its prompting guide notes &lt;code&gt;gpt-realtime-2&lt;/code&gt; generates them by default. Gemini handles it with non-blocking function declarations. A tool declared with &lt;code&gt;behavior: NON_BLOCKING&lt;/code&gt; runs asynchronously, and the function response tells the model what to do with the result: surface it immediately, wait until the conversation is idle, or keep it silent for later use. On the current generation this is the default rather than an opt-in. Gemini 3.8 Live defaults to asynchronous execution, keeps synchronous &lt;code&gt;BLOCKING&lt;/code&gt; available for backwards compatibility, and supports function scheduling. Gemini 3.8 Live Extended Thinking only accepts non-blocking declarations, returns a hard error for synchronous mode, and does not support scheduling configurations at all, which is worth knowing before you design around it. One practical note: Google's current documentation disagrees on the spelling of the immediate scheduling value, using &lt;code&gt;INTERRUPT&lt;/code&gt; in the Live tools guide and &lt;code&gt;INTERRUPTED&lt;/code&gt; on the 3.8 model and capabilities pages, so use whichever the SDK or schema you deploy actually accepts.&lt;/p&gt;

&lt;p&gt;Policy is the next one. Burying complex business rules in the voice prompt makes those rules harder to test, version and audit, and it increases the amount of policy the conversational model has to carry alongside the instructions that keep its speech natural. The problem people notice last is auditability. Intermediate text exists, but what you are reading is the model's own transcription of a decision it has already made, not a text step you can inspect and gate before it becomes speech.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture 2: full-duplex with a delegated backend
&lt;/h2&gt;

&lt;p&gt;OpenAI's GPT-Live is the clearest implementation of this shape. The voice model does one job: hold the conversation, listen while speaking (that is the full-duplex part), and decide when to ask a backend for help. The backend does the reasoning and the tool work, and it is a component you choose. OpenAI calls sending work to the backend &lt;strong&gt;delegation&lt;/strong&gt; and documents two modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Responses delegation:&lt;/strong&gt; an OpenAI-hosted model runs the backend reasoning; GPT-Live supplies conversation context and manages the calls; your application still runs its own function tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client delegation:&lt;/strong&gt; you connect your own agent, workflow or service, with whatever model and harness you already run, and return results to GPT-Live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The docs are explicit about the division of labour: keep speaking behaviour in the live model's prompt and business rules in the backend prompt. Your application checks permissions, obtains confirmations, runs the functions that touch your systems and saves task progress.&lt;/p&gt;

&lt;p&gt;Two things follow from that, and both need designing rather than assuming. The caller can keep talking while backend work runs, which is the entire point, but it also means &lt;strong&gt;an interruption does not automatically cancel backend work&lt;/strong&gt;. Your application decides whether to finish or cancel it, so a caller who says "no, cancel that" while the backend is mid-refund needs that path wired deliberately. Cancellation and interruption are not the same as undoing a side effect: the application still needs task state, retry reconciliation and explicit confirmation of completed actions. The other is more encouraging: this is a migration path rather than a rebuild. If you already have a text agent with tools and business logic, you can put a voice interface in front of it without moving any of that into a speech prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it breaks.&lt;/strong&gt; You now have two prompts, two failure surfaces and a delegation boundary to observe. The backend can become a major term in the latency budget and should be measured separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture 3: a chained pipeline
&lt;/h2&gt;

&lt;p&gt;Speech-to-text, then your agent as ordinary text, then text-to-speech. OpenAI documents it as the path to choose when you want to inspect or transform text between stages; examples of orchestration frameworks include LiveKit Agents and Pipecat, both actively released (livekit-agents 1.8.2 on 15 September 2026, pipecat-ai 1.11.0 on 18 September 2026).&lt;/p&gt;

&lt;p&gt;Every stage is inspectable and replaceable: you can store the transcript, run policy checks before the agent answers, call internal systems, and only synthesise speech once the workflow reaches an approved answer. With a clean interface between stages, you can often swap the STT provider without changing the agent's core business logic. Much of an existing text agent, its tools and its policy logic can be reused, though voice still needs its own evaluation and its own handling for turn-taking, interruptions, audible latency and speech. For workflows that require inspectable intermediate text, explicit policy checks or approval before speech is generated, a chained pipeline offers a clearer control surface.&lt;/p&gt;

&lt;p&gt;The cost is that you own turn-taking. Knowing when the user has finished speaking is the hardest unglamorous problem in voice, and the frameworks have specialised for it: LiveKit defaults to its turn-detector model for turn completion, which predicts the end of a turn from the meaning of the speech as well as its acoustics, with VAD-only, STT endpointing, realtime-model and manual modes available instead, plus explicit handling for false interruptions, where the framework detects speech and interrupts the agent but the transcription comes back empty. Pipecat splits the problem in two: a turn starts on VAD, or on an arriving transcription when VAD missed quiet speech, with an optional minimum-word strategy on top, and the turn end defaults to its Smart Turn model with a fixed speech timeout as the simpler alternative. You also pay latency at every stage boundary, and the agent reads a transcript rather than hearing the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The components every voice agent needs
&lt;/h2&gt;

&lt;p&gt;Whichever shape you choose, the same list applies. Many production failure modes come from missing one of these components rather than from choosing the wrong high-level architecture.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What it must do&lt;/th&gt;
&lt;th&gt;Notes that apply across vendors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;Carry audio and events&lt;/td&gt;
&lt;td&gt;WebRTC for browsers and mobile, WebSockets for server-side audio, SIP for telephony. OpenAI documents all three for its voice APIs; Gemini Live is WebSocket, with WebRTC available through partner integrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credentials&lt;/td&gt;
&lt;td&gt;Never ship a key to a client&lt;/td&gt;
&lt;td&gt;The mechanism is per platform. Realtime API: ephemeral client secrets minted server-side via &lt;code&gt;POST /v1/realtime/client_secrets&lt;/code&gt;, or the unified interface where your server brokers the browser's connection. GPT-Live: the project API key stays on a trusted server that sets up the session. Gemini: ephemeral tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn detection&lt;/td&gt;
&lt;td&gt;Decide when the caller has finished&lt;/td&gt;
&lt;td&gt;Server VAD (silence-based) or semantic detection (model decides from the words). OpenAI exposes &lt;code&gt;server_vad&lt;/code&gt;, tuned with &lt;code&gt;threshold&lt;/code&gt;, &lt;code&gt;prefix_padding_ms&lt;/code&gt; and &lt;code&gt;silence_duration_ms&lt;/code&gt;, and &lt;code&gt;semantic_vad&lt;/code&gt;, tuned with an optional &lt;code&gt;eagerness&lt;/code&gt; value of &lt;code&gt;low&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt; or &lt;code&gt;auto&lt;/code&gt;; frameworks add turn-detector models and endpointing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interruption handling&lt;/td&gt;
&lt;td&gt;Stop instantly and forget what was not heard&lt;/td&gt;
&lt;td&gt;Who does this depends on the transport. On Realtime WebRTC and SIP connections the server owns the output buffer and truncates unplayed audio automatically. On a WebSocket connection the client owns playback, so it must stop it and send &lt;code&gt;conversation.item.truncate&lt;/code&gt; with an &lt;code&gt;audio_end_ms&lt;/code&gt;, or the model believes it said things the caller never heard. Note that this trims the audio: OpenAI's docs state the model cannot precisely align transcript to audio, so the transcript is not a reliable record of exactly what was heard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool execution&lt;/td&gt;
&lt;td&gt;Run work, with permissions&lt;/td&gt;
&lt;td&gt;Tools may be local functions, or remote MCP servers the platform calls for you. The Realtime API's MCP tool takes a &lt;code&gt;server_label&lt;/code&gt; plus either &lt;code&gt;server_url&lt;/code&gt; for a remote server or &lt;code&gt;tunnel_id&lt;/code&gt; for a local one reached through Secure MCP Tunnel, with optional &lt;code&gt;allowed_tools&lt;/code&gt; and &lt;code&gt;require_approval&lt;/code&gt;. &lt;code&gt;connector_id&lt;/code&gt; is deprecated for models released after 1 September 2026, though existing models keep connector support. Decide deliberately which tools need approval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action state and idempotency&lt;/td&gt;
&lt;td&gt;Prevent duplicate, stale or wrongly confirmed side effects&lt;/td&gt;
&lt;td&gt;Track each side-effecting action with its own operation identifier, and each changed request with a task revision, so a later request supersedes an earlier one. Keep authoritative task state in your application. After a timeout or a lost response, reconcile against the backend before retrying, because the action may already have completed. Only tell the caller an action succeeded once the backend has confirmed the state change. Nothing in the protocol or the model gives you exactly-once execution; this is application work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend or agent boundary&lt;/td&gt;
&lt;td&gt;Hold business rules&lt;/td&gt;
&lt;td&gt;Whether it is a delegated backend or the agent stage of a pipeline, business rules belong here, not in the voice prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session state and resumption&lt;/td&gt;
&lt;td&gt;Survive a dropped connection&lt;/td&gt;
&lt;td&gt;Calls drop. Decide what a reconnect restores and what it must not replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transcripts&lt;/td&gt;
&lt;td&gt;Produce a record&lt;/td&gt;
&lt;td&gt;Needed for evaluation, disputes and improvement. Check what each API exposes and what has to be enabled. GPT-Live exposes input and output transcript delta events in its event stream. On the Realtime API, assistant output audio has transcript events, while input audio transcription must be configured when you need it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost accounting&lt;/td&gt;
&lt;td&gt;Attribute spend per call&lt;/td&gt;
&lt;td&gt;Voice sessions can be billed by duration (GPT-Live is billed per second, and backend model and tool usage is billed separately), so per-call cost is a different calculation from text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation&lt;/td&gt;
&lt;td&gt;Test the conversation and the task&lt;/td&gt;
&lt;td&gt;See below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Testing the conversation and the task
&lt;/h3&gt;

&lt;p&gt;OpenAI's evaluation guide makes a distinction worth borrowing: test both the conversation and the completed task. For a booking assistant, listen to the confirmation &lt;strong&gt;and&lt;/strong&gt; check the correct appointment was saved. For GPT-Live specifically, their guide separates task and tool outcomes, conversational timing, speech and language, and session reliability. It also recommends adding complexity in stages: synthetic speech for repeatable single-turn tests, then replayed human recordings, then an independent simulated caller for multi-turn conversations with interruptions and corrections. If you measure latency, change one factor at a time and keep the caller, recording, backend model, prompt, transport, audio cadence and grader fixed when comparing frontend models. Measure acknowledgements like "I'm checking" separately from the answer the caller actually needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If this is true&lt;/th&gt;
&lt;th&gt;Lean toward&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;You already have a text agent with tools and business rules&lt;/td&gt;
&lt;td&gt;Full-duplex with delegation, or a chained pipeline&lt;/td&gt;
&lt;td&gt;Both reuse the agent you have built with the least rework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool calls are fast and the domain is narrow&lt;/td&gt;
&lt;td&gt;Single realtime model&lt;/td&gt;
&lt;td&gt;Fewest moving parts in the voice path, and the model hears the caller directly. Tool implementations, permissions and guardrails still live in your code, but conversational policy, tool coordination and realtime-agent behaviour need careful design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool calls are slow, or work must continue while the caller talks&lt;/td&gt;
&lt;td&gt;Full-duplex with delegation&lt;/td&gt;
&lt;td&gt;It is the shape built for backend work overlapping speech; check your platform's cancel semantics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every decision must be inspectable or approved before it is spoken&lt;/td&gt;
&lt;td&gt;Chained pipeline&lt;/td&gt;
&lt;td&gt;Text between stages is the audit surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telephony is the primary channel&lt;/td&gt;
&lt;td&gt;Any architecture, provided the chosen stack has a SIP or telephony gateway&lt;/td&gt;
&lt;td&gt;Native transport support varies by provider. OpenAI documents SIP for its voice APIs; Gemini Live itself is WebSocket and reaches telephony through partner integrations. Check this first, because it constrains the rest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need to switch languages mid-conversation&lt;/td&gt;
&lt;td&gt;Verify per model&lt;/td&gt;
&lt;td&gt;Language support and whether the language can be set explicitly vary by model family&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need retrieval over private documents&lt;/td&gt;
&lt;td&gt;Any, with retrieval in the backend or agent stage&lt;/td&gt;
&lt;td&gt;Voice changes the interaction layer, not the retrieval architecture. Intent routing and authorization-aware retrieval still belong behind the voice layer. &lt;a href="https://jarvisbitz.com" rel="noopener noreferrer"&gt;JarvisBitz Engineering&lt;/a&gt; works on this class of production AI systems&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Production considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never ship a long-lived API key in browser or mobile code.&lt;/strong&gt; The replacement differs by vendor. OpenAI mints ephemeral client secrets server-side for untrusted clients (&lt;code&gt;POST /v1/realtime/client_secrets&lt;/code&gt;). Google recommends ephemeral tokens rather than standard API keys for production client-to-server Live API applications, and its own documentation marks the ephemeral-token feature as Preview, so recheck its status on the day you publish or ship. Server-to-server architectures are a separate case: there the backend holds the credential and the client never sees it, which is why the server-to-server shape remains the simpler security story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconnects.&lt;/strong&gt; Design what happens when the connection drops mid-sentence: what the user hears, what the agent believes was said, whether a partially completed task resumes or restarts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability at the boundaries.&lt;/strong&gt; Record the stages your application can actually see. For a delegated architecture that means delegation receipt, backend request start, first useful result, tool start and end, result submission, audio arrival and client playback. For a pipeline it means the same discipline at each stage. Compare median and 95th percentile across similar calls rather than averages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the voice prompt about voice.&lt;/strong&gt; In every architecture, the prompt that controls speaking should control speaking: pace, verbosity, when to acknowledge, when to hand off. Business rules, policy and tool workflows belong in the backend or agent layer where they can be tested as text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assume the model list will change.&lt;/strong&gt; Model identifiers and feature availability have changed quickly in 2026. Pin versions in code, and re-read the model page before a release rather than trusting a blog post, including this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to build a voice agent
&lt;/h2&gt;

&lt;p&gt;Voice is the right interface less often than the demos suggest.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The task is a form with four fields.&lt;/strong&gt; A form is faster for the user and cheaper for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The answer has to be exact and remembered.&lt;/strong&gt; An address, a reference number, a dosage: the caller will ask you to repeat it and then want it in writing anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your knowledge base cannot answer the questions in text.&lt;/strong&gt; Voice will not fix that. Get retrieval right first, because a voice layer over a system that does not know the answers is just a faster way to disappoint people.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody can staff the escape path.&lt;/strong&gt; Define an escape path for cases the agent cannot safely or reliably complete. Depending on the product, that may be a human transfer, a callback, a support ticket or another channel. Without one, the agent becomes a wall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody will review the conversations.&lt;/strong&gt; Voice systems fail in ways dashboards do not show. Review a representative sample of interactions, subject to the product's consent, privacy and retention requirements, and treat automated evaluation as a complement to that review rather than a replacement for it. Without either, the system drifts and nobody notices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the decision comes back to where reasoning and tools live, and all three answers are defensible. Pick the one that matches where your business logic already sits, then go through the component list, because that list is what separates a voice agent that survives contact with real callers from one that only demos well.&lt;/p&gt;

&lt;p&gt;If you are building one now, take the component table and mark which components your current design has a real answer for. Whatever you cannot answer for is where to start.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and reviewed, edited and approved by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>agents</category>
      <category>production</category>
    </item>
    <item>
      <title>MCP in 2026: What Changed in the 2026-07-28 Specification and How to Design Production Integrations</title>
      <dc:creator>Suresh B</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:02:51 +0000</pubDate>
      <link>https://dev.to/jarvisbitztech/mcp-in-2026-what-changed-in-the-2026-07-28-specification-and-how-to-design-production-integrations-16c4</link>
      <guid>https://dev.to/jarvisbitztech/mcp-in-2026-what-changed-in-the-2026-07-28-specification-and-how-to-design-production-integrations-16c4</guid>
      <description>&lt;p&gt;If you learned the Model Context Protocol from a tutorial written before August 2026, most of what it taught about transport is now wrong. There is no &lt;code&gt;initialize&lt;/code&gt; handshake. There is no &lt;code&gt;Mcp-Session-Id&lt;/code&gt;. There is no HTTP GET stream to listen on, and streams do not resume. The 2026-07-28 revision, released on 28 July 2026, removed all of them and replaced them with something closer to how the rest of the web works: every request is self-describing, so any request can land on any server instance. Protocol version, client identity and capabilities now travel per request in &lt;code&gt;_meta&lt;/code&gt;; application state that must survive across tool calls can be represented by explicit handles passed as tool arguments.&lt;/p&gt;

&lt;p&gt;This article is a migration-oriented reading of that revision. For each change it states what you can delete, what you must add, and what you have to keep if you still serve clients from the previous era. It ends with an integration decision table and a production checklist. Everything here is drawn from the specification, its changelog, the deprecated-features registry and the extensions documentation as they stand on 25 September 2026; where the SDKs lag the spec, that is stated.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP in September 2026
&lt;/h2&gt;

&lt;p&gt;Three dates anchor the current state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2025-11-25&lt;/strong&gt; is the last "legacy" revision: session-based, with an &lt;code&gt;initialize&lt;/code&gt; handshake and server-initiated requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-28&lt;/strong&gt; is the current revision (release candidate 29 May 2026, final 28 July 2026). The specification's own versioning page calls implementations of this and later revisions &lt;strong&gt;modern&lt;/strong&gt;, implementations of 2025-11-25 and earlier &lt;strong&gt;legacy&lt;/strong&gt;, and anything that supports both &lt;strong&gt;dual-era&lt;/strong&gt;. Those three words are used throughout this article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;28 July 2026&lt;/strong&gt; is also when Tier 1 SDKs shipped for the new revision. The TypeScript SDK was split into focused core, client, server and framework packages, launching at 2.0.0 alongside the protocol. Both SDK lines have moved on since. As of 25 September 2026, the main TypeScript core, client, server and node packages have 2.1.0 releases, while framework adapters can carry their own versions. The Python SDK's current stable v2 release is 2.2.0. Check the current release pages rather than pinning to the launch versions quoted in older write-ups, including this one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The protocol is governed under the Agentic AI Foundation, with a formal SEP (specification enhancement proposal) process and, new in this revision, a feature lifecycle policy with a minimum twelve-month deprecation window. That last item matters for planning: nothing deprecated on 28 July 2026 can be removed before a revision released on or after 28 July 2027.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 2026-07-28 removed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Sessions and &lt;code&gt;Mcp-Session-Id&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Protocol-level sessions are gone from the Streamable HTTP transport (SEP-2567). A modern server does not mint session IDs, does not echo them, and ignores an &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header if a legacy client sends one; a GET or DELETE to the MCP endpoint from an older client gets &lt;code&gt;405 Method Not Allowed&lt;/code&gt;. The consequence the changelog calls out explicitly: &lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;resources/list&lt;/code&gt; and &lt;code&gt;prompts/list&lt;/code&gt; no longer vary per connection. If your server previously returned a different tool set depending on session state, that behaviour has no home in the modern protocol; the tool set is a property of the server (and of the authenticated principal), not of a conversation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;initialize&lt;/code&gt; handshake
&lt;/h3&gt;

&lt;p&gt;There is no &lt;code&gt;initialize&lt;/code&gt; request and no &lt;code&gt;notifications/initialized&lt;/code&gt; (SEP-2575). Instead every request carries its own protocol version and client capabilities in &lt;code&gt;_meta&lt;/code&gt;, and the server accepts or rejects each request independently. A version the server does not support gets an &lt;code&gt;UnsupportedProtocolVersionError&lt;/code&gt; (code &lt;code&gt;-32022&lt;/code&gt;) listing the versions it does support, and the client retries with one of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The GET stream, resumability and a few utilities
&lt;/h3&gt;

&lt;p&gt;The standalone SSE stream a client used to open with HTTP GET is gone, replaced by &lt;code&gt;subscriptions/listen&lt;/code&gt; (below). SSE resumability and message redelivery (&lt;code&gt;Last-Event-ID&lt;/code&gt;, event IDs) are removed from the transport: a broken response stream loses the in-flight request and the client must re-issue it as a new request with a new ID. &lt;code&gt;ping&lt;/code&gt;, &lt;code&gt;logging/setLevel&lt;/code&gt; and &lt;code&gt;notifications/roots/list_changed&lt;/code&gt; are removed too; log level is now requested per call via &lt;code&gt;io.modelcontextprotocol/logLevel&lt;/code&gt; in &lt;code&gt;_meta&lt;/code&gt;, and a server must not emit &lt;code&gt;notifications/message&lt;/code&gt; for a request that did not ask for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server-initiated requests
&lt;/h3&gt;

&lt;p&gt;Servers no longer send their own JSON-RPC requests to clients on any stream. &lt;code&gt;roots/list&lt;/code&gt;, &lt;code&gt;sampling/createMessage&lt;/code&gt; and &lt;code&gt;elicitation/create&lt;/code&gt; still exist as request shapes, but they are now carried inside results (see MRTR below). The &lt;code&gt;notifications/elicitation/complete&lt;/code&gt; notification and the &lt;code&gt;elicitationId&lt;/code&gt; field, both added only in 2025-11-25, are removed with them.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Per-request &lt;code&gt;_meta&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Every request now describes itself. The reserved keys are &lt;code&gt;io.modelcontextprotocol/protocolVersion&lt;/code&gt; (required), &lt;code&gt;io.modelcontextprotocol/clientCapabilities&lt;/code&gt; (required), and &lt;code&gt;io.modelcontextprotocol/clientInfo&lt;/code&gt; (a client SHOULD send it). Servers SHOULD identify themselves with &lt;code&gt;io.modelcontextprotocol/serverInfo&lt;/code&gt; in each result's &lt;code&gt;_meta&lt;/code&gt;. On Streamable HTTP the version is mirrored into the &lt;code&gt;MCP-Protocol-Version&lt;/code&gt; header, and the two must match or the server rejects the request with &lt;code&gt;HeaderMismatch&lt;/code&gt; (&lt;code&gt;-32020&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A modern &lt;code&gt;tools/call&lt;/code&gt; over HTTP looks like this:&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="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/mcp&lt;/span&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="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json, text/event-stream&lt;/span&gt;
&lt;span class="na"&gt;MCP-Protocol-Version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-07-28&lt;/span&gt;
&lt;span class="na"&gt;Mcp-Method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tools/call&lt;/span&gt;
&lt;span class="na"&gt;Mcp-Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;get_weather&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tools/call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_weather"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Seattle, WA"&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;"_meta"&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;"io.modelcontextprotocol/protocolVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"io.modelcontextprotocol/clientInfo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ExampleClient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="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;"io.modelcontextprotocol/clientCapabilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no prior message. That request is valid as the first thing a client ever sends.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;server/discover&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Servers MUST implement &lt;code&gt;server/discover&lt;/code&gt;, which returns supported protocol versions, capabilities and identity. Clients MAY call it up front to choose a version, and on stdio it doubles as the backward-compatibility probe. It is optional for clients: a client is free to send any RPC inline and handle &lt;code&gt;UnsupportedProtocolVersionError&lt;/code&gt; if it guessed wrong. The response is cacheable.&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;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"discover-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&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;"resultType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"supportedVersions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-28"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"capabilities"&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;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"_meta"&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;"io.modelcontextprotocol/serverInfo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ExampleServer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="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;h3&gt;
  
  
  &lt;code&gt;subscriptions/listen&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A client that wants change notifications sends one &lt;code&gt;subscriptions/listen&lt;/code&gt; request, opting in to specific types (&lt;code&gt;toolsListChanged&lt;/code&gt;, &lt;code&gt;promptsListChanged&lt;/code&gt;, &lt;code&gt;resourcesListChanged&lt;/code&gt;, &lt;code&gt;resourceSubscriptions&lt;/code&gt;). The response is a long-lived SSE stream that carries only those notifications, each tagged with a subscription ID. Request-scoped notifications (&lt;code&gt;notifications/progress&lt;/code&gt;, &lt;code&gt;notifications/message&lt;/code&gt;) do not travel on it; they stay on the response stream of the request they belong to. Servers are encouraged to send SSE comment lines as keep-alives on this stream.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi Round-Trip Requests and &lt;code&gt;InputRequiredResult&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is the change that most affects tool design. When a server needs something from the client mid-call (user input via elicitation, an LLM completion via sampling, or the roots list) it no longer sends a request. It returns an interim result with &lt;code&gt;resultType: "input_required"&lt;/code&gt; whose &lt;code&gt;inputRequests&lt;/code&gt; map carries the requests it needs fulfilled, plus an opaque &lt;code&gt;requestState&lt;/code&gt; blob (SEP-2322). The client gathers the input and &lt;strong&gt;retries the original request with a new ID&lt;/strong&gt;, adding &lt;code&gt;inputResponses&lt;/code&gt; and echoing &lt;code&gt;requestState&lt;/code&gt;. The server reconstitutes whatever it needs from that blob; the two requests are fully independent and may be served by different instances.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client                                   Server
  |  POST tools/call (id 1)                 |
  |----------------------------------------&amp;gt;|
  |                                         | needs user input
  |  InputRequiredResult                    |
  |  resultType: "input_required"           |
  |  inputRequests: { github_login: elicitation/create }
  |  requestState: "&amp;lt;opaque&amp;gt;"               |
  |&amp;lt;----------------------------------------|
  |  (client prompts the user)              |
  |  POST tools/call (id 2)                 |
  |  original params + inputResponses       |
  |  + requestState                         |
  |----------------------------------------&amp;gt;|
  |                                         | reconstitutes state, completes
  |  result, resultType: "complete"         |
  |&amp;lt;----------------------------------------|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every result in the 2026-07-28 protocol carries a required &lt;code&gt;resultType&lt;/code&gt;. Modern core results use &lt;code&gt;"complete"&lt;/code&gt; or &lt;code&gt;"input_required"&lt;/code&gt;; negotiated extensions may define additional values, and the Tasks extension uses &lt;code&gt;resultType: "task"&lt;/code&gt; for its &lt;code&gt;CreateTaskResult&lt;/code&gt;. Clients MUST treat results from earlier-protocol servers that omit the field as &lt;code&gt;"complete"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosting consequences
&lt;/h2&gt;

&lt;p&gt;The transport now mirrors selected body fields into HTTP headers so that intermediaries can route and inspect without parsing JSON. &lt;code&gt;Mcp-Method&lt;/code&gt; is required on every POST; &lt;code&gt;Mcp-Name&lt;/code&gt; is required on &lt;code&gt;tools/call&lt;/code&gt;, &lt;code&gt;resources/read&lt;/code&gt; and &lt;code&gt;prompts/get&lt;/code&gt; (SEP-2243) (carrying the tool name or resource URI, Base64-wrapped when it is not header-safe). A server MAY annotate a tool parameter with &lt;code&gt;x-mcp-header&lt;/code&gt; in its input schema, and conforming clients MUST then mirror that argument into an &lt;code&gt;Mcp-Param-{Name}&lt;/code&gt; header; the canonical example is a &lt;code&gt;region&lt;/code&gt; parameter that a gateway uses to route the call. Servers that process the body MUST validate that headers and body agree and reject mismatches with &lt;code&gt;400&lt;/code&gt; and &lt;code&gt;-32020&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two more changes are aimed squarely at caches and prompt reuse. &lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;prompts/list&lt;/code&gt;, &lt;code&gt;resources/list&lt;/code&gt;, &lt;code&gt;resources/read&lt;/code&gt; and &lt;code&gt;resources/templates/list&lt;/code&gt; results now carry &lt;code&gt;ttlMs&lt;/code&gt; (a freshness hint) and &lt;code&gt;cacheScope&lt;/code&gt; (&lt;code&gt;"public"&lt;/code&gt; or &lt;code&gt;"private"&lt;/code&gt;, controlling whether a shared intermediary may cache them) (SEP-2549). And servers SHOULD return tools from &lt;code&gt;tools/list&lt;/code&gt; in a deterministic order, because a stable tool list is what lets the client's prompt cache hit.&lt;/p&gt;

&lt;p&gt;Put together: normal modern MCP requests are stateless HTTP calls. A server can sit behind a round-robin load balancer, be scaled horizontally, and be fronted by a gateway that routes on &lt;code&gt;Mcp-Name&lt;/code&gt; or &lt;code&gt;Mcp-Param-Region&lt;/code&gt;. Clients and gateways can also cache eligible results using the protocol's own hints: &lt;code&gt;ttlMs&lt;/code&gt; expresses freshness, and &lt;code&gt;cacheScope&lt;/code&gt; controls whether a result may be shared across authorization contexts. These are MCP-level hints rather than HTTP &lt;code&gt;Cache-Control&lt;/code&gt; headers, and they do not mean the server caches anything for you. That statelessness is the design goal the maintainers state in the release post, and it is the reason to migrate even if nothing you had was broken.&lt;/p&gt;

&lt;p&gt;One rule to get right the first time: never mark principal-specific or tenant-specific data as &lt;code&gt;public&lt;/code&gt;. A &lt;code&gt;public&lt;/code&gt; result may be shared by an intermediary across authorization contexts, so anything whose content depends on who asked belongs in &lt;code&gt;private&lt;/code&gt; scope. This matters most for &lt;code&gt;resources/read&lt;/code&gt; and for any list already filtered by identity or tenant.&lt;/p&gt;

&lt;p&gt;One caveat so the picture is complete: &lt;code&gt;subscriptions/listen&lt;/code&gt; is still a long-lived SSE stream. If your deployment runs several instances and any of them can change a tool list or a resource, delivering those notifications to a client whose listen stream is held open by a different instance requires shared pub/sub or equivalent infrastructure. Request handling is stateless; notification fan-out is a deployment concern you still have to design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stateful work without sessions
&lt;/h2&gt;

&lt;p&gt;Dropping protocol sessions does not force your application to be stateless. It moves state from the transport, where the model could not see it, into tool arguments, where it can. The pattern the maintainers recommend: mint an explicit, server-issued handle from one tool and have the model pass it back to later tools as an ordinary argument.&lt;/p&gt;

&lt;p&gt;A worked example. A &lt;code&gt;start_export&lt;/code&gt; tool creates a job and returns a handle:&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;"resultType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&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;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Export started."&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;"structuredContent"&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;"export_handle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exp_9f31c2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-21T13:00:00Z"&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;A later &lt;code&gt;get_export_status&lt;/code&gt; call takes &lt;code&gt;export_handle&lt;/code&gt; as a parameter. For a horizontally scaled server, prefer keeping the state the handle refers to in shared, durable storage (a database, a job queue, an object store) so that any instance can serve any call. It is possible instead to mirror the handle into a header with &lt;code&gt;x-mcp-header&lt;/code&gt; and have a gateway route every call for that export to the instance holding it in memory, but be clear about what that is: application-level affinity, reintroduced by your deployment, even though MCP itself remains sessionless. Handles should carry or imply an expiry, and a call with an expired or unknown handle should return an ordinary tool error the model can act on ("that export has expired, start a new one"), not a transport error.&lt;/p&gt;

&lt;p&gt;For interactions that need user input mid-call, use MRTR rather than a handle. Two things about &lt;code&gt;requestState&lt;/code&gt; are worth being precise about, because they are easy to get half right.&lt;/p&gt;

&lt;p&gt;First, it round-trips through the client, so treat it as untrusted input when it comes back. Integrity protect it and verify it before use, and bind it to the authenticated principal, the originating method, the salient request parameters and an expiry. The official TypeScript helper uses HMAC for this.&lt;/p&gt;

&lt;p&gt;Second, signing gives you integrity, not confidentiality. A client can still read a signed but unencrypted payload, so do not put secrets or sensitive internal state in there. If you need the contents hidden, use authenticated encryption such as AEAD, or keep the state server-side and round-trip an opaque reference to it.&lt;/p&gt;

&lt;p&gt;One production warning while you are here. The retry carries a fresh JSON-RPC ID, and that does nothing for application side effects. If the first round can create a reservation, a charge or a booking before it returns &lt;code&gt;input_required&lt;/code&gt;, the retry can create a second one. Keep the pre-input round free of side effects where you can. Where you cannot, mint an operation identifier, persist the operation state, bind the retry to the same operation, and reconcile instead of repeating. MCP gives you no exactly-once execution; that part is yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility matrix
&lt;/h2&gt;

&lt;p&gt;The specification defines the expected outcome of every client and server pairing. Condensed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Modern&lt;/td&gt;
&lt;td&gt;Modern&lt;/td&gt;
&lt;td&gt;Works. &lt;code&gt;server/discover&lt;/code&gt; optional; version mismatch surfaces as &lt;code&gt;UnsupportedProtocolVersionError&lt;/code&gt; and the client retries with a supported version.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modern&lt;/td&gt;
&lt;td&gt;Legacy&lt;/td&gt;
&lt;td&gt;Fails. The legacy server may reject, stay silent, or misinterpret. On stdio, send &lt;code&gt;server/discover&lt;/code&gt; first so the failure is deterministic.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dual-era&lt;/td&gt;
&lt;td&gt;Modern&lt;/td&gt;
&lt;td&gt;Works. The first modern request succeeds or returns a modern error; the client stays modern.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dual-era&lt;/td&gt;
&lt;td&gt;Legacy&lt;/td&gt;
&lt;td&gt;Works. The modern request gets a 4xx without a recognised modern error body; the client falls back to &lt;code&gt;initialize&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy&lt;/td&gt;
&lt;td&gt;Modern&lt;/td&gt;
&lt;td&gt;Fails. Missing headers and &lt;code&gt;_meta&lt;/code&gt; are rejected with &lt;code&gt;400&lt;/code&gt;. Legacy clients have no fall-forward mechanism.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy&lt;/td&gt;
&lt;td&gt;Dual-era&lt;/td&gt;
&lt;td&gt;Works. The server answers &lt;code&gt;initialize&lt;/code&gt; and serves the legacy revision.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The detection rule for a dual-era client on HTTP: attempt a modern request; on &lt;code&gt;400&lt;/code&gt;, inspect the body. A recognised modern JSON-RPC error (&lt;code&gt;UnsupportedProtocolVersion&lt;/code&gt;, &lt;code&gt;MissingRequiredClientCapability&lt;/code&gt;, &lt;code&gt;HeaderMismatch&lt;/code&gt;) means the server is modern, so correct the request or retry with an advertised version. An empty or unrecognised body means legacy, so fall back to &lt;code&gt;initialize&lt;/code&gt;. Era is a property of the server, not of a request; cache the result per origin.&lt;/p&gt;

&lt;p&gt;A dual-era server chooses its behaviour from how the client opens: a request carrying modern &lt;code&gt;_meta&lt;/code&gt; is served statelessly, an &lt;code&gt;initialize&lt;/code&gt; request selects legacy semantics scoped to that session. The Python SDK implements exactly this (&lt;code&gt;MCPServer&lt;/code&gt; and &lt;code&gt;mcp.Client&lt;/code&gt; with a &lt;code&gt;server/discover&lt;/code&gt; probe and legacy fallback), and its release notes add a detail worth knowing: idle legacy Streamable HTTP sessions now expire, which does not affect stateless or modern connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not assume every SDK enables dual-era negotiation the same way.&lt;/strong&gt; This one catches people. The Python client defaults to &lt;code&gt;mode="auto"&lt;/code&gt;: it sends a &lt;code&gt;server/discover&lt;/code&gt; probe and falls back to &lt;code&gt;initialize&lt;/code&gt; against a pre-2026 server, so you get era detection without asking for it. The TypeScript client is the opposite. Its documentation states that an absent &lt;code&gt;mode&lt;/code&gt;, or &lt;code&gt;mode: 'legacy'&lt;/code&gt;, performs the 2025 &lt;code&gt;initialize&lt;/code&gt; handshake byte for byte with no probe at all; you get modern negotiation only by passing &lt;code&gt;versionNegotiation: { mode: 'auto' }&lt;/code&gt;, or pin a revision with &lt;code&gt;mode: { pin: '2026-07-28' }&lt;/code&gt;. Same protocol, opposite defaults. Read your SDK's negotiation documentation before assuming a client is speaking the revision you think it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deprecations and the lifecycle
&lt;/h2&gt;

&lt;p&gt;The revision introduced a formal feature lifecycle (Active, Deprecated, Removed) with a minimum twelve-month deprecation window and a registry of deprecated features (SEP-2596); Roots, Sampling and Logging were deprecated under it (SEP-2577). As of 25 September 2026 the registry lists:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Deprecated in&lt;/th&gt;
&lt;th&gt;Migration&lt;/th&gt;
&lt;th&gt;Earliest removal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Roots&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;Pass directories or files via tool parameters, resource URIs or server configuration&lt;/td&gt;
&lt;td&gt;First revision on or after 2027-07-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sampling&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;Integrate directly with LLM provider APIs&lt;/td&gt;
&lt;td&gt;First revision on or after 2027-07-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logging&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stderr&lt;/code&gt; on stdio; OpenTelemetry for observability&lt;/td&gt;
&lt;td&gt;First revision on or after 2027-07-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic Client Registration (RFC 7591)&lt;/td&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;Client ID Metadata Documents&lt;/td&gt;
&lt;td&gt;First revision on or after 2027-07-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;includeContext: "thisServer"&lt;/code&gt; / &lt;code&gt;"allServers"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;Omit or use &lt;code&gt;"none"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Follows Sampling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP+SSE transport (2024-11-05)&lt;/td&gt;
&lt;td&gt;2025-03-26&lt;/td&gt;
&lt;td&gt;Streamable HTTP&lt;/td&gt;
&lt;td&gt;Registry wording: "Three months after SEP-2596 reaches Final"; check the deprecated-features registry before removal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing has been removed under the policy yet. Deprecated features keep working during the window, but new implementations should not adopt them. The "earliest removal" column marks when a feature becomes eligible for removal; the registry states that actual removal is a maintainer decision taken during release preparation and may happen later, so treat the registry, not this table, as the source of truth. Note that Roots, Sampling and Elicitation are still delivered through MRTR in the current revision; Roots and Sampling are simply on their way out, while Elicitation is not deprecated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authorization hardening
&lt;/h3&gt;

&lt;p&gt;The authorization section is optional for MCP implementations, but where a server implements it the requirements were tightened: OAuth 2.1 (draft 13) with the MCP server acting as a resource server; OAuth 2.0 Protected Resource Metadata (RFC 9728) is now mandatory for authorization server discovery; Client ID Metadata Documents are the preferred registration mechanism, with Dynamic Client Registration deprecated; resource indicators (RFC 8707) bind tokens to the server; authorization servers SHOULD return &lt;code&gt;iss&lt;/code&gt; and clients MUST validate it (RFC 9207); client credentials are bound to the issuer that granted them; and a step-up flow lets a server challenge for additional scopes on a per-call basis. Two 2026-07-28 changelog items are easy to miss: the resource-not-found error code moved from &lt;code&gt;-32002&lt;/code&gt; to &lt;code&gt;-32602&lt;/code&gt;, and a new error-code allocation policy reserves &lt;code&gt;-32020&lt;/code&gt; to &lt;code&gt;-32099&lt;/code&gt; for the specification (the earlier draft codes &lt;code&gt;-32001&lt;/code&gt;, &lt;code&gt;-32003&lt;/code&gt; and &lt;code&gt;-32004&lt;/code&gt; were renumbered to &lt;code&gt;-32020&lt;/code&gt;, &lt;code&gt;-32021&lt;/code&gt; and &lt;code&gt;-32022&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Extensions, and an integration decision table
&lt;/h2&gt;

&lt;p&gt;Capabilities now carry an &lt;code&gt;extensions&lt;/code&gt; map. Official MCP extensions are maintained in &lt;code&gt;ext-&lt;/code&gt; repositories under the Model Context Protocol GitHub organisation, each identified by an &lt;code&gt;io.modelcontextprotocol/&lt;/code&gt; prefix. A repository may contain more than one related extension: &lt;code&gt;ext-auth&lt;/code&gt; holds both of the authorization extensions below. Official does not necessarily mean stable either, since extension maturity is tracked separately, so check the extension's current specification before depending on it. In &lt;code&gt;ext-auth&lt;/code&gt; today, Enterprise-Managed Authorization is listed as Stable while OAuth Client Credentials is still Draft.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tasks&lt;/strong&gt; (&lt;code&gt;io.modelcontextprotocol/tasks&lt;/code&gt;), for long-running asynchronous work with task state and polling. Moved out of the core in this revision and redesigned (SEP-2663): polling via &lt;code&gt;tasks/get&lt;/code&gt; replaces the blocking &lt;code&gt;tasks/result&lt;/code&gt;, &lt;code&gt;tasks/update&lt;/code&gt; carries client-to-server input, &lt;code&gt;tasks/list&lt;/code&gt; is gone, and a server may return a task handle unsolicited. The Python SDK 2.2.0 release notes state the Tasks extension is not yet implemented there; check your SDK before designing around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP Apps&lt;/strong&gt; (&lt;code&gt;io.modelcontextprotocol/ui&lt;/code&gt;), for interactive UI surfaces rendered by capable hosts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth Client Credentials&lt;/strong&gt; (&lt;code&gt;io.modelcontextprotocol/oauth-client-credentials&lt;/code&gt;), for machine-to-machine authentication where no interactive user authorization is available: background services, CI/CD jobs, server-to-server integrations, workers and daemons. It supplements the normal user authorization flow rather than replacing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise-Managed Authorization&lt;/strong&gt; (&lt;code&gt;io.modelcontextprotocol/enterprise-managed-authorization&lt;/code&gt;), for centralised enterprise identity and access policy through an organisation's IdP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills&lt;/strong&gt; (&lt;code&gt;io.modelcontextprotocol/skills&lt;/code&gt;), which serves Agent Skills through MCP resources. SEP-2640 reached Final on 13 September 2026, and the extension adds &lt;code&gt;skills/list&lt;/code&gt; and &lt;code&gt;skills/get&lt;/code&gt; alongside an optional &lt;code&gt;resources/directory/read&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth separating two things that are easy to conflate. Core authorization and authorization extensions are not the same layer: RFC 9728 discovery, resource indicators and issuer validation are core requirements wherever authorization is implemented, while OAuth Client Credentials and Enterprise-Managed Authorization are optional extensions beyond that flow. A server does not need an authorization extension merely to implement normal MCP OAuth authorization.&lt;/p&gt;

&lt;p&gt;The extension ecosystem also moves independently of the core protocol. Extensions are versioned independently, their maturity differs, and the client support matrix on the documentation site is maintained by the community rather than generated from implementations. That page says as much itself, and points you at each extension's specification and repository for the latest status. So check the extension's own specification and repository before depending on it, and do not assume every client or SDK implements every official extension.&lt;/p&gt;

&lt;p&gt;Which integration shape to choose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommended shape&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local developer tools, one user, one machine&lt;/td&gt;
&lt;td&gt;stdio&lt;/td&gt;
&lt;td&gt;No network, no auth, &lt;code&gt;server/discover&lt;/code&gt; as the probe; stateless semantics still apply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal service used by several agents&lt;/td&gt;
&lt;td&gt;Streamable HTTP, modern only, behind your normal load balancer&lt;/td&gt;
&lt;td&gt;Stateless core; route on &lt;code&gt;Mcp-Method&lt;/code&gt; / &lt;code&gt;Mcp-Name&lt;/code&gt;; cache list results with &lt;code&gt;ttlMs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public or partner-facing server&lt;/td&gt;
&lt;td&gt;Streamable HTTP, modern only, with the authorization section implemented&lt;/td&gt;
&lt;td&gt;RFC 9728 discovery, resource-bound tokens, scope step-up; validate &lt;code&gt;Origin&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many servers behind one entry point&lt;/td&gt;
&lt;td&gt;Gateway that validates headers against bodies and routes on &lt;code&gt;Mcp-Name&lt;/code&gt; and &lt;code&gt;Mcp-Param-*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;This is what the mirrored headers exist for; the gateway must reject header and body mismatches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-running work (minutes to hours)&lt;/td&gt;
&lt;td&gt;Tasks extension where the SDK supports it; otherwise an explicit handle plus a polling tool&lt;/td&gt;
&lt;td&gt;Avoid holding a response stream open across minutes; streams are not resumable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clients you do not control and cannot upgrade&lt;/td&gt;
&lt;td&gt;Dual-era server for the twelve-month window, with a dated plan to drop legacy&lt;/td&gt;
&lt;td&gt;Legacy clients cannot fall forward; only the server can bridge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Failure modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Legacy assumptions in new code.&lt;/strong&gt; A common migration bug is code that still sends &lt;code&gt;initialize&lt;/code&gt; first, or expects an &lt;code&gt;Mcp-Session-Id&lt;/code&gt; in the response, and then treats a modern server's &lt;code&gt;400&lt;/code&gt; as a connection failure. A modern &lt;code&gt;400&lt;/code&gt; carries a JSON-RPC error body that tells you exactly what to do; read it before retrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buffering proxies.&lt;/strong&gt; Streamed responses depend on the proxy passing SSE events through as they arrive. The specification recommends servers send &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;; nginx-style proxies otherwise hold events and the stream looks dead. Keep-alive comment lines on &lt;code&gt;subscriptions/listen&lt;/code&gt; streams prevent idle timeouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Header and body disagreement.&lt;/strong&gt; Anything that rewrites bodies (a middleware that renames a tool, a proxy that normalises JSON) will produce &lt;code&gt;HeaderMismatch&lt;/code&gt; rejections. The mirrored headers are validated against the body on purpose, so intermediaries must rewrite both or neither. Intermediaries that route on headers should also check the &lt;code&gt;MCP-Protocol-Version&lt;/code&gt; indicates a revision that requires validation, and reject older traffic rather than trust unvalidated headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale cached lists.&lt;/strong&gt; With &lt;code&gt;ttlMs&lt;/code&gt; on list results, a client may legitimately serve a cached tool list for the hint's duration. If you add or remove tools, emit &lt;code&gt;notifications/tools/list_changed&lt;/code&gt; to subscribers and keep &lt;code&gt;ttlMs&lt;/code&gt; short during rollouts. Treat change notifications as the signal to invalidate and refetch, rather than assuming a stale entry disappears on its own because a TTL exists somewhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lost in-flight requests.&lt;/strong&gt; Streams do not resume. A load balancer that drains connections mid-response will lose the request, and the client must re-issue it with a new ID; make long tools idempotent or hand them a handle so the retry is safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-deterministic &lt;code&gt;tools/list&lt;/code&gt;.&lt;/strong&gt; A tool list that changes order between calls defeats both client-side caching and the model provider's prompt cache. Sort it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production checklist and tradeoffs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every request carries &lt;code&gt;_meta&lt;/code&gt; with protocol version and client capabilities; the HTTP header matches the body.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;server/discover&lt;/code&gt; is implemented and its result is cacheable.&lt;/li&gt;
&lt;li&gt;No modern code path depends on a protocol session; cross-call state on the 2026-07-28 path is represented explicitly, as a handle in tool arguments with an expiry and a model-readable error when stale.&lt;/li&gt;
&lt;li&gt;Mid-call input uses MRTR; &lt;code&gt;requestState&lt;/code&gt; is integrity protected, verified on return, bound to principal and expiry, and free of secrets; the retry path is tested with a different server instance handling the second request, and any pre-input side effect is reconciled rather than repeated.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools/list&lt;/code&gt; is deterministic and returns &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt;; changes are announced on &lt;code&gt;subscriptions/listen&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Origin is validated; local servers bind to localhost; the authorization section, where used, follows the 2026-07-28 rules (RFC 9728 discovery, resource indicators, &lt;code&gt;iss&lt;/code&gt; validation).&lt;/li&gt;
&lt;li&gt;Proxies pass SSE through unbuffered and forward &lt;code&gt;Mcp-*&lt;/code&gt; headers untouched.&lt;/li&gt;
&lt;li&gt;Roots, Sampling, Logging and Dynamic Client Registration are not adopted in new code; existing uses have a migration ticket dated before July 2027.&lt;/li&gt;
&lt;li&gt;If legacy clients still exist, legacy and session behaviour is isolated to the compatibility path and has a written removal date. The legacy wire did not become stateless; it is simply on a clock.&lt;/li&gt;
&lt;li&gt;The SDK's support for Tasks, DPoP and the &lt;code&gt;jwt-bearer&lt;/code&gt; grant is checked against its release notes before any design depends on them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  If you are migrating today
&lt;/h3&gt;

&lt;p&gt;The short version, in the order it usually gets done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Delete &lt;code&gt;initialize&lt;/code&gt; and &lt;code&gt;notifications/initialized&lt;/code&gt; from the modern path.&lt;/li&gt;
&lt;li&gt;Stop minting, echoing or expecting &lt;code&gt;Mcp-Session-Id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;io.modelcontextprotocol/protocolVersion&lt;/code&gt; and &lt;code&gt;clientCapabilities&lt;/code&gt; to every request, and mirror the version into the HTTP header.&lt;/li&gt;
&lt;li&gt;Implement &lt;code&gt;server/discover&lt;/code&gt; and make its result cacheable.&lt;/li&gt;
&lt;li&gt;Move anything the server used to ask the client for into MRTR.&lt;/li&gt;
&lt;li&gt;Move cross-call state into explicit handles backed by durable storage.&lt;/li&gt;
&lt;li&gt;Replace the old GET stream with &lt;code&gt;subscriptions/listen&lt;/code&gt;, and plan notification fan-out if you run more than one instance.&lt;/li&gt;
&lt;li&gt;Validate mirrored headers against the body, and set &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt; deliberately.&lt;/li&gt;
&lt;li&gt;Isolate legacy compatibility behind one path with a removal date, if you still need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are writing a new server rather than porting one, the companion piece walks through &lt;a href="https://dev.to/jarvisbitztech/how-to-build-an-mcp-server-for-internal-tools-architecture-authentication-and-error-handling-1if2"&gt;building an MCP server for internal tools&lt;/a&gt; with the Python SDK, including token verification and tool errors a model can act on.&lt;/p&gt;

&lt;p&gt;The tradeoffs are real. Stateless requests mean more bytes per call (&lt;code&gt;_meta&lt;/code&gt; on every request, capabilities repeated) and no transport-level continuity for free; you pay for that with explicit handles and MRTR retries. Dual-era support doubles your test matrix for a year. And the SDKs are not uniformly ahead of the spec. In exchange, an MCP server becomes something a platform team can host, scale, cache, route and observe with the tools it already has, which is the difference between a demo integration and one you can put a production agent on.&lt;/p&gt;

&lt;p&gt;If you are migrating, I would like to hear which 2025-era assumption broke first in your codebase. The next article in this series walks through building a modern MCP server for internal tools end to end: transport, authentication and error handling.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and reviewed, edited and approved by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>architecture</category>
      <category>agents</category>
    </item>
    <item>
      <title>RAG Architecture in 2026: A Production Blueprint for Retrieval-Augmented Generation</title>
      <dc:creator>Suresh B</dc:creator>
      <pubDate>Mon, 21 Sep 2026 11:07:39 +0000</pubDate>
      <link>https://dev.to/jarvisbitztech/rag-architecture-in-2026-a-production-blueprint-for-retrieval-augmented-generation-139e</link>
      <guid>https://dev.to/jarvisbitztech/rag-architecture-in-2026-a-production-blueprint-for-retrieval-augmented-generation-139e</guid>
      <description>&lt;p&gt;The notebook worked. Retrieval found the right passages, the model wrote a good answer, and the demo landed. Then the same design met real users and four things happened: first tokens took ten seconds or more, a document the user was never allowed to see turned up in the prompt, a wrong answer could not be traced to retrieval or to generation, and the monthly bill stopped being predictable.&lt;/p&gt;

&lt;p&gt;Most of those are not primarily model-selection problems. They are architecture problems, and they are the reason this article treats retrieval-augmented generation as a system with eight layers rather than a pattern with two steps. What follows is a blueprint: the layers, the contract each one owes the next, where authorization has to live, how a first-token budget is worked backwards from a target, and where it is worth buying a layer instead of building it. Where JarvisBitz has verified production evidence, I say so; everywhere else the guidance is engineering reasoning you should test against your own corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where RAG demos break in production
&lt;/h2&gt;

&lt;p&gt;Most production failures fall into five groups, and each one maps to a layer that the demo skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale index.&lt;/strong&gt; Documents change; the index does not. Users get last quarter's policy with a confident citation. The demo had no ingestion pipeline, only a one-time load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leaked documents.&lt;/strong&gt; The vector store returns the nearest chunks regardless of who is asking. A filter applied after retrieval helps until the top results are all restricted and the user sees an empty answer, or until someone forgets the filter on one code path. The demo had one user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unattributable errors.&lt;/strong&gt; An answer is wrong. Was the right chunk never indexed, never retrieved, retrieved but ranked out, or retrieved and then contradicted by the model? Without per-layer logs there is no way to know, so the fix is guesswork. The demo had no observability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency that compounds.&lt;/strong&gt; Authorization lookup, embedding, approximate nearest neighbour search, reranking and model time to first token each add their own share of latency, and the demo measured none of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unpredictable spend.&lt;/strong&gt; Long prompts, no caching, and a retrieval step that returns fifty chunks because fifty was the default. The demo ran twenty queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reference architecture: eight layers and their contracts
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; user request
      |
 [1] Ingest        raw documents -&amp;gt; parsed, cleaned, versioned text + metadata
      |
 [2] Index         chunks -&amp;gt; embeddings + lexical index + ACL metadata per chunk
      |
 [3] Query         request -&amp;gt; intent, rewritten query (or no retrieval at all)
     understanding
      |
 [4] Authorized    query + principal -&amp;gt; candidate chunks the user may see
     retrieval
      |
 [5] Ranking       candidates -&amp;gt; ordered, deduplicated shortlist
      |
 [6] Context       shortlist -&amp;gt; token-budgeted prompt with citation payloads
     assembly
      |
 [7] Grounded      prompt -&amp;gt; answer + citations, or an honest "not in the sources"
     generation
      |
 [8] Evaluation    every layer -&amp;gt; logs, metrics, offline evals, alerts
     and observability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A layer contract is the shape of what a layer promises to hand to the next one. Writing them down is the single most useful thing a team can do before scaling, because it turns "the RAG is wrong" into "layer 4 returned no candidates for this principal".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Owes the next layer&lt;/th&gt;
&lt;th&gt;Must record&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 Ingest&lt;/td&gt;
&lt;td&gt;Parsed text with structure preserved, a document version, source metadata&lt;/td&gt;
&lt;td&gt;Parse status per page, pages dropped, parser used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Index&lt;/td&gt;
&lt;td&gt;Chunks with stable IDs, embeddings, lexical terms, ACL and tenant metadata&lt;/td&gt;
&lt;td&gt;Chunk count per document, embedding model version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 Query understanding&lt;/td&gt;
&lt;td&gt;Intent label, rewritten query or "skip retrieval", confidence&lt;/td&gt;
&lt;td&gt;Intent, confidence, rewrite applied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 Authorized retrieval&lt;/td&gt;
&lt;td&gt;Candidates the principal is allowed to see, with scores&lt;/td&gt;
&lt;td&gt;Filter applied, candidate count, latency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 Ranking&lt;/td&gt;
&lt;td&gt;Ordered shortlist, duplicates removed&lt;/td&gt;
&lt;td&gt;Ranker, top scores, items dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 Context assembly&lt;/td&gt;
&lt;td&gt;Prompt within a token budget, one citation payload per passage&lt;/td&gt;
&lt;td&gt;Tokens per source, passages included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 Grounded generation&lt;/td&gt;
&lt;td&gt;Answer with citations, or a refusal&lt;/td&gt;
&lt;td&gt;Model, cached tokens, time to first token, citations emitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 Evaluation and observability&lt;/td&gt;
&lt;td&gt;Attribution for any answer, drift alerts&lt;/td&gt;
&lt;td&gt;Everything above, joined by request ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Query understanding and intent routing
&lt;/h2&gt;

&lt;p&gt;The cheapest retrieval is the one you do not run. A production assistant receives greetings, follow-ups that only need the conversation so far, requests for actions, and questions that the corpus cannot answer at all. Sending every one of those through embedding, search and reranking wastes latency and, worse, gives the model irrelevant passages to be confidently wrong with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Intents that skip retrieval
&lt;/h3&gt;

&lt;p&gt;Define a small set of intents up front and classify each request before anything else runs: informational (retrieve), transactional (route to a tool or workflow), conversational (answer from the dialogue), and out of scope (decline and log). Classification is a short model call or a lightweight classifier over a few example phrasings per intent; it costs far less than the retrieval it prevents. The intent also decides the rest of the path: which corpus, which filters, and whether a person needs to be involved.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to rewrite
&lt;/h3&gt;

&lt;p&gt;Rewriting the query helps when users write the way people talk and the corpus is written the way documents are written. "Why does my export keep failing since the update" becomes "export failure after version upgrade" and retrieval improves. Rewriting hurts when the query already contains exact identifiers (error codes, part numbers) that a rewrite paraphrases away. A workable rule: rewrite conversational queries, pass identifier-heavy queries through unchanged, and log both forms so you can see which one retrieval actually used. The managed retrieval tools now expose this directly; OpenAI's vector store search accepts &lt;code&gt;rewrite_query=true&lt;/code&gt; and returns the rewritten form in the result, which is convenient for exactly this kind of logging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorized retrieval
&lt;/h2&gt;

&lt;p&gt;This is the layer that separates a demo from a system you can put in front of customers, and the rule is short: &lt;strong&gt;authorization is a retrieval filter, never only a prompt instruction.&lt;/strong&gt; A sentence in the system prompt saying "only use documents the user may access" is a request to a model that has already been shown the documents. By then the leak has happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filter before rank
&lt;/h3&gt;

&lt;p&gt;The filter belongs inside the search query, applied by the store before similarity scoring, so that restricted chunks are never candidates. Filtering after retrieval has two failure modes: the result set can be exhausted by restricted items (the user gets nothing, or the system quietly widens the search), and every new code path is a place to forget the filter. The common retrieval stacks discussed in this article all support authorization-aware filtering, though the exact mechanism differs by product: pgvector through ordinary SQL predicates and Postgres row-level security, Qdrant and Weaviate through payload and tenant filters, Elasticsearch through document-level security, and the managed tools through attribute or metadata filters (OpenAI vector stores, Gemini File Search). Metadata filtering in Google's RAG Engine was documented as Preview on 18 September 2026; treat Preview features as things to test, not to assume.&lt;/p&gt;

&lt;h3&gt;
  
  
  ACL metadata on every chunk
&lt;/h3&gt;

&lt;p&gt;For the filter to work, each chunk must carry what the filter needs. A minimal record:&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;"chunk_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"doc_8812:p14:c03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"doc_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"doc_8812"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"doc_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-04T10:12:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"t_acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"acl"&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;"allow_groups"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"g_service_advisors"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g_admins"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow_users"&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;"classification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&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;"source"&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;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3://kb/manuals/2026/brake-service.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"section"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3.2 Torque specifications"&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;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"embedding_model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text-embedding-x@2026-06"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lexical_terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"torque"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"caliper"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bracket"&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;And the retrieval call that uses it, in framework-free pseudocode:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Chunk&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. cache: same principal scope + same normalised query =&amp;gt; reuse
&lt;/span&gt;    &lt;span class="n"&gt;cache_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retrieval_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&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;hit&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. authorization filter is part of the query, not a post-step
&lt;/span&gt;    &lt;span class="n"&gt;acl_filter&lt;/span&gt; &lt;span class="o"&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;tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;any_of&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;acl.allow_groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acl.allow_users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doc_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;q_vec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;q_vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;acl_filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sparse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lexical_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;acl_filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. fuse, then hand to the ranking layer; nothing unauthorised can reach it
&lt;/span&gt;    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reciprocal_rank_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dense&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sparse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;retrieval_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&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;candidates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that snippet matter more than they look. The cache key includes the principal's authorization scope, so a cached result can never be served to someone with narrower access. And the filter includes the document version, so a superseded chunk cannot outrank its replacement.&lt;/p&gt;

&lt;p&gt;Group expansion (turning a user into the set of groups they belong to) happens before retrieval, is cached briefly, and is invalidated when memberships change. Test this layer adversarially: seed a canary document that only one group may see and assert, in CI, that every other principal gets zero results for a query that matches it exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranking and context assembly
&lt;/h2&gt;

&lt;p&gt;Retrieval returns candidates; ranking decides what the model sees. Hybrid retrieval (dense plus lexical, fused with reciprocal rank fusion) fixes the case where a query contains an exact term the embedding blurs. A cross-encoder reranker over the fused top forty or so lifts precision further. Reranking adds another latency stage, and the cost varies materially with provider, candidate count and model. Measure it on the actual request path rather than assuming a fixed budget. Whether reranking pays off is an evaluation question, not a belief: measure recall at k with and without it on your own query set.&lt;/p&gt;

&lt;h3&gt;
  
  
  Token budget per source
&lt;/h3&gt;

&lt;p&gt;Context assembly gets a fixed budget and spends it deliberately. A practical scheme allocates a maximum share per source document so that one long manual cannot crowd out three short ones that each contain part of the answer, and orders passages deliberately. Where the highest-ranked evidence should sit is model-dependent, so test passage ordering on the chosen model with your own evaluation set rather than assuming a universal rule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deduplication
&lt;/h3&gt;

&lt;p&gt;Overlapping chunks from the same page, near-identical paragraphs across document versions, and the same table captured by two parsers all waste budget and mislead the model into treating repetition as corroboration. Deduplicate on normalised text and on (doc_id, page) before assembly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Citation payloads
&lt;/h3&gt;

&lt;p&gt;Every passage in the prompt carries a citation payload: chunk ID, document title, page or section, version. The model is instructed to cite by ID, and the application resolves IDs to links after generation. This keeps citations verifiable (the ID either exists in the prompt or it does not) and gives layer 8 the join key it needs to attribute a bad answer to a specific passage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first-token latency budget
&lt;/h2&gt;

&lt;p&gt;Users judge a chat interface by when the first token appears. Work the budget backwards from a target and give each layer a share. The figures below are illustrative allocations for a two-second first-token target, not measurements; your numbers come from your own traces.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Illustrative allocation&lt;/th&gt;
&lt;th&gt;What removes it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Authorization and group expansion&lt;/td&gt;
&lt;td&gt;50 ms&lt;/td&gt;
&lt;td&gt;Short-lived principal cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intent classification&lt;/td&gt;
&lt;td&gt;150 ms&lt;/td&gt;
&lt;td&gt;Skip for follow-ups with a cached intent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query embedding&lt;/td&gt;
&lt;td&gt;100 ms&lt;/td&gt;
&lt;td&gt;Embedding cache on normalised query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ANN plus lexical search&lt;/td&gt;
&lt;td&gt;150 ms&lt;/td&gt;
&lt;td&gt;Retrieval cache keyed by principal scope and query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reranking&lt;/td&gt;
&lt;td&gt;150 ms&lt;/td&gt;
&lt;td&gt;Rerank fewer candidates; skip when top scores are separated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context assembly&lt;/td&gt;
&lt;td&gt;50 ms&lt;/td&gt;
&lt;td&gt;Precomputed citation payloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model time to first token&lt;/td&gt;
&lt;td&gt;1,000 ms&lt;/td&gt;
&lt;td&gt;Prompt caching of the stable prefix; smaller model for simple intents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network and serialisation&lt;/td&gt;
&lt;td&gt;100 ms&lt;/td&gt;
&lt;td&gt;Streaming from the first token&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three different caches are doing three different jobs here, and conflating them is a common mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefix caching&lt;/strong&gt; is provided by the model API and removes the cost of re-reading the stable part of the prompt (system instructions, tool definitions, long standing context). It is enabled by default on OpenAI's platform with a minimum cacheable prefix of 1,024 tokens on GPT-5.6 and later, and cached reads are discounted by up to 90 percent; Anthropic offers automatic caching or explicit breakpoints with 5-minute or 1-hour lifetimes; standard cache hits are priced at 0.1x the base input price, with a 0.025x rate on Claude Fable 5.1 and Claude Mythos 5.1. Prefix caching only helps if the prompt is built so that the stable part comes first and the retrieved passages come last.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval caching&lt;/strong&gt; stores the candidate set for a normalised query within an authorization scope. It removes embedding and search time on repeated questions, which in support-style traffic are a large share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic caching&lt;/strong&gt; stores whole answers for questions judged equivalent. It is the biggest saver and the most dangerous: a wrong equivalence serves a wrong answer, and a cached answer can outlive the document it was built from. If you use it, key it by principal scope and document version, and expire it on ingestion events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build or buy, one layer at a time
&lt;/h2&gt;

&lt;p&gt;The decision is rarely all or nothing. In 2026 the practical options per layer are a self-managed search stack, a managed retrieval tool attached to a model API, or, for some corpora, no retrieval at all.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Self-managed&lt;/th&gt;
&lt;th&gt;Managed&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ingest and parse&lt;/td&gt;
&lt;td&gt;Docling, Unstructured, your own parsers&lt;/td&gt;
&lt;td&gt;Document AI layout parser and LLM parser in Google's RAG Engine; automatic chunking in OpenAI vector stores and Gemini File Search&lt;/td&gt;
&lt;td&gt;Managed parsing is fast to adopt and hard to inspect page by page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index and search&lt;/td&gt;
&lt;td&gt;pgvector for vector search combined with PostgreSQL full-text search and result fusion (RRF or reranking) in your own code; Qdrant, Weaviate and Elasticsearch with built-in hybrid search; all support pre-filtering&lt;/td&gt;
&lt;td&gt;OpenAI vector stores (hybrid search weights, attribute filters, up to 50 results), Gemini File Search (chunking config, metadata filters, file citations), RAG Engine (RagManagedDb, Vector Search, Pinecone, Weaviate)&lt;/td&gt;
&lt;td&gt;Managed stores tie you to one model provider's request path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization&lt;/td&gt;
&lt;td&gt;Your filter, your tests&lt;/td&gt;
&lt;td&gt;Attribute or metadata filters you populate&lt;/td&gt;
&lt;td&gt;Either way the ACL data is yours to maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ranking&lt;/td&gt;
&lt;td&gt;Cross-encoder rerankers, provider rerank APIs&lt;/td&gt;
&lt;td&gt;Built-in rankers with limited tuning&lt;/td&gt;
&lt;td&gt;Measure before paying for it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation&lt;/td&gt;
&lt;td&gt;Any model&lt;/td&gt;
&lt;td&gt;Any model&lt;/td&gt;
&lt;td&gt;Cache the prefix regardless&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation&lt;/td&gt;
&lt;td&gt;Open-source eval frameworks plus your own labelled set&lt;/td&gt;
&lt;td&gt;Provider tracing dashboards&lt;/td&gt;
&lt;td&gt;The labelled query set is the asset; tools are interchangeable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Long context as a layer
&lt;/h3&gt;

&lt;p&gt;The frontier models now offer roughly one million tokens of input: Claude Opus 5, Sonnet 5 and Fable 5.1 list a 1M token window (Haiku 4.5 stays at 200K); OpenAI's gpt-6-astra has a 1,050,000 token window with 922,000 maximum input tokens, and prompts above 272K input tokens are billed at twice the input rate and 1.5 times the output rate for the whole request; Gemini 3.8 Flash accepts 1,048,576 input tokens, with promotional pricing that runs until 31 December 2026. Many 300-page text-heavy manuals can fit within these context windows, depending on extraction, images and tokenization. Whether they should is a different question.&lt;/p&gt;

&lt;p&gt;Long context replaces layers 2 through 5 with "send everything", which is attractive for small, static, single-tenant corpora where every user may see every document. It is the wrong tool when the corpus is larger than the window (a 40,000 document library is not going in), when different users may see different documents (there is no filter to apply to a prompt), when the same content is read on every request (you pay the full input each time unless the cache holds), or when you need to attribute an answer to a passage. In practice the strongest 2026 designs use both: retrieval to select an authorized, versioned subset, and a large window to give the model generous, well-ordered context from that subset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes and the detectors that catch them
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;What the user sees&lt;/th&gt;
&lt;th&gt;Detector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval miss&lt;/td&gt;
&lt;td&gt;"I could not find that", but the document exists&lt;/td&gt;
&lt;td&gt;Offline recall at k on a labelled query set; alert on recall drop after index rebuilds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ranking miss&lt;/td&gt;
&lt;td&gt;The right chunk was retrieved but not shown&lt;/td&gt;
&lt;td&gt;Log positions; measure "gold chunk rank" distribution; compare with and without reranker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Faithfulness failure&lt;/td&gt;
&lt;td&gt;Answer contradicts or exceeds the passages&lt;/td&gt;
&lt;td&gt;Faithfulness eval on sampled traffic; citation ID validation (every cited ID must exist in the prompt)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permission leak&lt;/td&gt;
&lt;td&gt;A restricted passage appears in a citation&lt;/td&gt;
&lt;td&gt;Canary documents; assert zero cross-scope results in CI; audit log per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index drift&lt;/td&gt;
&lt;td&gt;Answers cite superseded versions&lt;/td&gt;
&lt;td&gt;Version field on every chunk; alert when retrieved doc_version is not current&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache poisoning&lt;/td&gt;
&lt;td&gt;A stale or wrong answer keeps being served&lt;/td&gt;
&lt;td&gt;Cache keys include scope and version; invalidate on ingestion events; sample cached answers into evals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost spike&lt;/td&gt;
&lt;td&gt;The bill jumps with no traffic change&lt;/td&gt;
&lt;td&gt;Track tokens per request by layer; alert on prompt length percentile and on cache hit rate drops&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The common thread: every detector depends on a log line from a specific layer, keyed by the request ID. If layer 8 is bolted on after launch, most of these detectors cannot be built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every chunk carries tenant, ACL, version and source metadata; retrieval filters on all four inside the store query.&lt;/li&gt;
&lt;li&gt;Intent classification runs before retrieval; at least one intent skips retrieval entirely.&lt;/li&gt;
&lt;li&gt;Hybrid retrieval is fused, deduplicated and reranked; recall at k is measured on a labelled set before and after each change.&lt;/li&gt;
&lt;li&gt;Context assembly has a token budget per source and emits one citation payload per passage.&lt;/li&gt;
&lt;li&gt;The prompt is ordered stable-prefix-first so prefix caching applies; retrieval and semantic caches are keyed by principal scope and document version.&lt;/li&gt;
&lt;li&gt;The first-token budget is written down per layer and compared with traces weekly.&lt;/li&gt;
&lt;li&gt;Canary documents and cross-scope assertions run in CI.&lt;/li&gt;
&lt;li&gt;Every request produces per-layer logs joined by a request ID, and a bad answer can be attributed to a layer within minutes.&lt;/li&gt;
&lt;li&gt;Index rebuilds and re-embedding are events that invalidate caches and trigger the recall eval.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The lesson from a production system
&lt;/h2&gt;

&lt;p&gt;JarvisBitz rebuilt the AI assistant inside a US automotive SaaS product whose knowledge base held more than 120 GB of content across roughly 40,000 PDF documents. Before the rebuild, answers could take approximately 10 to 30 seconds to begin, depending on the query, and intent accuracy was poor. The redesign was the architecture above: intent detection and routing before retrieval, authorization-aware document retrieval, caching, targeted retrieval and optimised context construction, built in Python on Google Vertex AI Platform (now Gemini Enterprise Agent Platform) with Gemini. After it, first-token latency in typical interactions was under three seconds, the system achieved approximately 98 percent accuracy across intent recognition and information retrieval in the client's evaluated use cases, and operating cost fell by roughly 30 percent. The full account is in &lt;a href="https://www.jarvisbitz.com/insights/saas-ai-assistant-30-seconds-to-3" rel="noopener noreferrer"&gt;the case study on our site&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What that project taught, and what this blueprint is really about, is that the difficult part of production RAG is rarely the model call. It is the surrounding system: retrieval, permissions, caching, context management, latency controls, verification and monitoring. These are the kinds of production AI engineering problems &lt;a href="https://jarvisbitz.com" rel="noopener noreferrer"&gt;JarvisBitz Engineering&lt;/a&gt; focuses on, and they are the ones that decide whether a RAG system survives contact with real users.&lt;/p&gt;

&lt;p&gt;The rest of this series covers each layer in turn. If your open problems are exact-term queries or tenant isolation, start with hybrid search and multi-tenant retrieval.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and reviewed, edited and approved by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
      <category>production</category>
    </item>
    <item>
      <title>AI voice agent for customer service: what stops callers hanging up?</title>
      <dc:creator>Suresh B</dc:creator>
      <pubDate>Mon, 21 Sep 2026 07:42:03 +0000</pubDate>
      <link>https://dev.to/jarvisbitztech/ai-voice-agent-for-customer-service-what-stops-callers-hanging-up-4cl3</link>
      <guid>https://dev.to/jarvisbitztech/ai-voice-agent-for-customer-service-what-stops-callers-hanging-up-4cl3</guid>
      <description>&lt;p&gt;Every support team that asks us about a &lt;a href="https://www.jarvisbitz.com/ai/conversational-ai" rel="noopener noreferrer"&gt;voice agent&lt;/a&gt; has already heard a demo. The demo is always good. A pleasant voice answers a clean question in a quiet room, and somebody in the room says the word deflection. Then the thing goes live on a real number, and within a week somebody pulls the recordings and finds that a third of callers are pressing zero or simply hanging up.&lt;/p&gt;

&lt;p&gt;I have looked at enough of those recordings to be fairly confident about where the damage happens, and it is almost never where the team expects. The voice gets blamed, the accent gets blamed, and speed gets blamed most of all because it is the number on the dashboard. In the recordings, callers leave when the agent has been allowed to start something it cannot finish, and then cannot pass them to a person without losing everything they just said.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hangup happens at a specific moment
&lt;/h2&gt;

&lt;p&gt;Average handling time will not show you this. Averages smooth over the exact thing you need to see. What you want is the turn number at which the call died, counted across a few hundred real calls, and once you plot that you tend to get two spikes rather than a slope.&lt;/p&gt;

&lt;p&gt;The first spike is early, usually turn one or two. That is the caller deciding they have reached a machine and they would rather not. Some of that is unavoidable. Some of it is your greeting promising more than the agent can do, which makes the caller test it immediately with the hardest thing they have.&lt;/p&gt;

&lt;p&gt;The second spike is the expensive one. It sits somewhere in the middle of the call, after the caller has explained their problem properly, given an account number, answered two clarifying questions, and then hit a wall. The agent cannot do the thing. It says something polite. The caller has now invested two minutes and is further from a human than when they started. That is the call that turns into a complaint, and it is entirely a design decision rather than a technology limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrow the job until it is boring
&lt;/h2&gt;

&lt;p&gt;My opinion, and it is an opinion rather than a measurement, is that most voice agent projects fail at scoping and then spend their budget on engineering. The question teams usually ask is what the model can handle, and the answer is a lot, which is why it is the wrong question. The useful one is which calls have a definite end that the agent can reach on its own.&lt;/p&gt;

&lt;p&gt;A call has a definite end when there is a system the agent can read from or write to, and a sentence it can say afterwards that closes the matter. Where is my order. Move my appointment. Am I still under warranty. Reset this. Those end.&lt;/p&gt;

&lt;p&gt;Calls that do not end are the ones where the resolution lives in somebody's judgement: refunds outside policy, complaints, anything where the caller is upset about a decision rather than confused about a fact. An agent can take the details, and that is genuinely useful, but the moment it implies it might resolve the matter you have built the middle of call hangup on purpose.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A test I use when a team hands me their intent list: for each intent, write the exact final sentence the agent says when it goes well. If you cannot write that sentence without a conditional in it, the intent is not ready for the agent to own.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Build the transfer before the conversation
&lt;/h2&gt;

&lt;p&gt;The handoff is treated as an edge case in most projects I have seen, and it is the single biggest driver of whether callers tolerate the agent at all. A caller who reaches a person in fifteen seconds, with the agent having already told that person the account number and the problem, has had a decent experience even though the automation technically failed. A caller who has to start again from the beginning has had a worse experience than if you had never deployed anything.&lt;/p&gt;

&lt;p&gt;So I would build the transfer path first, before a single intent is wired up. Concretely that means three things exist on day one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A warm transfer that carries state.&lt;/strong&gt; The agent writes a short structured summary plus the raw transcript into the ticket or the CRM record before the call moves, so the agent picking up is reading rather than asking. If your telephony platform cannot attach data to a transferred call, fix that before anything else, because everything downstream depends on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An escape that always works.&lt;/strong&gt; Saying agent, saying representative, pressing zero, or simply repeating themselves twice. All of these route out immediately, with no retention attempt and no "I can help with that" in between. Retention attempts on a caller who has already asked for a human are where the recordings get genuinely unpleasant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rule for what happens out of hours.&lt;/strong&gt; When there is nobody to transfer to, the agent needs an honest fallback such as taking a callback with a stated window. An agent that keeps trying because the queue is closed is the worst version of this product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a cost argument here too, and it cuts against the usual business case. If you scope narrowly and transfer quickly, your containment rate will look modest. Somebody will be disappointed by that number. I would rather have a modest containment rate on calls that were genuinely finished than a high one that is mostly callers giving up, which is what a containment metric counts if you do not separate the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually measure
&lt;/h2&gt;

&lt;p&gt;Support dashboards are built for human queues and the default metrics mislead you here. These are the ones I would put on the wall instead.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Why it tells you something&lt;/th&gt;
&lt;th&gt;What the usual metric hides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hangups by turn number&lt;/td&gt;
&lt;td&gt;Separates people who never wanted a machine from people the agent lost mid call&lt;/td&gt;
&lt;td&gt;Average handling time averages the two together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved without transfer, sampled and listened to&lt;/td&gt;
&lt;td&gt;Confirms the call actually ended rather than the caller leaving&lt;/td&gt;
&lt;td&gt;Containment counts an abandoned call as a success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time from escape request to a human voice&lt;/td&gt;
&lt;td&gt;This is the number angry callers remember&lt;/td&gt;
&lt;td&gt;Queue time measured from call start, not from the handoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeat contact within 48 hours on the same issue&lt;/td&gt;
&lt;td&gt;Catches confident wrong answers that closed the call falsely&lt;/td&gt;
&lt;td&gt;First contact resolution measured on the agent side only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repeat contact number is the one I would watch most closely in the first month. A voice agent that is wrong and definite will look excellent on every other metric for about two weeks, because the calls are short and they end. The cost arrives later, in the second call and in the trust the caller no longer extends to your support line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that are genuinely hard
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Interruptions
&lt;/h3&gt;

&lt;p&gt;Callers talk over agents constantly, especially when correcting a misheard account number. If the agent keeps talking while being interrupted, the call is effectively over even if nobody hangs up yet. Stopping instantly and discarding the rest of the planned sentence is table stakes, and it is worth testing explicitly rather than assuming your platform does it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Numbers and names
&lt;/h3&gt;

&lt;p&gt;Order references, postcodes, spelled surnames. Recognition on these degrades in exactly the conditions real support calls happen in, which is a caller in a car or a kitchen. Where you can, verify against a system rather than asking the caller to repeat: a partial match against the account on record is faster and less annoying than a third attempt at the full string.&lt;/p&gt;

&lt;h3&gt;
  
  
  The greeting
&lt;/h3&gt;

&lt;p&gt;I keep coming back to how much weight sits in the first eight seconds. An agent that names what it can do, in plain words, and mentions that a person is available, gets a noticeably calmer conversation than one that opens with "how can I help you today". The open question invites the whole problem, including the parts the agent cannot take.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I would start if this were my support line
&lt;/h2&gt;

&lt;p&gt;Pick the two highest volume calls that have a definite end. Build the transfer and the logging first. Run the agent on a share of traffic rather than the whole line, with the old routing one config change away. Listen to twenty calls yourself in week one, which nobody ever wants to do and which tells you more than the dashboard will for a month. Expand the intent list only after the hangup curve stops showing a bump in the middle.&lt;/p&gt;

&lt;p&gt;The volume and cost side of the business case depends entirely on your call mix, so I will not put a number on it here. Working that out for a specific line is part of the audit below.&lt;/p&gt;

&lt;p&gt;If you have a voice agent live and the recordings are not matching the dashboard, our &lt;a href="https://www.jarvisbitz.com/free-ai-audit" rel="noopener noreferrer"&gt;free AI audit&lt;/a&gt; includes listening to a sample of real calls and marking where each one was lost, and &lt;a href="https://www.jarvisbitz.com/ai/voice-intelligence" rel="noopener noreferrer"&gt;how we build voice systems&lt;/a&gt; covers the architecture underneath. If you would rather talk it through first, &lt;a href="https://www.jarvisbitz.com/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Suresh B is the Founder &amp;amp; CEO of &lt;a href="https://www.jarvisbitz.com" rel="noopener noreferrer"&gt;JarvisBitz Tech&lt;/a&gt;, an AI and software engineering company helping businesses build and scale production AI systems and digital products.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>startup</category>
    </item>
    <item>
      <title>How to build an AI sales assistant that does not make things up</title>
      <dc:creator>Suresh B</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:47:43 +0000</pubDate>
      <link>https://dev.to/jarvisbitztech/how-to-build-an-ai-sales-assistant-that-does-not-make-things-up-25m5</link>
      <guid>https://dev.to/jarvisbitztech/how-to-build-an-ai-sales-assistant-that-does-not-make-things-up-25m5</guid>
      <description>&lt;p&gt;I have tested a lot of website chatbots over the past two years, and the failure I see most often is confidence. An assistant quotes a price nobody agreed to. It promises a feature that does not exist. It explains a refund policy the business never wrote down. Every one of these answers arrives in the same calm, helpful tone as the correct ones, which is exactly what makes them dangerous.&lt;/p&gt;

&lt;p&gt;We have been building a &lt;a href="https://www.jarvisbitz.com/ai/conversational-ai" rel="noopener noreferrer"&gt;configurable assistant platform&lt;/a&gt; at JarvisBitz for a while now, and the question I get asked most about it is how we stop it inventing answers. This article is my answer. It is an engineering answer rather than a product pitch, because the fix lives in the architecture, and it applies whether you build your own assistant or buy one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where invented answers actually come from
&lt;/h2&gt;

&lt;p&gt;A language model answers every question it is given. That is what it was trained to do, and it is very good at it. Put it behind a chat window, give it your web pages as context, and it will answer the questions your pages cover. It will also answer the questions your pages do not cover, using whatever it absorbed during training, in the same confident voice.&lt;/p&gt;

&lt;p&gt;Retrieval augmented generation, the technique most teams reach for, narrows the problem without removing it. Retrieval finds the passages from your material that look most similar to the question, and the model writes an answer from them. The trouble starts when retrieval comes back thin. The passages are only loosely related, but the model still has to say something, so it fills the gap from general knowledge. The output looks grounded. It has the shape of an answer drawn from your documents, and part of it is fiction.&lt;/p&gt;

&lt;p&gt;I have seen this with prices, with features that did not exist, and with policies the business never wrote down. In each case the retrieval step had worked exactly as designed, which is the uncomfortable part. The design itself was the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule one: not enough evidence is an outcome, and it needs its own path
&lt;/h2&gt;

&lt;p&gt;The most important decision we made was to treat "we do not have a reliable answer" as a real result with its own branch in the system, instead of something the model should smooth over.&lt;/p&gt;

&lt;p&gt;In practice it works like this. After retrieval and before any text is generated, a separate check scores the evidence. How well do the retrieved passages cover the question. Whether they come from approved sources. Whether there are enough of them and whether they agree with each other. If that score falls under a threshold the business has set, the model is never asked to write an answer. The conversation takes a different route: the assistant says plainly that it does not have that information, offers to connect the visitor with a person, and, if the visitor is willing, collects a way to follow up.&lt;/p&gt;

&lt;p&gt;It sounds like a small change, and it changes almost everything downstream, because once the model only ever generates from evidence that passed a check, it is no longer in the position of having to guess. The invented price problem mostly disappears on its own.&lt;/p&gt;

&lt;p&gt;The threshold is a business decision rather than an engineering one. A law firm wants it strict. A company selling gift boxes can afford to be looser. So we expose it as a setting, and we let the business watch what happens at each level during testing, which I will come back to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Give "I do not know" its own path through the system. Most of the other rules follow from that one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Rule two: the assistant may only know what the business gave it
&lt;/h2&gt;

&lt;p&gt;The second rule is about the boundary of knowledge. The assistant answers from approved sources and from nothing else. The website, the product documentation, the policy PDFs, the notes someone in sales wrote down. Whatever the business attaches is the whole world as far as the assistant is concerned.&lt;/p&gt;

&lt;p&gt;This is easy to say and surprisingly easy to get wrong. The most common leak I see is a system prompt that tells the model to use its general knowledge if the documents do not cover the question. Someone added it because the assistant felt unhelpful in testing, and it is the single instruction most responsible for confident nonsense in production. We do not allow it. If the business wants the assistant to answer a question, the business adds a source that answers it.&lt;/p&gt;

&lt;p&gt;The second leak is subtler. The model is allowed to reason from the documents, and reasoning drifts. Asked whether the product integrates with a tool the documents never mention, a model reading a page that lists eight integrations will sometimes conclude that a ninth is probably supported as well. The evidence check catches most of this, because the passages do not actually contain the claim. Requiring the model to cite the passage behind every factual statement catches most of the rest, since a claim with nothing to cite stands out immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule three: business rules belong in code, where a visitor cannot argue with them
&lt;/h2&gt;

&lt;p&gt;Grounding governs what the assistant knows. There is a separate question of what it is allowed to say, and this is where I see a second family of failures that have nothing to do with hallucination.&lt;/p&gt;

&lt;p&gt;Whether this assistant may discuss pricing at all. Whether the support assistant may suggest an upgrade, or that belongs to the sales assistant alone. Which topics are off limits. At what point in a conversation it should stop and bring a person in. These are policies. Most teams put them in the system prompt, and I understand why, because it takes two minutes. But a prompt instruction is advice the model can be talked out of by a determined visitor, and visitors are more determined than you expect. A rule checked in code on every turn cannot be talked out of anything.&lt;/p&gt;

&lt;p&gt;So in our architecture the persona layer has two parts. Tone, which the prompt handles perfectly well. And policy, which ordinary code checks before and after generation. If the policy says no pricing, the draft answer is checked for pricing and stopped before it reaches the visitor, who gets a polite redirect and, where it makes sense, an offer to talk to someone who can quote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule four: the hard cases go to a person, and that is a feature
&lt;/h2&gt;

&lt;p&gt;An assistant that hands off to a human is often described as one that failed. I think that view is wrong, and it pushes teams into making the assistant answer things it should not.&lt;/p&gt;

&lt;p&gt;A visitor who is angry, a question that touches a contract, a request for a discount, a medical or legal detail, a question the knowledge base cannot support: these should reach a person, and the assistant's job is to get them there gracefully and with context. The handoff carries the conversation so far, the intent it detected, and whatever contact details the visitor offered, so the person picks it up without asking anyone to start again.&lt;/p&gt;

&lt;p&gt;Designed this way, the handoff is where the highest value conversations end up. It deserves to be built properly rather than treated as the apology screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part most teams skip: learning from the questions you could not answer
&lt;/h2&gt;

&lt;p&gt;Every time the evidence check fails, we record the question. Over a month, that log becomes one of the most useful documents the business owns.&lt;/p&gt;

&lt;p&gt;If thirty visitors ask whether you work with a particular accounting system and your site never mentions it, that is thirty pieces of market research delivered for free. It tells the business what to write next, what belongs in the FAQ, what sales keeps hearing on calls, and sometimes what to build. An assistant that had invented a plausible answer would have destroyed that signal. The visitor would have left, satisfied or misled, and nobody would have learned that the question was ever asked.&lt;/p&gt;

&lt;p&gt;We surface this as a report: unanswered questions grouped by theme and ranked by how often they came up. I expect it to be the feature businesses value most once they have lived with it, and it only exists because we refused to let the model guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it before it meets a customer
&lt;/h2&gt;

&lt;p&gt;None of the above is worth much if the business cannot see what the assistant is doing. Our test environment shows the decision behind every reply: which intent matched and with what confidence, which sources were retrieved, whether the evidence threshold passed, which policy rule fired, which lead detail was being collected.&lt;/p&gt;

&lt;p&gt;This is how a non technical owner tunes the system. A wrong answer with no trace is a mystery that needs an engineer. A wrong answer with a trace usually shows that a document was missing or a threshold was too loose, and the owner fixes that directly. It is also how they choose the evidence threshold with some confidence, by watching real questions flow through at different settings before anything goes live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you are buying rather than building
&lt;/h2&gt;

&lt;p&gt;If you are evaluating an assistant for your website, from us or from anyone else, the rules above turn into a short checklist. Ask the vendor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What happens when the knowledge base does not contain the answer. Ask to see it, using a question you know is not covered.&lt;/li&gt;
&lt;li&gt;Whether you can restrict the assistant to your sources only, with no fallback to general knowledge.&lt;/li&gt;
&lt;li&gt;Whether rules like "never discuss pricing" are enforced in code or only written into a prompt.&lt;/li&gt;
&lt;li&gt;Whether you can see why the assistant gave a particular answer.&lt;/li&gt;
&lt;li&gt;Whether you get a report of the questions it could not answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A vendor who cannot answer those clearly is selling you the chat window, and the chat window is the easy part. If you already run an assistant and want a second opinion on it, our &lt;a href="https://www.jarvisbitz.com/free-ai-audit" rel="noopener noreferrer"&gt;free AI audit&lt;/a&gt; covers exactly these checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves us
&lt;/h2&gt;

&lt;p&gt;We describe what we are building as a platform for &lt;a href="https://www.jarvisbitz.com/ai/agents" rel="noopener noreferrer"&gt;domain specific assistants&lt;/a&gt;, and it is still a prototype, so I am not going to claim results we have not measured. What I will claim is that this architecture is the only approach I have seen hold up once real visitors arrive. The model handles the execution: understanding the question, finding the answer, collecting the details. The business keeps control of what the assistant knows, what it may say, and when it must stop. If you take one thing from this piece, make it the first rule. Give "I do not know" its own path, and most of the rest follows.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Suresh B is the Founder &amp;amp; CEO of &lt;a href="https://www.jarvisbitz.com" rel="noopener noreferrer"&gt;JarvisBitz Tech&lt;/a&gt;, an AI and software engineering company helping businesses build and scale production AI systems and digital products.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How we took a SaaS AI assistant from 30 second answers to under 3, across 40,000 PDFs</title>
      <dc:creator>Suresh B</dc:creator>
      <pubDate>Fri, 18 Sep 2026 13:56:13 +0000</pubDate>
      <link>https://dev.to/jarvisbitztech/how-we-took-a-saas-ai-assistant-from-30-second-answers-to-under-3-across-40000-pdfs-2bjj</link>
      <guid>https://dev.to/jarvisbitztech/how-we-took-a-saas-ai-assistant-from-30-second-answers-to-under-3-across-40000-pdfs-2bjj</guid>
      <description>&lt;p&gt;The client is a SaaS company in the US automotive space. I will keep them anonymous, but the shape of the problem will be familiar to anyone who added an AI assistant to a product in the first wave. They had done it early, and they had done it seriously: the assistant was meant to help customers find information, work out what they wanted, respond to it, spot sales opportunities in the conversation, search a very large document library, and eventually talk out loud.&lt;/p&gt;

&lt;p&gt;By the time we were involved it did most of those things badly. It behaved like a fixed workflow with a chat window on the front. It often misread what the customer was asking. And depending on the question, an answer could take anywhere from 10 to 30 seconds to appear, which in a chat interface feels like the product has died.&lt;/p&gt;

&lt;p&gt;The knowledge base was the reason for the last part. More than 120 GB of material, roughly 40,000 PDF documents, and the original system was searching far too much of it for every single question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we found when we looked at the latency
&lt;/h2&gt;

&lt;p&gt;Everyone's first instinct with a slow assistant is to blame the model. In this case the model was maybe a fifth of the problem. Most of the 10 to 30 seconds was spent before any text generation started, and it broke down into four habits that reinforced each other.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retrieving too much.&lt;/strong&gt; Every question pulled a wide set of passages from across the library, because nobody had decided what kind of question it was first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sending too much.&lt;/strong&gt; All of those passages went into the prompt. Bigger prompts mean slower generation and higher cost, and they also make the model worse at picking out the passage that actually matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking permissions late.&lt;/strong&gt; Which documents a given user was allowed to see was handled after retrieval, which meant retrieving material only to throw it away, and in a few cases meant material that should have been filtered leaking into the context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching nothing.&lt;/strong&gt; Two people asking the same question an hour apart paid the full cost twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The intent problem and the latency problem turned out to be the same problem. The system did not know what the customer wanted, so it retrieved everything, and retrieving everything is slow.&lt;/p&gt;

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

&lt;p&gt;We redesigned the system around a custom retrieval augmented generation pipeline with agentic orchestration on top, running on Google Vertex AI with Gemini, in Python, with speech to text and text to speech for the voice path. The important decisions were not the tools. They were the order things happen in.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it decides&lt;/th&gt;
&lt;th&gt;Why it comes before the model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intent detection&lt;/td&gt;
&lt;td&gt;What kind of request this is, and which path it should take&lt;/td&gt;
&lt;td&gt;Everything downstream depends on it: what to retrieve, which rules apply, whether this is a lead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization aware retrieval&lt;/td&gt;
&lt;td&gt;Which documents this user is entitled to, applied as a constraint on the search itself&lt;/td&gt;
&lt;td&gt;Filtering after retrieval wastes time and can leak; filtering inside retrieval does neither&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context construction&lt;/td&gt;
&lt;td&gt;The smallest set of passages that can answer the question well&lt;/td&gt;
&lt;td&gt;Smaller prompts are faster, cheaper and more accurate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;What can be reused safely across users and sessions, and what must never be&lt;/td&gt;
&lt;td&gt;Repeated questions stop paying the full cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agentic execution&lt;/td&gt;
&lt;td&gt;What happens next: answer, ask a follow up, route a lead, hand to a person&lt;/td&gt;
&lt;td&gt;The assistant responds to the request instead of walking a fixed script&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Intent first, then retrieval
&lt;/h2&gt;

&lt;p&gt;The single biggest change was putting intent detection at the front. The business defined the intents that mattered to them, with example phrasings for each, and every incoming message is classified against those by meaning before anything else happens. Once the system knows it is looking at, say, a question about a specific product's specification rather than a general browse, retrieval can be targeted at the right slice of the library instead of the whole thing.&lt;/p&gt;

&lt;p&gt;This is also where the lead generation logic lives. Certain intents carry commercial signal. When one of those fires, the assistant can move naturally from answering into asking a qualifying question or two, and route the conversation on. A customer who simply wants a fact gets the fact and is left alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions inside the search, not after it
&lt;/h2&gt;

&lt;p&gt;Different users on this platform are entitled to different documents. The original design retrieved first and filtered second, which is the common pattern and the wrong one. We moved authorization into the retrieval step itself, so the search only ever runs over documents the current user may see. It is faster, because there is less to search, and it closes a category of leak that is very hard to test for from an admin account, because admins can see everything and every answer looks fine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If your retrieval system is tested only by the people who built it, using their own accounts, you have not tested permissions at all. Test it with the narrowest account you have.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Less context, chosen better
&lt;/h2&gt;

&lt;p&gt;With intent known and the candidate set already narrowed by authorization, context construction becomes a question of how little you can send rather than how much. We were fairly aggressive here. The prompt carries the passages that answer the question and not much else. This is where a lot of the latency went, and it is also where a surprising amount of the accuracy came from: a model reading three relevant passages is more reliable than a model reading thirty, most of which are noise.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to first token, typical interaction&lt;/td&gt;
&lt;td&gt;10 to 30 seconds&lt;/td&gt;
&lt;td&gt;Under 3 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy across intent recognition and retrieval&lt;/td&gt;
&lt;td&gt;Frequent misreads&lt;/td&gt;
&lt;td&gt;Approximately 98% on the client’s evaluated use cases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI system operating cost&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;Roughly 30% lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge base&lt;/td&gt;
&lt;td&gt;120 GB, about 40,000 PDFs&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two honest notes on those figures. The 98% is measured on the set of use cases the client defined and evaluated, and I would not present it as a universal rate across every possible question. And "under three seconds" is time to the first token of the answer in typical interactions, which is what a person in a chat window actually feels; long answers still take longer to finish streaming.&lt;/p&gt;

&lt;p&gt;The cost reduction was mostly a side effect. Smaller prompts and cached answers mean fewer tokens, and fewer tokens is the whole cost model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The voice path
&lt;/h2&gt;

&lt;p&gt;The same engine sits behind a voice interface, with speech to text on the way in and text to speech on the way out. Voice is far less forgiving of latency than text; a three second pause in a spoken conversation is an eternity. The work described above is what made a voice path viable at all. Without the intent routing and context trimming, the assistant could not have answered fast enough to hold a conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell a team starting this today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Find out what kind of question you are answering before you go looking for the answer. Intent detection is not a nice to have on top of RAG; it is what makes retrieval targeted.&lt;/li&gt;
&lt;li&gt;Put permissions inside retrieval from day one. Retrofitting it is painful and the leak you are preventing is one your own testing will never find.&lt;/li&gt;
&lt;li&gt;Measure where the time actually goes before touching the model. In our experience the model is rarely the largest slice.&lt;/li&gt;
&lt;li&gt;Keep humans in charge of the decisions that matter. The assistant here answers questions, collects details and routes leads on its own. It does not close deals or change records without a person in the loop, and that is a design choice, not a limitation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hard part of enterprise AI is rarely calling the model. It is the retrieval, the permissions, the context management, the caching, the latency controls and the verification around it. That surrounding system is what we build, and it is the difference between an assistant that demos well and one that runs inside a real product with a substantial production user base. If you have an assistant that is slow, wrong, or both, the &lt;a href="https://www.jarvisbitz.com/free-ai-audit" rel="noopener noreferrer"&gt;free AI audit&lt;/a&gt; is a reasonable place to start, and &lt;a href="https://www.jarvisbitz.com/ai/retrieval-augmented-generation" rel="noopener noreferrer"&gt;this page&lt;/a&gt; describes how we approach retrieval systems in general.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Suresh B is the Founder &amp;amp; CEO of &lt;a href="https://www.jarvisbitz.com" rel="noopener noreferrer"&gt;JarvisBitz Tech&lt;/a&gt;, an AI and software engineering company helping businesses build and scale production AI systems and digital products. This article first appeared on &lt;a href="https://www.jarvisbitz.com/insights/saas-ai-assistant-30-seconds-to-3" rel="noopener noreferrer"&gt;jarvisbitz.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>python</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
