<?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: QuietDesk Studio</title>
    <description>The latest articles on DEV Community by QuietDesk Studio (@quietdesk_studio_83466628).</description>
    <link>https://dev.to/quietdesk_studio_83466628</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%2F4141890%2F3400a594-f4fc-49cf-8197-1a15e2c8b5e1.png</url>
      <title>DEV Community: QuietDesk Studio</title>
      <link>https://dev.to/quietdesk_studio_83466628</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/quietdesk_studio_83466628"/>
    <language>en</language>
    <item>
      <title>The MCP Redirect URI Edge Cases Dynamic Client Registration Doesn't Save You From</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Tue, 06 Oct 2026 18:17:23 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/the-mcp-redirect-uri-edge-cases-dynamic-client-registration-doesnt-save-you-from-1gh1</link>
      <guid>https://dev.to/quietdesk_studio_83466628/the-mcp-redirect-uri-edge-cases-dynamic-client-registration-doesnt-save-you-from-1gh1</guid>
      <description>&lt;h2&gt;
  
  
  Scope of this post
&lt;/h2&gt;

&lt;p&gt;This is not another "what is redirect_uri_mismatch" explainer. I already wrote the desktop-client version of that bug elsewhere on this blog — localhost vs 127.0.0.1, ephemeral local callback servers, the basics.&lt;/p&gt;

&lt;p&gt;This post covers a narrower, later-stage problem: &lt;strong&gt;the redirect URI and session bugs that show up &lt;em&gt;after&lt;/em&gt; you've already implemented Dynamic Client Registration (DCR) correctly.&lt;/strong&gt; Teams ship DCR, watch the happy path work in testing, and then get paged three weeks later when a client that authenticated fine on day one suddenly can't.&lt;/p&gt;

&lt;p&gt;What's explicitly &lt;em&gt;not&lt;/em&gt; here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Basic redirect URI string-matching mistakes (covered already)&lt;/li&gt;
&lt;li&gt;PKCE and authorization server discovery mechanics (separate post, already covered)&lt;/li&gt;
&lt;li&gt;Full session-store architecture, revocation policy, and multi-tenant client registries — that's the production-hardening layer, and it's dense enough that it lives in the paid pack, not a blog post&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're still debugging "my redirect_uri doesn't match," stop here and go fix that first. This post assumes DCR is working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DCR doesn't actually solve the redirect URI problem — it relocates it
&lt;/h2&gt;

&lt;p&gt;Dynamic Client Registration feels like it should end redirect URI pain: the client registers itself, the server echoes back an accepted &lt;code&gt;redirect_uris&lt;/code&gt; list, done. In practice, DCR just moves the trust boundary from "a human configured this URI once" to "two independent systems have to agree on an exact string, indefinitely, across restarts, redeploys, and port changes." That's a &lt;em&gt;harder&lt;/em&gt; problem, not an easier one, and real issue trackers are full of it right now.&lt;/p&gt;

&lt;p&gt;Here are five specific failure modes that show up after DCR is "working," ranked by how often they actually page someone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #1: The authorization endpoint disagrees with the registration endpoint
&lt;/h2&gt;

&lt;p&gt;The most confusing version of this bug is when registration succeeds but authorization rejects the &lt;em&gt;exact same URI&lt;/em&gt; moments later. One recent report against Tableau's MCP server showed exactly this: client registration succeeds via Dynamic Client Registration, but the authorization endpoint rejects the exact same redirect URI that was previously accepted during registration, even though the redirect URI used during authorization appears to be identical to the redirect URI accepted during dynamic client registration.&lt;/p&gt;

&lt;p&gt;That's not a client bug. It's two code paths on the server — the register handler and the authorize handler — validating against different sources of truth (a cache vs. a database, a normalized string vs. a raw one). If you're building the server side, &lt;strong&gt;the fix is to make both endpoints read from the same canonical store&lt;/strong&gt;, not two independently-maintained allowlists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #2: Loopback redirect URIs work for CLIs, except when the provider doesn't trust loopback at all
&lt;/h2&gt;

&lt;p&gt;For native/CLI clients, convention is to register a loopback URI like &lt;code&gt;http://127.0.0.1:&amp;lt;random-port&amp;gt;/callback&lt;/code&gt;. Most authorization servers accept this per spec. Some don't. A recent Atlassian MCP integration report shows the authorization server flatly refusing it: loopback (127.0.0.1) redirect URIs registered via dynamic client registration should be accepted for local/CLI OAuth flows, but Atlassian's authorization server rejects the redirect URI as not registered for the dynamically-registered client, blocking authentication entirely.&lt;/p&gt;

&lt;p&gt;If you're building an MCP &lt;em&gt;client&lt;/em&gt; that targets third-party servers, don't assume loopback-via-DCR is universally supported — some providers require a pre-registered static client with a fixed redirect URI instead, and your fallback path needs to exist before a user hits this in production, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #3: Stale client registrations die silently, and nothing tells the client to re-register
&lt;/h2&gt;

&lt;p&gt;This is the one that pages people weeks after launch, not during testing. When an identity provider redeploys, migrates its database, or rotates its OAuth backend, dynamically-registered clients can simply stop existing server-side — while the &lt;em&gt;local cache&lt;/em&gt; of that client's credentials looks perfectly healthy. One report against an agent framework described this precisely: when an MCP server's dynamically-registered OAuth client becomes invalid server-side (e.g. IdP redeploy, DB migration, rebrand), the client continues to present the dead client_id indefinitely, and the IdP rejects it with 'Redirect URI Mismatch', and the agent does not detect this condition or re-run dynamic client registration. The giveaway that it's &lt;em&gt;not&lt;/em&gt; a token problem: the cached refresh tokens are still valid for years into the future — the breakage is purely the registered client, not the credentials it holds.&lt;/p&gt;

&lt;p&gt;The practical fix: your client needs to treat &lt;code&gt;invalid_client&lt;/code&gt; / "redirect URI not registered" as a distinct error class from &lt;code&gt;invalid_grant&lt;/code&gt; / expired token, and respond by &lt;em&gt;re-running DCR from scratch&lt;/em&gt;, not by refreshing a token that was never the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #4: &lt;code&gt;client_uri&lt;/code&gt; origin-matching breaks tools that proxy through a different domain than they authenticate from
&lt;/h2&gt;

&lt;p&gt;Some authorization servers enforce that the optional &lt;code&gt;client_uri&lt;/code&gt; metadata field shares an origin with your registered &lt;code&gt;redirect_uri&lt;/code&gt; — a reasonable anti-phishing check that quietly breaks tools built for flexibility. One widely-used MCP debugging tool hit this directly: strict OAuth providers reject the configuration with error "client_uri must have the same origin as a redirect_uri," because the redirect_uri uses the current local origin while client_uri points to an unrelated domain.&lt;/p&gt;

&lt;p&gt;If you're writing a server that enforces this check, document it loudly in your registration error response — "client_uri origin must match redirect_uri origin" is a one-line fix for integrators once they know it's required. If you're writing a client, default &lt;code&gt;client_uri&lt;/code&gt; to the same origin as your redirect URI unless you have a specific reason not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #5: Session binding that happens &lt;em&gt;after&lt;/em&gt; the OAuth handshake, not during it
&lt;/h2&gt;

&lt;p&gt;This is the subtlest one and it's specifically a session issue, not a redirect issue — but it gets mistaken for a redirect bug constantly because the symptom is identical: the user completes auth successfully, gets a valid token, and tool calls still fail. A conformance tooling doc for MCP lists this as its own distinct failure class: token audience mismatch (OAuth flow succeeds but tools/list returns 401 because aud doesn't match the MCP resource URL), scope mismatch (token issued but the tool handler requires different scopes), and session binding, where some servers require a separate session init after auth.&lt;/p&gt;

&lt;p&gt;If your MCP server requires a distinct session-initialization call after the OAuth code exchange, a client that treats "got a valid token" as "ready to call tools" will silently fail on its first real request. The redirect URI did its job correctly — the bug is one layer downstream, in session lifecycle, and it's easy to misdiagnose as yet another OAuth config error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this post deliberately doesn't solve
&lt;/h2&gt;

&lt;p&gt;Each of these five gotchas has a narrow, specific fix at the code level, which is what I covered above. What none of them answer on their own is the bigger operational question: how do you design a client registration store that survives IdP redeploys, how do you version and rotate client secrets without an outage window, how do you build a test suite that catches "authorize endpoint disagrees with register endpoint" &lt;em&gt;before&lt;/em&gt; a real user hits it, and how do you scope session permissions so a stale or hijacked client can't silently reuse another caller's authorization. That's exactly the gap between "I fixed the bug I saw in the logs" and "I'd trust this in front of real traffic" — and it's the part that doesn't compress into a blog post.&lt;/p&gt;

&lt;p&gt;If you're building toward that production bar, the &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/a&gt; gives you a structured way to document exactly these kinds of auth and session failures when they happen, plus the permission-scoping templates to limit blast radius the next time a client registration goes stale or a session gets bound to the wrong caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line takeaway
&lt;/h2&gt;

&lt;p&gt;DCR doesn't end your redirect URI problems — it just moves them from "did I type the URI correctly" to "do my registration and authorization code paths agree, does my client know how to tell a dead registration from an expired token, and does my session lifecycle actually match what my OAuth flow assumes it does." Fix those four questions explicitly, and most of what looks like a mysterious OAuth bug turns out to be a one-line diagnosis.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>The MCP Load &amp; Resilience Checklist: What This Covers, and What It Deliberately Doesn't</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Thu, 01 Oct 2026 21:15:02 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/the-mcp-load-resilience-checklist-what-this-covers-and-what-it-deliberately-doesnt-2d7h</link>
      <guid>https://dev.to/quietdesk_studio_83466628/the-mcp-load-resilience-checklist-what-this-covers-and-what-it-deliberately-doesnt-2d7h</guid>
      <description>&lt;p&gt;A few weeks back I wrote about rate-limiting and backoff patterns for MCP servers under load. The feedback that came back wasn't "give me more backoff code" — it was "okay, but how do I know when I'm actually done? What's the full list I check before I say this server can take real traffic?"&lt;/p&gt;

&lt;p&gt;That's a fair question, and it's also a trap. The honest answer is that "production-ready" isn't one checklist — it's several, covering different failure domains, and the posts that try to cram all of them into one listicle end up shallow on every single item. So this piece does one thing: it covers &lt;strong&gt;load and resilience&lt;/strong&gt; — the specific question of whether your server degrades gracefully or falls over when traffic spikes, retries pile up, or a downstream dependency gets slow.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; cover authentication, OAuth token handling, tool permission scoping, or incident response after something breaks. Those are real, separate checklists with their own failure modes, and bolting them onto this one would make all of them worse. If you need that ground covered, that's exactly what our &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/a&gt; is for — it's the companion piece for the "something went wrong, now what" and "who's allowed to call this tool" questions this post intentionally skips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why scope matters here specifically
&lt;/h2&gt;

&lt;p&gt;MCP sits in an unusual spot: the caller isn't a human clicking slowly, it's an agent that can retry a failed call in milliseconds and doesn't get bored. A load checklist for a normal REST API assumes human-paced traffic with occasional bursts. An MCP server has to assume an agent that, on a bad day, will call the same tool in a tight loop because its reasoning told it the first nine calls "almost worked."&lt;/p&gt;

&lt;p&gt;That's the specific risk this checklist is built around. It's not a general "is my API fast" checklist — it's "will this server survive an agent that doesn't know when to stop."&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist: 10 items, four phases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Phase 1 — Limits (before traffic exists)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Per-tool rate limits, not just per-connection.&lt;/strong&gt; A global rate limit protects your server from one noisy client. It does nothing to stop a single agent from hammering your most expensive tool while staying under the global cap. Set limits per tool, per session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A documented maximum concurrency per session.&lt;/strong&gt; Decide how many in-flight tool calls one session is allowed to have before you start queuing or rejecting. If you haven't picked a number, the number is "however many the agent sends," which is not a number you want to discover in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Timeouts on every external call the tool makes, not just the top-level request.&lt;/strong&gt; A tool that calls three downstream services needs three timeout budgets, not one umbrella timeout that masks which dependency actually hung.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2 — Backoff and retry behavior (during a spike)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;4. Your server returns a retry signal the agent can actually use.&lt;/strong&gt; A bare 429 or 503 with no &lt;code&gt;Retry-After&lt;/code&gt; header just teaches the agent to retry immediately, which is the opposite of what you want under load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. You've tested what happens when the agent ignores the backoff signal.&lt;/strong&gt; Some clients respect &lt;code&gt;Retry-After&lt;/code&gt;. Plenty don't, especially home-grown agent loops. Your server's behavior under a client that retries immediately anyway is the real test, not the happy path where everyone cooperates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. There's a circuit breaker on your slowest downstream dependency.&lt;/strong&gt; If one backend call starts timing out, the server should stop sending it traffic for a cooldown window instead of queuing requests behind a dependency that's already failing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3 — Degradation (when limits are hit)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;7. You've decided what "degraded" looks like, and it's not silence.&lt;/strong&gt; When a rate limit kicks in, does the agent get a clear, structured error it can reason about, or does the connection just hang until it times out client-side? The second one looks like a bug to whoever's debugging it a week later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Queue depth has a ceiling.&lt;/strong&gt; An unbounded queue in front of a slow tool doesn't prevent failure, it just delays it and makes the eventual failure bigger. Pick a max queue size and reject past it with a clear error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 4 — Verification (before you trust any of the above)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;9. You've actually load-tested with concurrent sessions, not just concurrent requests from one session.&lt;/strong&gt; These look similar on a chart and behave completely differently in practice — session-level state (locks, in-memory caches, connection pools) often breaks only under genuine multi-session concurrency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. You know your p95 latency under load, not just your average.&lt;/strong&gt; An average that looks fine can hide a long tail where 5% of calls take 10x as long — and that tail is exactly where agent timeouts and retry storms start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha that isn't in the generic guides
&lt;/h2&gt;

&lt;p&gt;Here's the one that catches people who've done everything above correctly: &lt;strong&gt;backoff signals only work if your transport actually passes them through.&lt;/strong&gt; If you're running MCP over a reverse proxy or a load balancer with its own retry logic, your carefully-tuned &lt;code&gt;Retry-After&lt;/code&gt; header can get stripped or overridden before it reaches the client. Test the full path — agent to proxy to server and back — not just the server's response in isolation. This is the kind of thing that works perfectly in a local test and silently breaks the moment there's infrastructure in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's deliberately out of scope here (and where it lives)
&lt;/h2&gt;

&lt;p&gt;To be specific about the boundary, this checklist does not cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token validation and scope enforcement&lt;/strong&gt; — that's an auth checklist, not a load one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens after a rate-limit abuse pattern turns out to be a compromised or over-permissioned agent&lt;/strong&gt; — that's an incident-response and permission-scoping problem, and conflating it with load handling is how teams end up with checklists nobody can actually use as a definition of done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging and tracing setup&lt;/strong&gt; — necessary for diagnosing load issues after the fact, but it's its own piece of work with its own checklist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're past the load question and into "how do we write down what happened and lock down permissions so it doesn't happen again," that's the gap our &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;permission-scoping and incident postmortem template pack&lt;/a&gt; is built to close — it gives you a structured format for the postmortem itself and a worksheet for tightening scopes afterward, rather than starting from a blank doc at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using this as a definition of done
&lt;/h2&gt;

&lt;p&gt;Treat the 10 items above as pass/fail, not as "mostly handled." A server that passes 8 of 10 doesn't fail gracefully 80% of the time — it fails exactly the same way, just slightly less often, which is a worse debugging experience because the failure looks rare instead of systemic. Run through all 10 before you call a server load-tested, and keep the list next to your deploy checklist so the next server doesn't start the conversation from zero.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>llm</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>Rate-Limiting and Backoff Patterns for MCP Servers That Don't Fall Over Under Load</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Wed, 30 Sep 2026 20:45:01 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/rate-limiting-and-backoff-patterns-for-mcp-servers-that-dont-fall-over-under-load-48bb</link>
      <guid>https://dev.to/quietdesk_studio_83466628/rate-limiting-and-backoff-patterns-for-mcp-servers-that-dont-fall-over-under-load-48bb</guid>
      <description>&lt;p&gt;A team I talked with last quarter had an agent stuck in a loop calling the same tool for eight hours. By the time someone noticed, it had run up a five-figure cloud bill. That's not a hypothetical — a runaway automation loop making 127,000 API calls in roughly 8 hours has been reported to cost around $47,000 in a single incident. No rate limiter, no circuit breaker, nothing between the agent and the API meter.&lt;/p&gt;

&lt;p&gt;This post covers how to design rate limiting and retry/backoff for an MCP server so a stuck agent gets stopped cheaply instead of expensively. It does &lt;strong&gt;not&lt;/strong&gt; cover OAuth token refresh (see my earlier post on that gotcha) or full observability setup — that's a separate piece. Scope here: what layer to rate-limit at, how to return a 429 an agent can actually act on, and the one retry mistake that causes most MCP production outages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just add exponential backoff" isn't the fix
&lt;/h2&gt;

&lt;p&gt;Most teams stop at client-side retry logic and call it done. That's table stakes, not a solution — it protects against transient blips but does nothing for structural overload, and it can make things &lt;em&gt;worse&lt;/em&gt; if you get the placement wrong.&lt;/p&gt;

&lt;p&gt;The failure mode that actually takes MCP servers down in production isn't "no backoff." It's &lt;strong&gt;cascading retries inside a single agent turn&lt;/strong&gt;: the model calls a tool, gets a 429, the MCP server retries internally with backoff, and &lt;em&gt;while that retry is still pending&lt;/em&gt;, the same agent turn calls a second tool that hits the same upstream bucket. Now you have two retry loops racing on one quota, and bucket recovery ends up taking the longer of the two backoff schedules instead of either one alone.&lt;/p&gt;

&lt;p&gt;The fix isn't fancier backoff math. It's not retrying inside the server at all for agent-facing calls — bubble the 429 straight back to the model with a clear, structured error and a wait-time hint, and let the agent decide whether to wait, switch tools, or tell the user. Retrying silently inside the server hides the real signal from the one thing (the agent's reasoning loop) that's actually in a position to stop calling the same tool five more times in the next two seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the layer you're actually rate-limiting
&lt;/h2&gt;

&lt;p&gt;"Rate limit the server" is underspecified. There are at least three layers, and conflating them is how teams end up instrumenting one and getting blindsided by the other two:&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 controls&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transport / connection&lt;/td&gt;
&lt;td&gt;Requests per second, globally or per-session&lt;/td&gt;
&lt;td&gt;Simple, predictable backpressure; easy to reason about&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool / token budget&lt;/td&gt;
&lt;td&gt;Cost-weighted limits per tool call&lt;/td&gt;
&lt;td&gt;Tools with wildly different execution cost (a 1-second lookup vs. a 10-second batch job)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant / credential&lt;/td&gt;
&lt;td&gt;One bucket per (tenant, upstream API) pair&lt;/td&gt;
&lt;td&gt;Multi-tenant setups where one shared OAuth credential serves many customers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tenant layer matters more than it looks. If one OAuth credential is shared across customers, the first agent to get throttled can trigger a cascade where every other session behind that same credential inherits the block — sometimes within twenty seconds of the first call. Per-tenant token buckets contain the blast radius; the credential model underneath decides how big that radius is in the first place. If you're issuing one shared API key to a backend that serves 40 tenants, your rate limiter needs to know about tenants, not just requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  A token bucket you can actually reason about
&lt;/h2&gt;

&lt;p&gt;Token bucket is the right primitive for most MCP tool-call limiting because it allows bursts (a legitimate agent batch-processing 10 items) while still capping sustained rate.&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;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TenantTokenBucket&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;__init__&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;capacity&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="n"&gt;refill_per_sec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&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;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&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;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&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;refill_per_sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;refill_per_sec&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;last_refill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&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;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&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;try_consume&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;cost&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;1&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;bool&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;with&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;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&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;last_refill&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;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&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;capacity&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;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&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;refill_per_sec&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;last_refill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
            &lt;span class="k"&gt;if&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;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cost&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;tokens&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter and are easy to skip:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's async and lock-guarded.&lt;/strong&gt; MCP's streaming architecture means a single slow tool call can block the event loop if your limiter isn't async-aware — you'll end up deadlocking clients that are just waiting on a response, which looks like a hang, not a rate limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cost&lt;/code&gt; is a parameter, not always 1.&lt;/strong&gt; A tool that fans out into five upstream calls should consume five tokens, not one. Flat per-request limiting wastes budget on cheap calls and underprices expensive ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Returning a 429 the agent can act on
&lt;/h2&gt;

&lt;p&gt;A rate limit is only as useful as the signal it sends back. The common failure is a bare 429 with no machine-readable guidance — the client has no idea whether to wait 500ms or 5 minutes, so it does something arbitrary, usually retrying immediately and making things worse.&lt;/p&gt;

&lt;p&gt;Structure your rate-limit error so the agent (not a human reading logs) can parse it:&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;"error"&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rate_limited"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tool call rate limit exceeded for this session"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"retry_after_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant:acme-corp:tool:search_invoices"&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;The &lt;code&gt;retry_after_ms&lt;/code&gt; field is doing the real work — it tells the agent's retry logic exactly how long to back off instead of guessing. And if an upstream API sends you a &lt;code&gt;Retry-After&lt;/code&gt; header, treat it as a mandatory floor, not a suggestion. Some APIs enforce hard cool-downs (Airtable's 30-second window is a well-known example), and retrying before that window closes just resets the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backoff schedule itself
&lt;/h2&gt;

&lt;p&gt;For the retries that do belong in your code (agent-to-tool retries, not the cascading server-side kind above), a schedule that shows up consistently in production guidance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start at 500ms&lt;/li&gt;
&lt;li&gt;Double on each retry, capped at 30 seconds&lt;/li&gt;
&lt;li&gt;Add ±20% jitter so multiple sessions recovering from the same outage don't all retry in the same instant (thundering herd)&lt;/li&gt;
&lt;li&gt;Never go tighter than 500ms — anything faster just looks like a burst to the very limiter you're trying to respect&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Catching stuck agents before they trip the limit
&lt;/h2&gt;

&lt;p&gt;A rate limiter protects your server, but it doesn't fix the agent that's stuck in a loop — it just makes the loop cheaper. Worth adding separately: if a session calls the &lt;em&gt;same tool with identical arguments&lt;/em&gt; more than a handful of times in a row, that's a strong signal of a stuck agent, not legitimate traffic. A real agent varies arguments between calls (different IDs, different queries); a stuck one repeats the exact same call. Flagging that pattern and returning a hard stop (not just a slower retry) is cheaper than waiting for the token bucket to catch it call-by-call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the pack
&lt;/h2&gt;

&lt;p&gt;Rate limiting is one piece of a much longer list — session handling, timeout budgets, error taxonomy, and the pre-launch checks that catch this stuff before it's live. The rest is in the pack: the &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/a&gt; has the incident-response templates for when a rate limit &lt;em&gt;does&lt;/em&gt; get bypassed and something runs away anyway — scoped permission worksheets and a postmortem template built specifically for agent tool-call incidents, not generic infra outages.&lt;/p&gt;

&lt;p&gt;If you're shipping an MCP server this month, the combination that actually holds up under load is: tenant-scoped token buckets, structured 429s with &lt;code&gt;retry_after_ms&lt;/code&gt;, no server-side retries on agent-facing calls, and a separate stuck-loop detector that doesn't wait for the bucket to empty. Everything else is tuning.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>backend</category>
      <category>api</category>
    </item>
    <item>
      <title>The MCP Session Refresh Gotcha: Why "Token Expired" and "Session Expired" Are Not the Same Bug</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:44:00 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the-same-bug-4dmn</link>
      <guid>https://dev.to/quietdesk_studio_83466628/the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the-same-bug-4dmn</guid>
      <description>&lt;h2&gt;
  
  
  The bug report that keeps repeating itself
&lt;/h2&gt;

&lt;p&gt;Scan enough MCP issue trackers and you'll notice the same shape of bug filed against a dozen different clients and servers: Copilot CLI, Gemini CLI, Cursor, Open WebUI, Codex, and several homegrown gateways. The symptom is always some version of "it worked, then it silently stopped working, and the only fix was to log in again."&lt;/p&gt;

&lt;p&gt;The root cause is almost never "OAuth is broken." It's that two independent clocks — the &lt;strong&gt;access token's&lt;/strong&gt; expiry and the &lt;strong&gt;MCP session's&lt;/strong&gt; lifetime — get treated as one clock, and the recovery logic for one gets applied to the other.&lt;/p&gt;

&lt;p&gt;That distinction is the entire subject of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope: what this covers and what it doesn't
&lt;/h2&gt;

&lt;p&gt;This post is narrowly about &lt;strong&gt;keeping a connection alive after the initial handshake&lt;/strong&gt; — refreshing tokens and re-initializing sessions without forcing a user back through a browser. It assumes you already have PKCE, discovery, and initial token validation working (covered in an earlier post in this series). It does &lt;strong&gt;not&lt;/strong&gt; cover setting up dynamic client registration or scopes — that's a separate problem with a separate fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: Understand that you have two clocks, not one
&lt;/h2&gt;

&lt;p&gt;OAuth token expiry and session expiry are unrelated. An expired token gives you 401 and needs a refresh; an expired session gives you 404 and needs a re-initialize. The session itself, per spec, is a correlation and state handle. It carries no lease, no expiry timestamp and no promise about how long it stays valid. Nothing in the lifecycle specification obliges a server to keep it alive for any period at all.&lt;/p&gt;

&lt;p&gt;So you're managing two independent failure modes with two independent recoveries, and most of the bugs above come from collapsing them into one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: Correctly classify the failure before you react
&lt;/h2&gt;

&lt;p&gt;The spec's intent is clean: If a server has ended a session, requests carrying the old id must receive HTTP 404. The client's required response is to discard the id and send a fresh initialize. A 401 means something entirely different — the MCP server acts as an OAuth resource server. The client sends a bearer token on each request, the server validates expiry, scope and audience, and an invalid token gets a 401.&lt;/p&gt;

&lt;p&gt;In practice, plenty of servers don't respect this. One real-world rmcp-based server was found answering an unrecognized session ID with 401 Unauthorized: Session not found instead of the spec-mandated 404 — and the consequence is severe: OAuth MCP clients (claude.ai, Claude Code) treat a 401 as an invalid credential: they drop the token and demand an interactive re-auth. A perfectly valid, unexpired token gets discarded because the server picked the wrong status code for a session problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're building the server&lt;/strong&gt;, this is the single highest-leverage fix in this whole post: return 404 for unknown/expired sessions, 401 (with &lt;code&gt;WWW-Authenticate&lt;/code&gt;) only for actual credential problems, and never conflate the two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're building the client&lt;/strong&gt;, don't treat every 401 as "burn the token and force login." Distinguish an auth failure from a stale session by checking whether &lt;code&gt;WWW-Authenticate&lt;/code&gt; is present, and only discard credentials on a genuine 401 with that header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: Refresh the token — but check the provider actually gave you one
&lt;/h2&gt;

&lt;p&gt;This is where a surprising number of teams get stuck before refresh logic even runs. Several identity providers don't issue a refresh token unless you explicitly ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google&lt;/strong&gt;: Google OAuth issues a refresh token only when the authorization request includes access_type=offline (typically with prompt=consent). This is a Google-specific query parameter, not part of OAuth scope negotiation. Google does not honor offline_access as a scope, so requesting it via the existing scopes config has no effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta/Facebook-backed servers&lt;/strong&gt;: one production report found refresh_token is advertised but never issued. Meta's OAuth returns a long-lived access token with no refresh token. So once the client decides the session must be refreshed, there is nothing to refresh with, and the only possible outcome is the message above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom auth servers&lt;/strong&gt;: a similar bug appeared in the wild where offline_access is required but the engine never tells the client to request it, so every session hard-expires after 7 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix at the client level: check for a &lt;code&gt;refresh_token&lt;/code&gt; field immediately after the initial grant, and fail loudly in development if it's missing — don't wait until a user hits it in production three hours into a session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: Don't let a successful token refresh leave a stale session behind
&lt;/h2&gt;

&lt;p&gt;This is the gotcha most teams never see coming, because the token refresh itself succeeds. A Streamable HTTP transport ties a session ID to the credentials that created it. If you refresh the bearer token but keep reusing the old &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, the server sees a mismatch between the session and the token attached to it.&lt;/p&gt;

&lt;p&gt;This exact failure was reported against a production MCP integration: after Cursor performs a silent OAuth access-token refresh (near token expiry / refresh flow), the result was Session token does not match bearer token. The only reliable recovery was heavy-handed: Note that only toggling the MCP server off/on, full re-login, or restarting Cursor recovers it. Manual testing confirmed the actual fix is much lighter than a full restart: refreshing the OAuth access token and calling initialize again with the new bearer produces a new Mcp-Session-Id and restores tool calls. Cursor's silent refresh does the token part but skips session re-initialization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb: a successful token refresh is not a completed recovery on its own.&lt;/strong&gt; After any refresh, re-run &lt;code&gt;initialize&lt;/code&gt; with the new bearer token and accept whatever session ID comes back. Don't assume the old session ID is still valid just because the token now is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 5: Retry once, with a real circuit breaker — not forever
&lt;/h2&gt;

&lt;p&gt;The last gotcha is about failure discipline. One gateway logged this pattern in production: An MCP server whose credential was never saved is retried forever. Backoff grows to 300 s and then stays there; 18+ failures logged in one session with no circuit breaker, no UI indicator. A 401 caused by a genuinely dead credential is not a transient network blip — retrying it indefinitely just burns cycles and hides the real problem from whoever's on call.&lt;/p&gt;

&lt;p&gt;The pattern that actually works, synthesized from several fixes above:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Catch the 401 vs 404 distinction at the transport layer.&lt;/li&gt;
&lt;li&gt;On 401 with a refresh token available: refresh once, then re-run &lt;code&gt;initialize&lt;/code&gt;, then retry the original call once.&lt;/li&gt;
&lt;li&gt;On 404: discard the session ID, re-run &lt;code&gt;initialize&lt;/code&gt; with the existing (still-valid) token, retry once.&lt;/li&gt;
&lt;li&gt;If refresh itself fails (invalid_grant, no refresh_token, or repeated 401 after a fresh refresh): stop retrying, surface a "needs reauthorization" state, and don't touch the retry loop again until the credential changes.&lt;/li&gt;
&lt;li&gt;Log which of these four paths fired. When a session breaks in production, that log line is the difference between a five-minute diagnosis and an hour of guessing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why this is worth getting right before launch
&lt;/h2&gt;

&lt;p&gt;None of these five phases are exotic — they're each a handful of lines. But they interact, and the bug reports above show that even mature clients (Cursor, Gemini CLI, Copilot CLI) shipped with at least one of these five phases missing or wrong, and the resulting incident always looked the same to the end user: "the MCP server just stopped working."&lt;/p&gt;

&lt;p&gt;If you're running MCP servers in production, this class of failure — auth working perfectly for hours, then a session or refresh edge case taking the whole integration down with a vague error — is exactly the kind of incident worth documenting &lt;em&gt;before&lt;/em&gt; it happens, not after. Our &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/a&gt; gives you a structured way to capture root cause, blast radius, and the permission/session boundaries involved, so the next time a token refresh gotcha slips through, you're writing a five-minute postmortem instead of re-deriving the timeline from scratch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>The Desktop Redirect URI Gotcha That Breaks MCP OAuth (And How to Actually Fix It)</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Mon, 28 Sep 2026 16:45:15 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/the-desktop-redirect-uri-gotcha-that-breaks-mcp-oauth-and-how-to-actually-fix-it-25ak</link>
      <guid>https://dev.to/quietdesk_studio_83466628/the-desktop-redirect-uri-gotcha-that-breaks-mcp-oauth-and-how-to-actually-fix-it-25ak</guid>
      <description>&lt;h2&gt;
  
  
  Scope of this post
&lt;/h2&gt;

&lt;p&gt;This is not a general MCP OAuth tutorial. It covers exactly one thing: &lt;strong&gt;why desktop MCP clients (Claude Desktop, Cursor, Claude Code, custom native apps) fail during the redirect step of OAuth, and what to do about each failure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What's explicitly &lt;em&gt;not&lt;/em&gt; here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PKCE and authorization server discovery — that's its own post, already covered in depth elsewhere on this blog.&lt;/li&gt;
&lt;li&gt;Token validation and audience checks — separate concern, separate post.&lt;/li&gt;
&lt;li&gt;Full production hardening (rate limiting, client allowlisting policy, session revocation) — that's the checklist-level work, and it's dense enough that it lives in the paid pack, not a blog post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're debugging a redirect_uri error right now, this is the post. If you're trying to figure out your entire auth architecture, read the discovery/PKCE post first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this specific piece breaks so often
&lt;/h2&gt;

&lt;p&gt;Desktop apps aren't web apps. They can't sit at a public URL waiting for a redirect. So every desktop MCP client fakes it: it spins up a short-lived local HTTP server, tells the authorization server "send the code here," and tears the server down once the callback lands. MCP clients like Claude Desktop and Cursor are desktop apps, not web apps, so the standard approach is to start a temporary local HTTP server, use that as the redirect URI, and shut it down after receiving the callback.&lt;/p&gt;

&lt;p&gt;That handoff — client picks a port, registers it, then has to send back the &lt;em&gt;exact same string&lt;/em&gt; later — is where almost every desktop OAuth bug in MCP actually lives. Here are the four that show up over and over in real issue trackers, not hypotheticals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #1: &lt;code&gt;localhost&lt;/code&gt; vs &lt;code&gt;127.0.0.1&lt;/code&gt; are not the same string
&lt;/h2&gt;

&lt;p&gt;This is the single most common desktop redirect failure. OAuth redirect matching is exact-string, not semantic. Some MCP clients register redirect URIs with localhost, but then send 127.0.0.1 during the OAuth flow (or vice versa), and according to the OAuth spec these are different strings, so redirect URI validation fails even though they're semantically identical loopback addresses. It's not user error — it's usually a bug in the client's own networking layer. It's usually a client bug: the client might use different network libraries for registration vs. the actual callback, or the OS might resolve localhost differently in different contexts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix on your server:&lt;/strong&gt; normalize both sides. Don't just normalize at registration time — normalize again at token exchange, because clients can flip-flop between the two calls independently. One team building MCP OAuth against Clerk found that normalizing the redirect_uri again at token exchange (always to localhost) to match what was registered was necessary because clients can be inconsistent at different steps — some register with localhost but send 127.0.0.1 during token exchange, or vice versa — and normalizing only at registration wasn't enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #2: dynamic ports break exact-match validation against a fixed document
&lt;/h2&gt;

&lt;p&gt;If you're using Client ID Metadata Documents (CIMD) instead of per-client dynamic registration, your redirect_uris list is static — but the client's actual callback port is not. Claude Code, for example, picks a random port every run. Claude Code uses dynamic ports for its OAuth callback server (e.g., &lt;a href="http://localhost:8080/callback" rel="noopener noreferrer"&gt;http://localhost:8080/callback&lt;/a&gt; or &lt;a href="http://localhost:60351/callback" rel="noopener noreferrer"&gt;http://localhost:60351/callback&lt;/a&gt;). If your CIMD document only lists a bare &lt;code&gt;http://localhost/callback&lt;/code&gt;, that doesn't match a port-bearing URI at all, because &lt;a href="http://localhost:8080/callback" rel="noopener noreferrer"&gt;http://localhost:8080/callback&lt;/a&gt; does not match &lt;a href="http://localhost/callback" rel="noopener noreferrer"&gt;http://localhost/callback&lt;/a&gt; because no-port defaults to port 80 per RFC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; use wildcard ports in your CIMD redirect_uris, which the spec explicitly supports: the CIMD document should use wildcard ports to match Claude Code's actual callback behavior — "&lt;a href="http://localhost:*/callback" rel="noopener noreferrer"&gt;http://localhost:*/callback&lt;/a&gt;", "&lt;a href="http://127.0.0.1:*/callback" rel="noopener noreferrer"&gt;http://127.0.0.1:*/callback&lt;/a&gt;" — and this wildcard syntax is supported by the CIMD spec and by FastMCP's redirect_validation.py. If your server framework doesn't support wildcard matching yet, your fallback is forcing Dynamic Client Registration instead, since server operators can disable CIMD support so the client falls back to Dynamic Client Registration, which works because the client registers its own redirect URIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #3: custom URI schemes get rejected outright
&lt;/h2&gt;

&lt;p&gt;Some desktop clients skip the loopback server entirely and register an app-specific scheme like &lt;code&gt;myapp://oauth-callback&lt;/code&gt; instead of an &lt;code&gt;http://localhost&lt;/code&gt; address. This looks fine until you hit an authorization server that enforces the stricter native-app rules. One real-world report showed a desktop app sending a custom scheme, claude://claude.ai/mcp-auth-callback/sdk to a server whose authorization endpoint accepts only https:// or loopback redirects — meaning the exact same client worked fine against one host and failed against another with no code change on the client side at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; if you're building the server, decide upfront and document which redirect schemes you accept — loopback, HTTPS, or a specific custom scheme allowlist — and reject silently-different behavior between hosts. If you're building the client, prefer loopback over custom schemes unless you have a specific platform reason not to; it's the pattern every major authorization server actually tests against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #4: registering &lt;code&gt;localhost&lt;/code&gt;, sending &lt;code&gt;127.0.0.1&lt;/code&gt; at the platform-policy level
&lt;/h2&gt;

&lt;p&gt;This isn't just a client quirk — some identity platforms have opinions about which loopback form is even valid, and those opinions can silently break dynamic-port native apps. Microsoft Entra, for instance, only does port-agnostic matching for &lt;code&gt;localhost&lt;/code&gt;, not &lt;code&gt;127.0.0.1&lt;/code&gt;: Entra's port-agnostic loopback matching works only for localhost; dynamic-port native apps therefore require localhost. A client that defaults to &lt;code&gt;127.0.0.1:52446&lt;/code&gt; against an Entra-backed MCP server will hit a hard rejection — the browser lands on the Entra error AADSTS50011, that the redirect URI specified in the request does not match the redirect URIs configured for the application — even though the exact same client works fine against a server that uses standard RFC 8252 loopback matching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; if you're behind Entra or a similarly strict IdP, standardize on &lt;code&gt;localhost&lt;/code&gt; for your loopback redirect, not &lt;code&gt;127.0.0.1&lt;/code&gt;, and test both forms in CI against your actual authorization server before shipping — don't assume RFC 8252 behavior is universal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference: which fix goes where
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Root cause&lt;/th&gt;
&lt;th&gt;Fix location&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;redirect_uri_mismatch&lt;/code&gt; on otherwise-correct flow&lt;/td&gt;
&lt;td&gt;localhost vs 127.0.0.1 string mismatch&lt;/td&gt;
&lt;td&gt;Server: normalize at registration &lt;em&gt;and&lt;/em&gt; token exchange&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CIMD rejects a valid dynamic-port callback&lt;/td&gt;
&lt;td&gt;Static redirect_uris list, dynamic client port&lt;/td&gt;
&lt;td&gt;Server: wildcard port syntax in CIMD, or force DCR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client works against one server, fails against another&lt;/td&gt;
&lt;td&gt;Custom scheme accepted inconsistently&lt;/td&gt;
&lt;td&gt;Server: explicit scheme allowlist, documented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AADSTS50011 or similar IdP-specific rejection&lt;/td&gt;
&lt;td&gt;Loopback host policy differs per IdP&lt;/td&gt;
&lt;td&gt;Server: standardize on IdP's supported loopback host&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where this fits into the bigger picture
&lt;/h2&gt;

&lt;p&gt;Fixing these four gotchas gets your desktop OAuth flow working. It does not tell you whether your redirect validation is &lt;em&gt;safe&lt;/em&gt; — that's a different question, and a more consequential one, because loose redirect validation is a real attack surface: if your validation is too loose (wildcards, broad localhost rules, arbitrary custom schemes), a malicious client can register a redirect that routes sensitive data somewhere unsafe, and OAuth history is full of "slightly wrong redirect validation → stolen codes → stolen tokens."&lt;/p&gt;

&lt;p&gt;That's the part this post deliberately leaves out: how tight is "too tight," what to log when a redirect gets rejected, and how to scope what a compromised or misconfigured OAuth client can actually reach once it does get a token. If you've had an agent or MCP client misbehave — or you just want a structured way to think through blast radius before it happens — that's exactly what the &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/a&gt; is built for: a template for writing the postmortem and tightening scopes after something goes wrong, so the next redirect URI bug doesn't turn into a token leak.&lt;/p&gt;

&lt;p&gt;For now: fix your loopback string matching, wildcard your CIMD ports, pick one redirect scheme and stick to it, and test against your actual IdP's loopback policy instead of assuming RFC 8252 is universal. That's the whole gotcha, scoped tight on purpose.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>security</category>
      <category>api</category>
    </item>
    <item>
      <title>A Go/No-Go Rubric for Evaluating MCP Servers Before They Touch Production Traffic</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Sat, 26 Sep 2026 22:44:18 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/a-gono-go-rubric-for-evaluating-mcp-servers-before-they-touch-production-traffic-a0d</link>
      <guid>https://dev.to/quietdesk_studio_83466628/a-gono-go-rubric-for-evaluating-mcp-servers-before-they-touch-production-traffic-a0d</guid>
      <description>&lt;p&gt;Most MCP "readiness checklists" are really just a list of things that would be nice to have. They tell you to add logging, add auth, add tests — true, but not decision-grade. None of them answer the question a lead engineer actually asks before a release: &lt;em&gt;given everything we know right now, do we ship this server to production agents or not?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This post is a rubric, not a wishlist. It's built to produce a number and a decision, the way a real go/no-go review works. It won't re-teach OAuth flows or walk through building a server from scratch — we've covered both of those in depth already. This is specifically about &lt;strong&gt;evaluation&lt;/strong&gt;: how you score a server you (or someone else) already built, before it gets wired into an agent that real users depend on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "it passed my manual tests" isn't an answer
&lt;/h2&gt;

&lt;p&gt;Protocol-level tests (does &lt;code&gt;tools/list&lt;/code&gt; return valid schemas, does the server respond to &lt;code&gt;initialize&lt;/code&gt;) tell you the server speaks MCP correctly. They don't tell you what happens when an agent is calling it. Those are two different failure surfaces, and conflating them is the single most common mistake teams make when they say a server is "tested."&lt;/p&gt;

&lt;p&gt;Server tests validate implementation behavior against known requirements, and MCP evals measure agent performance across realistic tasks. A server can pass every unit and conformance test and still fall apart the moment a non-deterministic agent has to choose between five similar-sounding tools, or retries a call that already partially succeeded. An MCP server can pass every protocol and handler test and still break down once an agent has to pick a tool, fill in valid arguments, and work through errors on the way to finishing a task. A scorer that reads only the final response misses silent failures and duplicate records left behind by a retry.&lt;/p&gt;

&lt;p&gt;So the rubric below has two tiers, and you need both before you can call a server production-ready:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier 1 — Protocol and handler correctness.&lt;/strong&gt; Does the server do what its schema says, deterministically, every time?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 2 — Agent-facing behavior.&lt;/strong&gt; When a real (or simulated) agent drives the server through a task, does it pick the right tool, construct valid arguments, and leave the system in a sane state afterward?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tier 1: Protocol and handler checklist (score each 0/1)
&lt;/h2&gt;

&lt;p&gt;Run these against the raw protocol, no LLM involved. They should be fast, deterministic, and part of CI — not something you eyeball once before a release.&lt;/p&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;Check&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Every tool's JSON schema is valid and matches the runtime handler's actual accepted inputs&lt;/td&gt;
&lt;td&gt;Schema drift is invisible until an agent sends a payload the handler silently mishandles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Malformed input returns a structured error, not a stack trace or empty 200&lt;/td&gt;
&lt;td&gt;Agents parse error text to decide their next move; garbage in, garbage reasoning out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Every documented tool has at least one negative test (bad auth, missing field, out-of-range value)&lt;/td&gt;
&lt;td&gt;Happy-path-only test suites are the norm, and it shows in production incident logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Timeouts and partial failures are distinguishable from success&lt;/td&gt;
&lt;td&gt;An agent that can't tell "still running" from "failed" will duplicate side effects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Token/session validation happens on every call, not just at connection time&lt;/td&gt;
&lt;td&gt;Long-lived sessions with front-loaded auth checks are a common gap once bearer tokens get forwarded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;p95 latency under realistic load is measured, not assumed&lt;/td&gt;
&lt;td&gt;A tool that's 200ms in dev and 5s under load stalls the agent's reasoning loop and can trigger retries that compound the problem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're scoring under 5/6 here, stop — don't move to Tier 2 yet. Agent-level evals on top of a shaky protocol layer just produce noisy, non-repeatable results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 2: Agent-facing eval checklist
&lt;/h2&gt;

&lt;p&gt;This is the part almost nobody automates, because it requires actually running an agent against the server rather than curling endpoints.&lt;/p&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;Check&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Harness uses the &lt;em&gt;same&lt;/em&gt; transport, auth path, and tool list as production&lt;/td&gt;
&lt;td&gt;The eval harness acts as an MCP client, so it should use the same transport, authentication path, and exposed tools as the client used in production. A harness that skips OAuth or substitutes a shortened tool list is measuring a different configuration, and its scores say nothing about how reliably agents will use the production server.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Tool selection is scored separately from argument construction and outcome&lt;/td&gt;
&lt;td&gt;A single pass-or-fail score shows a run failed but does not identify the cause. Effective agent evaluation separates tool use, arguments, execution, and outcome into distinct measurements.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Every task is run multiple trials, not once&lt;/td&gt;
&lt;td&gt;Repeated trials: Multiple runs of the same task that reveal how consistently the agent completes it. A repeatable MCP eval ties each task-level pass rate to the agent behavior and system state behind it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Adversarial and near-miss prompts are included, not just the intended use case&lt;/td&gt;
&lt;td&gt;Test with adversarial prompts first. Don't just test the happy path. Try prompts that are similar to the intended use case but shouldn't activate the server.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;System state after a run is checked, not just the final agent message&lt;/td&gt;
&lt;td&gt;A trajectory can "look" successful while leaving duplicate writes or half-completed operations behind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Recovery behavior is tested, not just failure detection&lt;/td&gt;
&lt;td&gt;Being observable isn't the same as being recoverable — check whether state can actually roll back or resume after a partial failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Score Tier 2 the same way: 0/1 per row, and treat anything under 5/6 as "not yet," not "close enough."&lt;/p&gt;

&lt;h2&gt;
  
  
  What "fine, it's not ready" actually looks like
&lt;/h2&gt;

&lt;p&gt;A server that scores 4/6 and 3/6 isn't broken — it's early. The honest move is to say so publicly (or at least in your internal docs): note what's covered, what isn't, and what an integrator should assume is their responsibility until it's fixed. If the server has no concept of budgets, quotas, or side-effect governors, then it is asking the orchestrator to do all the defensive work. Sometimes that's acceptable. But then be honest: the server is not production-complete on its own. That kind of scoped honesty is more useful to the next engineer than a README that just says "production ready" with no evidence behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning this into a repeatable process
&lt;/h2&gt;

&lt;p&gt;The table above is something you can run once for a demo. The harder part — and the part that actually matters eight weeks from now when the server has changed twelve times — is making it repeatable: fixed task sets, versioned scorers, a way to diff "did this get better or worse after the last deploy" instead of re-litigating the whole checklist from memory every release.&lt;/p&gt;

&lt;p&gt;That's exactly the gap our &lt;strong&gt;MCP Production Checklist + Eval Templates pack&lt;/strong&gt; is built to close. It's the scored version of everything above, packaged as reusable YAML task definitions and a minimal harness scaffold you can point at any server — plus the &lt;a href="https://quietdesk15.gumroad.com/l/mcpevalpack" rel="noopener noreferrer"&gt;Production Checklist&lt;/a&gt; itself as a standalone document your team can actually use in a release review instead of reconstructing it from a blog post every time.&lt;/p&gt;

&lt;p&gt;If you're evaluating a server you didn't build — a community package, a vendor's offering, or a teammate's first attempt — running both tiers above before it touches real traffic is the difference between finding the gaps in a review meeting and finding them in an incident channel at 2am.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>testing</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>MCP OAuth 2.1 in Practice: Authorization Server Discovery, PKCE, and Token Validation That Actually Works</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Fri, 25 Sep 2026 14:42:56 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/mcp-oauth-21-in-practice-authorization-server-discovery-pkce-and-token-validation-that-actually-1k7m</link>
      <guid>https://dev.to/quietdesk_studio_83466628/mcp-oauth-21-in-practice-authorization-server-discovery-pkce-and-token-validation-that-actually-1k7m</guid>
      <description>&lt;p&gt;Most MCP OAuth guides stop at "point your client at an authorization server and get a token." That's the easy 20%. The other 80% — the part that decides whether a compromised client can impersonate every user on your server — is discovery, audience binding, and token validation. This post walks through that part with actual code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP OAuth isn't "just OAuth"
&lt;/h2&gt;

&lt;p&gt;The MCP authorization spec is built on OAuth 2.1, but it adds constraints that a lot of implementations skip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The MCP server acts as an &lt;strong&gt;OAuth resource server&lt;/strong&gt;, not the authorization server itself in most production setups. It validates tokens; it doesn't issue them.&lt;/li&gt;
&lt;li&gt;Clients must discover the authorization server via &lt;strong&gt;protected resource metadata&lt;/strong&gt; (RFC 9728) rather than hardcoding endpoints.&lt;/li&gt;
&lt;li&gt;Tokens must be &lt;strong&gt;audience-restricted&lt;/strong&gt; to the specific MCP server — a token minted for Server A must not work against Server B, even if both trust the same identity provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PKCE is mandatory&lt;/strong&gt;, not optional, because MCP clients are often public clients (CLI tools, desktop apps) that can't hold a client secret safely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip any of these and you get a server that "works" in a demo with one client and one user, and quietly becomes a multi-tenant data leak in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Publish protected resource metadata
&lt;/h2&gt;

&lt;p&gt;Your MCP server needs to tell clients which authorization server to use. This is a static JSON document at a well-known path:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/.well-known/oauth-protected-resource&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;"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://mcp.yourcompany.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;"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.yourcompany.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;"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="nl"&gt;"resource_documentation"&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://mcp.yourcompany.com/docs"&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;The &lt;code&gt;resource&lt;/code&gt; field matters more than it looks — it's what you'll check against the token's &lt;code&gt;aud&lt;/code&gt; claim later. If you skip this document, every client has to be manually configured with your auth server's URL, which is exactly the kind of hardcoded assumption that breaks the first time you rotate identity providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: PKCE, end to end
&lt;/h2&gt;

&lt;p&gt;Your client generates a code verifier and challenge before redirecting to the authorization server:&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;import&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;

&lt;span class="n"&gt;code_verifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_urlsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;code_challenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code_verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;auth_url&lt;/span&gt; &lt;span class="o"&gt;=&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;auth_server&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/authorize&lt;/span&gt;&lt;span class="sh"&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;?response_type=code&amp;amp;client_id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;&amp;amp;redirect_uri=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;redirect_uri&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;&amp;amp;code_challenge=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code_challenge&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;&amp;amp;code_challenge_method=S256&lt;/span&gt;&lt;span class="sh"&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;&amp;amp;resource=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mcp_server_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# RFC 8707 resource indicator
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;resource&lt;/code&gt; parameter is the piece most tutorials leave out. It's what tells the authorization server which audience to bake into the token, so the token it issues can't be replayed against a different MCP server. Without it, a user who authorizes your MCP server today could have that same token work against a malicious server tomorrow if the auth server doesn't scope tokens per-resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Validate the token like you mean it
&lt;/h2&gt;

&lt;p&gt;This is where most "production" MCP servers fall short. Validating a token means checking four things, not one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;th&gt;What happens if you skip it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Signature / introspection&lt;/td&gt;
&lt;td&gt;Forged tokens&lt;/td&gt;
&lt;td&gt;Anyone can mint a fake token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;aud&lt;/code&gt; claim matches your resource URL&lt;/td&gt;
&lt;td&gt;Tokens issued for a different service&lt;/td&gt;
&lt;td&gt;Cross-server token replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;exp&lt;/code&gt; / &lt;code&gt;nbf&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Expired or not-yet-valid tokens&lt;/td&gt;
&lt;td&gt;Sessions that never die&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope sufficiency for the requested tool&lt;/td&gt;
&lt;td&gt;Over-privileged calls&lt;/td&gt;
&lt;td&gt;A read-only token deleting records&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;validate_token&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="n"&gt;expected_audience&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;TokenClaims&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="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&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;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;expected_audience&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="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&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;TokenExpired&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;expected_audience&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&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;aud&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AudienceMismatch&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;TokenClaims&lt;/span&gt;&lt;span class="p"&gt;(&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Teams almost always get the signature check right — libraries do that for you. They almost as often skip the audience check, because it requires knowing what "your own identity" is as a resource server, which means you actually have to do the metadata step from Step 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Sessions are not the same thing as tokens
&lt;/h2&gt;

&lt;p&gt;A valid OAuth token proves who the caller is. It doesn't tell you what state their MCP session is in. Conflating the two is how you get bugs where a token refresh silently resets an agent's in-progress multi-step workflow.&lt;/p&gt;

&lt;p&gt;Keep them separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token&lt;/strong&gt;: short-lived (15–60 min), carries identity and scopes, validated on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session&lt;/strong&gt;: server-side state keyed by a session ID the client sends alongside the token, holding conversation/tool-call context, with its own TTL and idle timeout independent of the token's lifetime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a token expires mid-session, refresh the token without invalidating the session. When a session expires, require a fresh authorization handshake even if the token is technically still valid — an idle session is a bigger risk surface than a rotated token.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode that actually happens in the wild
&lt;/h2&gt;

&lt;p&gt;The most common real-world break isn't a missing signature check — it's &lt;strong&gt;audience confusion&lt;/strong&gt; in multi-tenant setups where one authorization server issues tokens for several MCP servers under the same organization. A team validates the signature, checks expiry, and calls it done. Six months later, a token minted for the internal analytics MCP server also works against the customer-facing one, because nobody checked &lt;code&gt;aud&lt;/code&gt;. That's not a hypothetical; it's the exact gap the resource indicator in Step 2 and the audience check in Step 3 exist to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits with what we've already covered
&lt;/h2&gt;

&lt;p&gt;If you haven't seen the walkthrough on wiring up auth, sessions, and error recovery in a minimal server, that post covers the end-to-end request lifecycle this one zooms into. And if you're deciding whether you need any of this OAuth machinery at all, the piece on MCP vs. simple scripts has the decision framework for when a stateless script beats a full MCP server with session management.&lt;/p&gt;

&lt;p&gt;If you'd rather start from code that already implements this correctly than assemble it from blog posts, the &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AgentKitLab MCP Production Checklist pack&lt;/a&gt; includes a minimal working server with the discovery document, PKCE flow, and audience-validated token handling shown above already wired up, plus a checklist to audit an existing server against and agent-eval test templates to catch regressions before they ship. It's $9–$29 depending on the tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference: the four checks before you ship
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Do you publish &lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt; with a correct &lt;code&gt;resource&lt;/code&gt; field?&lt;/li&gt;
&lt;li&gt;Does your client send a &lt;code&gt;resource&lt;/code&gt; parameter (RFC 8707) during authorization?&lt;/li&gt;
&lt;li&gt;Does your validation logic check &lt;code&gt;aud&lt;/code&gt;, not just signature and expiry?&lt;/li&gt;
&lt;li&gt;Are sessions tracked independently of token lifetime, with their own idle timeout?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can't answer yes to all four, that's your next PR — not a nice-to-have, but the difference between an OAuth flow that looks done and one that's actually safe to point real users at.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>MCP Tool Poisoning: How Malicious Tool Descriptions Hijack Your Agent (And How to Stop It)</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:41:46 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/mcp-tool-poisoning-how-malicious-tool-descriptions-hijack-your-agent-and-how-to-stop-it-517c</link>
      <guid>https://dev.to/quietdesk_studio_83466628/mcp-tool-poisoning-how-malicious-tool-descriptions-hijack-your-agent-and-how-to-stop-it-517c</guid>
      <description>&lt;p&gt;If you've shipped an MCP server or connected an agent to a few community servers, you've probably read the words "tool poisoning" somewhere and moved on. It sounds like theoretical security-blog fear-mongering. It isn't. It's the single most distinctive attack class in the MCP ecosystem, and it works because of how MCP is designed to work, not because of a bug someone will eventually patch.&lt;/p&gt;

&lt;p&gt;This post explains what tool poisoning actually is, why your existing auth and network security don't stop it, and the specific controls you can add to a production MCP server or client this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tool poisoning actually is
&lt;/h2&gt;

&lt;p&gt;MCP Tool Poisoning is an indirect prompt injection attack targeting AI agents that connect to external tool servers via the Model Context Protocol. The attacker runs a malicious MCP server whose tools look normal, but their responses contain hidden instructions. When an AI agent calls one of these tools, the injected instructions land in the LLM's context window and get treated as trusted input. The LLM follows them – calling restricted tools, leaking data, or bypassing its own system prompt.&lt;/p&gt;

&lt;p&gt;The root cause isn't a missing patch — it's architectural. The root cause is a trust gap between connect-time and runtime: tool descriptions are reviewed once, when the agent first connects to a server, but tool responses go straight into the LLM context with no equivalent check. That unguarded runtime channel is what the attacker abuses.&lt;/p&gt;

&lt;p&gt;This was first documented publicly by Invariant Labs, who found that a malicious server cannot only exfiltrate sensitive data from the user but also hijack the agent's behavior and override instructions provided by other, trusted servers, leading to a complete compromise of the agent's functionality. They also named a closely related pattern, &lt;strong&gt;tool shadowing&lt;/strong&gt;: a malicious server injects a tool description that modifies the agent's behavior with respect to a trusted service or tool, leading to malicious behavior. The danger compounds the more servers you connect — the problem of malicious MCP servers becomes even more severe when multiple MCP servers are connected to the same client, because the LLM can't tell which server "owns" an instruction once it's in context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's so hard to catch
&lt;/h2&gt;

&lt;p&gt;Three properties make this attack class nastier than a typical vulnerability:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It hides in plain sight.&lt;/strong&gt; Because MCP tool poisoning hides malicious logic inside trusted MCP tool metadata, traditional code review or UI inspection won't catch it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's conditional.&lt;/strong&gt; Most attacks operate silently and conditionally—only triggering when the right sequence of actions or prompts occurs. A tool can behave normally for weeks and only poison the context under a specific trigger phrase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can arrive after the fact.&lt;/strong&gt; This is the "rug pull" variant: a server passes your initial review, gets adopted, and is modified later. Attackers first deploy a seemingly trustworthy, fully functional MCP service with comprehensive documentation to attract integrations and build user dependency. Once users have established stable usage patterns, the attacker silently injects backdoors, steals credentials, or redirects communications through remote updates. The lack of mandatory mechanisms for version signing, change auditing, or update source verification in current MCP implementations makes such stealthy modifications extremely difficult for end users to detect.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you only vetted a third-party MCP server once, at integration time, you have no idea what it's serving your agent today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural tension underneath all of this
&lt;/h2&gt;

&lt;p&gt;It helps to understand &lt;em&gt;why&lt;/em&gt; MCP is built this way. The intense research focus on tool poisoning attacks reflects a deeper architectural tension in MCP: the protocol treats natural language tool metadata as authoritative input for LLM decision-making, yet provides no mechanisms to verify the provenance, integrity, or semantic safety of this metadata. Tool descriptions, JSON schemas, enum values, and parameter examples aren't just documentation — they're part of the model's planning input, and nothing in the protocol distinguishes "documentation for humans" from "instructions the model will act on."&lt;/p&gt;

&lt;p&gt;That distinction matters for how you write your own tool descriptions too, not just how you consume someone else's. In MCP tool poisoning, trusted-looking metadata changes planning, tool choice, or argument construction, and tool descriptions, JSON schemas, enum values, parameter examples, and remote MCP resources can all steer behavior early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defenses that actually hold up
&lt;/h2&gt;

&lt;p&gt;None of these are exotic. They're the kind of thing you can add to a server or gateway in an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Treat metadata as untrusted input, not config.&lt;/strong&gt;&lt;br&gt;
The fastest way to catch this class of issue is to stop treating tool metadata as harmless configuration — treat it like untrusted prompt input that sits on a privileged path. That means running the same kind of injection screening on tool descriptions and tool responses that you'd run on raw user input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Scope tools tightly.&lt;/strong&gt;&lt;br&gt;
Each MCP tool should expose only what it needs to function, nothing more — broad or loosely defined tools dramatically increase the blast radius of a poisoned description, while tight boundaries contain the potential damage if malicious metadata fires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Lock down JSON schemas.&lt;/strong&gt;&lt;br&gt;
Use strict JSON Schema for tool parameters: set additionalProperties: false and use pattern (or similar) on string fields so only declared parameters and valid formats are accepted. This closes off a common channel for smuggling extra instructions through loosely typed fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Kill silent auto-approval.&lt;/strong&gt;&lt;br&gt;
This is the single highest-leverage control in the research so far. Research shows 84.2% attack success rates when AI agents auto-approve tool calls versus less than 5% with human-in-loop approval, so best practices recommend disabling auto-approval for any tools accessing credentials, filesystems, or external networks. If your agent framework lets you require confirmation for a subset of high-risk tools, use it — it's the cheapest fix on this list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Allowlist servers, don't blocklist behavior.&lt;/strong&gt;&lt;br&gt;
Maintain an allowlist of approved MCP servers, don't let users connect to arbitrary servers, vet and approve servers before they can be used, and require explicit user confirmation for sensitive operations. A default-deny approach using an allowlist of specific tools works better than blocklisting patterns, because you can't enumerate every malicious phrasing in advance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Pin and hash-check what you trust.&lt;/strong&gt;&lt;br&gt;
For servers you don't control, guard against the rug-pull scenario directly. Use a signed catalog or approved registry for MCP tools and servers, so agents only load tools from an allowlist, and the catalog records the expected metadata hash for each tool definition and remote resource. If a hash changes, that's your signal to re-review before the agent uses the tool again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Scan before you connect.&lt;/strong&gt;&lt;br&gt;
Don't rely purely on manual review. Use tools like mcp-scan to automatically detect poisoned descriptions and cross-server shadowing. Snyk's MCP-Scan tool provides free static analysis for immediate tool poisoning detection during evaluation — run it as a CI step whenever you add or update a server dependency, not just once at onboarding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Remember it's a credential problem too.&lt;/strong&gt;&lt;br&gt;
Tool poisoning itself isn't an authentication flaw, but it often creates authentication vulnerabilities by causing agents to expose or misuse credentials, so strong auth limits downstream damage if an agent is manipulated by poisoned metadata. Your session and OAuth hygiene (short-lived tokens, scoped permissions, no long-lived credentials in tool responses) is a real mitigation, not a separate concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal pre-deployment checklist
&lt;/h2&gt;

&lt;p&gt;Before you connect any third-party MCP server to a production agent, run through this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server is on an explicit allowlist&lt;/td&gt;
&lt;td&gt;Prevents arbitrary connections from untrusted sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata hash pinned and diffed on update&lt;/td&gt;
&lt;td&gt;Catches rug-pull style changes after initial vetting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool schemas use &lt;code&gt;additionalProperties: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Blocks smuggled parameters and malformed injection payloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-risk tools require human confirmation&lt;/td&gt;
&lt;td&gt;Cuts attack success rate from ~84% to under 5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static scan (e.g., mcp-scan) run in CI&lt;/td&gt;
&lt;td&gt;Catches known poisoning patterns automatically, continuously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool scopes minimized to single responsibility&lt;/td&gt;
&lt;td&gt;Limits blast radius if a description is compromised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No credentials ever appear in tool descriptions/responses&lt;/td&gt;
&lt;td&gt;Prevents silent credential exfiltration through poisoned output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where this fits with everything else you've already hardened
&lt;/h2&gt;

&lt;p&gt;If you've already worked through session handling, observability, or the production readiness checklist for your MCP servers, tool poisoning is the piece that sits on top of all of it — it's specifically about &lt;em&gt;what the model is allowed to believe&lt;/em&gt; from tool metadata and responses, independent of whether your auth and transport layer are solid. A server can have perfect OAuth and still poison an agent through its tool descriptions.&lt;/p&gt;

&lt;p&gt;If you're building out the incident-response side of this — what to do when a poisoned tool actually fires, and how to scope permissions so the blast radius is contained next time — the &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/a&gt; walks through structured postmortems and permission-scoping worksheets built for exactly this kind of agent-security incident.&lt;/p&gt;

&lt;p&gt;Tool poisoning isn't going away — it's a structural feature of how MCP passes natural language into the model's decision loop. The teams shipping safely aren't the ones waiting for the protocol to fix it; they're the ones treating every tool description and every tool response as untrusted input, today.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>security</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>MCP Server Observability: A Practical Logging and Tracing Setup You Can Ship This Week</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Fri, 25 Sep 2026 11:40:45 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/mcp-server-observability-a-practical-logging-and-tracing-setup-you-can-ship-this-week-48dc</link>
      <guid>https://dev.to/quietdesk_studio_83466628/mcp-server-observability-a-practical-logging-and-tracing-setup-you-can-ship-this-week-48dc</guid>
      <description>&lt;h2&gt;
  
  
  The problem: your MCP server is a black box the moment it leaves your laptop
&lt;/h2&gt;

&lt;p&gt;Here's a scenario that plays out for almost every team building agents: an autonomous workflow runs overnight, a few tool calls succeed, one fails silently, and the agent reports something vague like "I wasn't able to retrieve that." No stack trace, no tool name, no timing. You're left guessing which of your five MCP servers dropped the ball.&lt;/p&gt;

&lt;p&gt;This isn't a hypothetical edge case — it's the default experience once you move past &lt;code&gt;mcp dev&lt;/code&gt; and local testing. The server side of MCP is where visibility disappears first: a slow tool call, a &lt;code&gt;tools/list&lt;/code&gt; response that quietly grew to dozens of entries, a downstream API returning a 500 wrapped in a polite &lt;code&gt;isError&lt;/code&gt; result — none of it shows up unless you deliberately put it there.&lt;/p&gt;

&lt;p&gt;There's also a spec-level wrinkle worth knowing about if you've been relying on MCP's built-in logging capability: the protocol has been moving away from protocol-level logging as a first-class feature, which means you shouldn't build your production debugging story around it. The safer bet is to own your logging and tracing at the application layer, independent of what the spec guarantees.&lt;/p&gt;

&lt;p&gt;This post walks through a setup you can add to an existing MCP server in an afternoon — no observability vendor, no OpenTelemetry collector cluster, just structured logs and a correlation ID pattern that will save you hours the next time something breaks at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why standard API debugging tools don't apply cleanly
&lt;/h2&gt;

&lt;p&gt;If you've operated a REST API before, you already have instincts for this: instrument the HTTP layer, log request/response pairs, ship to your logging stack, query when something breaks. MCP breaks those instincts in a few specific ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protocol wrapping.&lt;/strong&gt; Tool calls happen over JSON-RPC or HTTP, but a single tool invocation can chain multiple operations inside the server. One "call" in the trace might represent five internal steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session state.&lt;/strong&gt; MCP servers often maintain state across a session, so observability needs to capture state &lt;em&gt;transitions&lt;/em&gt;, not just discrete request/response pairs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compound side effects.&lt;/strong&gt; A tool that loops — say, calling &lt;code&gt;create_issue&lt;/code&gt; repeatedly due to an agent retry bug — doesn't fail loudly. It just creates duplicate issues. "Did the call succeed" is the wrong question; "how many downstream effects occurred, and are they recoverable" is the right one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential opacity.&lt;/strong&gt; If your server supports multiple auth modes (service account, bring-your-own-token, delegated), your logs need to capture &lt;em&gt;which&lt;/em&gt; identity actually executed a given call, not just that a call happened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic engineering. It just means the logging you'd bolt onto a typical REST service isn't quite enough here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Give every request a correlation ID, end to end
&lt;/h2&gt;

&lt;p&gt;The single highest-leverage change you can make is attaching a request ID the moment a tool call enters your server, and threading it through every log line, downstream API call, and error message tied to that request.&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;import&lt;/span&gt; &lt;span class="n"&gt;contextvars&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;

&lt;span class="n"&gt;request_id_var&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contextvars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_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;default&lt;/span&gt;&lt;span class="o"&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&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;request_id_var&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;request_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# ... your existing tool dispatch logic
&lt;/span&gt;        &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;request_id_var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few implementation traps to watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't rely on a server-level "on request" decorator that may not exist in your framework version — inject the ID directly at the top of your tool dispatch function or via a transport-layer wrapper instead.&lt;/li&gt;
&lt;li&gt;If you're running concurrent coroutines and logging the same ambient request ID from both, you'll get log lines that look correlated but aren't — scope the context var narrowly to a single tool invocation, not a whole session.&lt;/li&gt;
&lt;li&gt;When you serialize the ID into JSON logs, guard against &lt;code&gt;None&lt;/code&gt; explicitly (&lt;code&gt;request_id_var.get() or ""&lt;/code&gt;), otherwise a default value can quietly break downstream log parsers that expect a string.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Log tool calls as structured events, not print statements
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;print()&lt;/code&gt; statements don't survive process or subprocess boundaries cleanly, and they make your logs unsearchable. Instead, log every tool call as a single structured JSON event with a consistent shape:&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;"timestamp"&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-25T14:02:11Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_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;"a1b2c3d4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create_issue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent_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;"agent-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"args_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256:..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UPSTREAM_500"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"server-managed"&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;Two design choices matter more than they seem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hash or redact arguments instead of logging them raw.&lt;/strong&gt; MCP tools frequently pass user data, tokens, or business-sensitive payloads. Avoid collecting authentication data, credentials, and personally identifiable information in your logs — if you need to correlate calls back to a user later, use the correlation ID plus a lookup table you control, not raw PII in the log line itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the auth mode that actually fired.&lt;/strong&gt; If your server supports more than one credential path, the calling agent often has no idea which one executed. Capturing that explicitly is the difference between a five-minute incident review and a half-day one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 3: Set latency baselines per tool, not a universal number
&lt;/h2&gt;

&lt;p&gt;Don't reach for an industry-standard latency table — there isn't a meaningful one. A tool that calls an external API has a fundamentally different latency floor than one that reads from local memory, and &lt;code&gt;initialize&lt;/code&gt;/&lt;code&gt;tools/list&lt;/code&gt; calls should be near-instant regardless. The practical approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Capture p50/p95/p99 latency per tool method over a few days of normal traffic to establish your own baseline.&lt;/li&gt;
&lt;li&gt;Alert on regressions against &lt;em&gt;that&lt;/em&gt; baseline, not an arbitrary fixed threshold.&lt;/li&gt;
&lt;li&gt;Treat a sudden spike in &lt;code&gt;tools/list&lt;/code&gt; size or a previously-fast tool crossing its p95 as an early warning sign, not just outright errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Distinguish "it failed" from "it lied"
&lt;/h2&gt;

&lt;p&gt;The most expensive failure mode in agent systems isn't the loud error — it's the tool call that returns a technically well-formed response that's wrong. The server logs a success. The agent moves on confidently. Nobody notices for a week. Structured logging alone won't catch this; you need a lightweight assertion layer that checks tool outputs against expected shapes or value ranges before treating a call as "successful," and logs a distinct &lt;code&gt;status: "suspicious"&lt;/code&gt; event when something looks off. This is a smaller cousin of the eval-style testing you'd run in CI, applied at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits with the rest of your production checklist
&lt;/h2&gt;

&lt;p&gt;Logging and tracing are one piece of a larger production-readiness picture — alongside session handling, auth scoping, error recovery, and test coverage for tool behavior. Piecing these patterns together yourself, as outlined above, is entirely doable in an afternoon and gives you a solid foundation to build on as your MCP server matures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't depend on protocol-level logging guarantees — own structured logging at the application layer.&lt;/li&gt;
&lt;li&gt;Thread a correlation ID through every tool call, scoped narrowly to avoid concurrency bugs.&lt;/li&gt;
&lt;li&gt;Log tool calls as structured JSON events with hashed/redacted arguments, never raw credentials or PII.&lt;/li&gt;
&lt;li&gt;Build latency baselines per tool from your own traffic, not generic thresholds.&lt;/li&gt;
&lt;li&gt;Add a lightweight runtime check for "successful but wrong" responses — this is often the costliest failure mode and the hardest to see in raw logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small, deliberate investment here pays off the first time you have to explain to a teammate exactly which tool call failed, why, and what it touched before it did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>The MCP Production Readiness Checklist: What We Learned Shipping Real Servers</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Fri, 25 Sep 2026 01:43:02 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/the-mcp-production-readiness-checklist-what-we-learned-shipping-real-servers-1a80</link>
      <guid>https://dev.to/quietdesk_studio_83466628/the-mcp-production-readiness-checklist-what-we-learned-shipping-real-servers-1a80</guid>
      <description>&lt;p&gt;If you've built more than one MCP server, you've probably noticed a pattern: the first version takes an afternoon, and the second version — the one that actually has to survive real traffic, real auth flows, and real agent behavior — takes weeks. That gap is where most of the "MCP is overhyped" complaints come from. It's not that MCP is bad. It's that most public examples stop exactly where production work begins.&lt;/p&gt;

&lt;p&gt;This post pulls together the lessons from two things we've built recently: a full &lt;a href="https://dev.to"&gt;minimal production-ready MCP server walkthrough&lt;/a&gt; covering auth, sessions, and error recovery, and a &lt;a href="https://dev.to"&gt;testing pyramid for MCP servers&lt;/a&gt; covering how to actually verify tool behavior instead of just eyeballing it. Rather than repeat either in detail, this is the condensed checklist — the thing you'd actually tape to the wall before a release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a checklist instead of another tutorial
&lt;/h2&gt;

&lt;p&gt;Tutorials show you one path through one server. A checklist works differently: it's a list of things that &lt;em&gt;will&lt;/em&gt; bite you regardless of which framework, transport, or model you're using, because they're structural, not implementation details. If you can check every box below, you're in materially better shape than 90% of the MCP servers currently sitting in public repos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 1: Authentication and identity
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every tool call carries a verified identity, not just a token.&lt;/strong&gt; Bearer tokens get forwarded; make sure your server validates scope and expiry on every call, not just on connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session tokens are short-lived and rotate.&lt;/strong&gt; Long-lived sessions are the single most common way an MCP server turns into a standing liability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have a clear answer for "what happens when auth expires mid-task."&lt;/strong&gt; Does the agent get a clean, actionable error, or does it silently retry into a wall?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials never appear in tool descriptions, logs, or error messages returned to the model.&lt;/strong&gt; This one gets missed constantly because error messages are usually the last thing anyone reviews before shipping.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Section 2: Session and state management
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sessions are scoped per-connection, not global.&lt;/strong&gt; A shared in-memory dict keyed by nothing is fine for a demo and a data leak in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You've decided what happens on reconnect.&lt;/strong&gt; Does the agent resume where it left off, or start clean? Both are valid — but only one should be true, deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle sessions get cleaned up.&lt;/strong&gt; Memory leaks in long-running MCP servers are almost always orphaned session state, not the tool logic itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent calls from the same session are handled safely.&lt;/strong&gt; Agents parallelize tool calls more than people expect; race conditions in session state are a quiet, nasty bug class.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Section 3: Error recovery
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Errors returned to the model are structured, not raw stack traces.&lt;/strong&gt; The model has to &lt;em&gt;use&lt;/em&gt; this information to decide what to do next — a Python traceback is not decision-useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retryable and non-retryable errors are distinguishable.&lt;/strong&gt; Without this, agents either give up too early or retry forever on something that will never succeed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial failures in multi-step tools are handled explicitly.&lt;/strong&gt; If a tool does three things and the second one fails, what state is the world left in, and does the model know?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have timeouts on every external call the tool makes.&lt;/strong&gt; An MCP server is only as reliable as its slowest downstream dependency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Section 4: Testing
&lt;/h2&gt;

&lt;p&gt;This is the section most public MCP examples skip entirely, which is a shame because it's also the highest-leverage one.&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 checks&lt;/th&gt;
&lt;th&gt;Typical tooling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unit&lt;/td&gt;
&lt;td&gt;Tool logic in isolation, no protocol involved&lt;/td&gt;
&lt;td&gt;Standard test framework for your language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contract&lt;/td&gt;
&lt;td&gt;Tool schemas match what the server actually returns&lt;/td&gt;
&lt;td&gt;Schema validation against real responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration&lt;/td&gt;
&lt;td&gt;Server behaves correctly over the actual MCP transport&lt;/td&gt;
&lt;td&gt;A real client hitting a real running server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent-eval&lt;/td&gt;
&lt;td&gt;The &lt;em&gt;agent&lt;/em&gt;, given the tool, produces correct outcomes&lt;/td&gt;
&lt;td&gt;Scripted agent runs with known tasks and expected results&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last layer is the one people underrate. A tool can pass every unit and integration test and still be unusable in practice because the model consistently misuses it — wrong argument shapes, wrong assumptions about return values, wrong retry behavior. You find that out by actually running an agent against the tool with realistic tasks, not by testing the tool in a vacuum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 5: Observability
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every tool call is logged with enough context to reconstruct what happened&lt;/strong&gt;, without logging sensitive payloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can answer "how many times did this tool fail last week, and why"&lt;/strong&gt; without grepping raw logs by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency per tool is tracked&lt;/strong&gt;, because a slow tool doesn't just annoy users — it changes agent behavior, since models sometimes abandon or retry slow calls in ways that compound.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Section 6: Scope and permissions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools expose the minimum surface area needed&lt;/strong&gt;, not a general-purpose API wrapped in MCP clothing. The more a tool can do, the more damage a confused or manipulated agent can do through it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destructive actions are gated&lt;/strong&gt; — behind confirmation, behind a narrower scope, or behind a separate approval tool entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You've written down, explicitly, what the worst-case action this server can take is&lt;/strong&gt;, and whether that's acceptable given who can reach it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using this as an actual pre-release gate
&lt;/h2&gt;

&lt;p&gt;The realistic way to use this list isn't as an aspirational document — it's as a gate. Before a server goes anywhere near production traffic, someone (even just you, alone, with coffee) should go through each section and either check the box or write down why it's explicitly out of scope for this release. "We're not handling reconnect gracefully yet, and here's the ticket" is a fine thing to ship with. Silently not knowing is not.&lt;/p&gt;

&lt;p&gt;If Sections 3 and 6 above are the ones that keep you up at night — figuring out what actually happened after a tool misbehaves, and deciding what an agent should and shouldn't be allowed to touch — we put together a related resource: the &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/a&gt;. It's meant to give you a starting structure for both, rather than reinventing one from scratch after your first bad incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;None of this is exotic engineering. It's the same discipline you'd apply to any service that takes untrusted input and takes real actions — auth, scoping, timeouts, structured errors, and tests that check outcomes, not just responses. The reason it feels new is that MCP made it very easy to skip straight to "it works in my terminal" and call that done. It's a good starting point. It's not a production system yet, and the gap between the two is exactly the six sections above.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>A Minimal Production-Ready MCP Server Walkthrough (Auth, Sessions, Error Recovery)</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Fri, 25 Sep 2026 00:41:54 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/a-minimal-production-ready-mcp-server-walkthrough-auth-sessions-error-recovery-40aj</link>
      <guid>https://dev.to/quietdesk_studio_83466628/a-minimal-production-ready-mcp-server-walkthrough-auth-sessions-error-recovery-40aj</guid>
      <description>&lt;p&gt;Most MCP server examples you'll find online are 40 lines long, have no auth, and crash the moment a tool call throws an exception. That's fine for a demo. It's not fine for something a real agent will call thousands of times a day on behalf of real users.&lt;/p&gt;

&lt;p&gt;This walkthrough builds up a minimal but genuinely production-ready MCP server, piece by piece, focusing on the three things toy examples always skip:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt; — so random clients can't call your tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session handling&lt;/strong&gt; — so state doesn't leak between users or die on restart&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error recovery&lt;/strong&gt; — so one bad tool call doesn't take down the whole server&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're still deciding whether you need MCP at all versus a simple script, that's a separate question worth answering first — this post assumes you've already decided MCP is the right shape for your problem and now you need to ship it safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bare-minimum server (what most tutorials stop at)
&lt;/h2&gt;

&lt;p&gt;A typical "hello world" MCP server looks like this:&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&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Server&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.stdio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;stdio_server&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo-server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.call_tool&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;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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;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;get_weather&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&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;Sunny in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&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;with&lt;/span&gt; &lt;span class="nf"&gt;stdio_server&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nf"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_initialization_options&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works great locally over stdio with one trusted client. It has no concept of &lt;em&gt;who&lt;/em&gt; is calling, no memory between calls, and if &lt;code&gt;arguments['city']&lt;/code&gt; is missing it just crashes the process. Let's fix all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Authentication: don't trust the transport
&lt;/h2&gt;

&lt;p&gt;If you're exposing your MCP server over HTTP/SSE (which you need to do the moment more than one client or a hosted agent talks to it), stdio's implicit trust disappears. Every request needs to prove who it is.&lt;/p&gt;

&lt;p&gt;The simplest production-safe pattern is a bearer token checked on every request, validated against a store you control (not hardcoded):&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;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;

&lt;span class="n"&gt;VALID_TOKEN_HASHES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# store hashed tokens, never raw tokens
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a3f8...&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;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent-prod-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read:tools&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="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;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="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&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="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VALID_TOKEN_HASHES&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;token_hash&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;client&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;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid token&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="n"&gt;client&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/mcp/call&lt;/span&gt;&lt;span class="sh"&gt;"&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;call_tool_endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;authorization&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;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;authorization&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="sh"&gt;"&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;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing bearer token&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&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeprefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&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;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# client["scopes"] now gates which tools this caller can invoke
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;dispatch_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter here that toy examples never mention:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hash tokens at rest.&lt;/strong&gt; If your token store leaks, raw tokens are game over. Hashed tokens are not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope tokens per client, not per server.&lt;/strong&gt; A CI agent and a customer-facing agent should not share a token with identical permissions. This is exactly the kind of permission-scoping gap that causes real incidents when an agent gets access it was never meant to have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need real user-level auth (not just service-to-service), OAuth 2.1 with PKCE is becoming the de facto standard for hosted MCP servers — but bearer tokens are a completely legitimate starting point for most internal or B2B agent deployments, and you can add OAuth later without rearchitecting the tool layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Session handling: state that survives more than one request
&lt;/h2&gt;

&lt;p&gt;Toy servers keep everything in a global dict or, worse, in-process variables that reset the moment the server restarts. In production you need sessions that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;are isolated per client/user&lt;/li&gt;
&lt;li&gt;survive a server restart or redeploy&lt;/li&gt;
&lt;li&gt;expire automatically so old sessions don't pile up
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6379&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode_responses&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="n"&gt;SESSION_TTL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;  &lt;span class="c1"&gt;# 30 minutes
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&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="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&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="nf"&gt;get&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;session:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&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;if&lt;/span&gt; &lt;span class="n"&gt;raw&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;history&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;save_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&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;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setex&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;session:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;SESSION_TTL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;),&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;dispatch_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&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;session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;history&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;args&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;save_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_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;session_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis here is a stand-in — a small SQLite table works fine at low volume. The point isn't the storage engine, it's the pattern: &lt;strong&gt;session state lives outside the process&lt;/strong&gt;, has a TTL, and is keyed so one client can never read another's session. That last part is easy to get wrong when you're moving fast, and it's the kind of thing worth putting on a checklist rather than trusting memory to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Error recovery: one bad tool call shouldn't kill the server
&lt;/h2&gt;

&lt;p&gt;This is the gap that hurts the most in practice. An agent will eventually call a tool with malformed arguments, a downstream API will time out, or a tool will throw an unhandled exception. A toy server crashes. A production server catches it, logs it usefully, and returns something the calling agent can actually reason about.&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;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;traceback&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp-server&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;ToolExecutionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&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;__init__&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;message&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;retryable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&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;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&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;retryable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retryable&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tool_fn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TOOL_REGISTRY&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;name&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;tool_fn&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;ToolExecutionError&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;unknown tool: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retryable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tool_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ToolExecutionError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&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;tool_error tool=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; retryable=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retryable&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; msg=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="si"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retryable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retryable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# unexpected error — log full trace, return a safe generic message
&lt;/span&gt;        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&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;unexpected_error tool=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;traceback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format_exc&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internal error, try again&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retryable&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design choices here matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;retryable&lt;/code&gt; is part of the response contract.&lt;/strong&gt; An agent orchestrator can decide to auto-retry a timeout but not auto-retry a bad-arguments error. Without this flag, agents tend to either retry everything (wasteful, sometimes dangerous) or nothing (brittle).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never leak stack traces to the caller.&lt;/strong&gt; Log them server-side, return a generic message to the client. Stack traces in tool responses are a surprisingly common accidental information leak.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The full request path now looks like: bearer token verified → session loaded from durable storage → tool executed inside a try/except that classifies errors → session saved → structured response returned. None of this is exotic engineering — it's the same auth/session/error-handling discipline you'd apply to any API. MCP just makes it easy to skip because the demos never show it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;Writing this layer from scratch for every new MCP server is exactly the kind of repeated setup work that's easy to get subtly wrong under deadline pressure — a missing TTL here, an unscoped token there. If the permission-scoping gaps discussed above (the CI agent vs. customer-facing agent scenario) sound familiar, the &lt;strong&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/strong&gt; is built specifically around auditing and documenting those failure modes before they turn into incidents: &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;check it out here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building agent tooling more broadly and want a second pair of eyes on where things tend to break, the failure-mode patterns in autonomous coding agents are worth a read too — a lot of the same "it worked in the demo" gaps show up there.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>python</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Test MCP Servers: A Practical Testing Pyramid for Agent Tools</title>
      <dc:creator>QuietDesk Studio</dc:creator>
      <pubDate>Fri, 25 Sep 2026 00:41:53 +0000</pubDate>
      <link>https://dev.to/quietdesk_studio_83466628/how-to-test-mcp-servers-a-practical-testing-pyramid-for-agent-tools-5ef3</link>
      <guid>https://dev.to/quietdesk_studio_83466628/how-to-test-mcp-servers-a-practical-testing-pyramid-for-agent-tools-5ef3</guid>
      <description>&lt;p&gt;Most MCP tutorials stop at "here's a tool that returns weather data." Almost none of them answer the question that actually matters once you ship: &lt;strong&gt;how do you know your MCP server still works after the next change?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've built a production MCP server, you already know the failure modes are weird. A tool schema drifts and the model starts calling it wrong. A session token expires mid-conversation and the agent silently gets empty results instead of an error. A refactor changes a return type from a string to an object and every downstream agent breaks in a way no &lt;code&gt;pytest&lt;/code&gt; unit test catches.&lt;/p&gt;

&lt;p&gt;This post gives you a concrete testing pyramid for MCP servers, with what to test at each layer and why the layer above it isn't enough on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP testing is different from normal API testing
&lt;/h2&gt;

&lt;p&gt;A regular REST API has one consumer type: code that calls it with fixed, known inputs. An MCP server has two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic clients&lt;/strong&gt; — your own test harness, CI, health checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A non-deterministic client&lt;/strong&gt; — the LLM itself, which decides &lt;em&gt;when&lt;/em&gt; to call a tool, &lt;em&gt;what arguments&lt;/em&gt; to pass, and &lt;em&gt;how to interpret&lt;/em&gt; the response.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second consumer means schema correctness isn't optional polish — it's the interface contract the model reasons over. A tool description that's technically valid JSON Schema but ambiguous in plain English will get called wrong in production even if every unit test passes.&lt;/p&gt;

&lt;p&gt;So your test suite needs to answer four separate questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the tool do what it claims when called with valid arguments?&lt;/li&gt;
&lt;li&gt;Does it fail safely and informatively when called with bad arguments?&lt;/li&gt;
&lt;li&gt;Does the session/auth layer behave correctly across the lifecycle (expiry, revocation, refresh)?&lt;/li&gt;
&lt;li&gt;Would a model, given only the tool's description and schema, actually call it correctly?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most teams only test the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: Schema and contract tests
&lt;/h2&gt;

&lt;p&gt;Before anything touches an LLM, validate that your tool definitions are internally consistent and haven't silently drifted.&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;test_tool_schema_matches_handler_signature&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;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;registered_tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;schema_props&lt;/span&gt; &lt;span class="o"&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;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_schema&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;handler_params&lt;/span&gt; &lt;span class="o"&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;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;schema_props&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;handler_params&lt;/span&gt;&lt;span class="p"&gt;,&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: schema/handler mismatch — &lt;/span&gt;&lt;span class="sh"&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;schema has &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;schema_props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, handler expects &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;handler_params&lt;/span&gt;&lt;span class="si"&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;def&lt;/span&gt; &lt;span class="nf"&gt;test_all_required_fields_are_documented&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;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;registered_tools&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;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_schema&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;required&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="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_schema&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;field&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;description&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; has no description — the model can&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t reason about it&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;This catches the single most common regression: someone adds a required parameter to the handler and forgets to update the schema. It's boring, it's fast, and it should run on every commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Handler behavior tests (unit + integration)
&lt;/h2&gt;

&lt;p&gt;These are close to normal unit tests, but with an MCP-specific twist: test the &lt;strong&gt;MCP response envelope&lt;/strong&gt;, not just the underlying business logic.&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;test_tool_returns_structured_error_on_bad_input&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_invoice&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;invoice_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not-a-real-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_error&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;  &lt;span class="c1"&gt;# error is specific, not generic
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;             &lt;span class="c1"&gt;# never return an empty error body
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_tool_handles_upstream_timeout_gracefully&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;mock_upstream_timeout&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_invoice&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;invoice_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INV-100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_error&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timeout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pattern worth stealing: write one test per failure mode you've actually seen in production (timeout, malformed upstream response, rate limit, auth expiry), not just the happy path. If you keep an incident log, each entry should turn into a regression test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Session and auth lifecycle tests
&lt;/h2&gt;

&lt;p&gt;This is the layer almost everyone skips, and it's the one that causes the scariest production bugs, because failures here are silent — the agent doesn't crash, it just gets wrong or stale data.&lt;/p&gt;

&lt;p&gt;Minimum test set for any MCP server with auth:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Expected behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valid session, first call&lt;/td&gt;
&lt;td&gt;Succeeds, returns fresh data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session expired mid-conversation&lt;/td&gt;
&lt;td&gt;Tool returns explicit "session expired" error, not empty/stale data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token refresh in-flight during concurrent calls&lt;/td&gt;
&lt;td&gt;No duplicate refresh requests, no dropped calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revoked session&lt;/td&gt;
&lt;td&gt;Immediate rejection, not a delayed 200 with garbage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session reused across two different agent contexts&lt;/td&gt;
&lt;td&gt;Explicit isolation — no data leakage between conversations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;test_expired_session_returns_explicit_error_not_stale_data&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl_seconds&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list_documents&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;session&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_error&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expired&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# the dangerous version of this bug: result.content has stale cached data instead
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only test one thing from this post, test the expired-session case. It's the difference between an agent that says "I need you to re-authenticate" and one that confidently reports numbers from three hours ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: LLM-in-the-loop evaluation tests
&lt;/h2&gt;

&lt;p&gt;This is the layer that's genuinely new compared to normal software testing, and it's where "agent-eval" testing lives. The question isn't "does the code work," it's "given only the tool description, does the model call it the way you intended?"&lt;/p&gt;

&lt;p&gt;A minimal eval harness:&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="n"&gt;EVAL_CASES&lt;/span&gt; &lt;span class="o"&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;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s the status of invoice INV-4471?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected_tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected_args_contains&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;invoice_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INV-4471&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="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cancel my subscription&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected_tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cancel_subscription&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected_args_contains&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;must_not_call&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;delete_account&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;# guard against dangerous over-reach
&lt;/span&gt;    &lt;span class="p"&gt;},&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;run_eval_case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model_client&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;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;registered_tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tool_calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected_tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tool_calls&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;forbidden&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;case&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;must_not_call&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="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;forbidden&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tool_calls&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;Model incorrectly called &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;forbidden&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this against your actual model provider on a schedule (weekly, or on every schema change), not just once at launch. Model providers update their models, and a tool description that worked perfectly with one model version can start getting misinterpreted after a silent upgrade. This is the test layer that catches "the model started calling &lt;code&gt;delete_account&lt;/code&gt; when the user said 'cancel my subscription'" before your users do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: a CI pipeline that actually catches regressions
&lt;/h2&gt;

&lt;p&gt;A sane ordering, fastest and cheapest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schema/contract tests&lt;/strong&gt; — milliseconds, run on every save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handler unit tests&lt;/strong&gt; — seconds, run on every commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session/auth lifecycle tests&lt;/strong&gt; — seconds to low minutes, run on every PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM-in-the-loop evals&lt;/strong&gt; — costs real API tokens, run on every PR to &lt;code&gt;main&lt;/code&gt; and on a nightly schedule, not on every keystroke.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Gate merges on layers 1–3. Treat layer 4 as a monitored signal — alert if the pass rate drops, but don't necessarily block every PR on it, since model non-determinism means occasional flakes are expected. Track the pass rate as a trend, not a single number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;Writing all four layers from scratch for your first MCP server takes longer than it should — mostly because there's no standard scaffold for the schema/contract tests or the eval harness, so everyone reinvents it slightly differently.&lt;/p&gt;

&lt;p&gt;If the session/auth layer and incident-driven test cases in this post resonated with you, I put together the &lt;a href="https://quietdesk15.gumroad.com/l/wytfec" rel="noopener noreferrer"&gt;AI Agent Incident Postmortem &amp;amp; Permission-Scoping Template Pack&lt;/a&gt; — templates for turning production incidents (like the expired-session and over-reach cases above) into structured postmortems and tighter permission scopes for your tools. It won't write your test suite for you, but it gives you a structured way to capture the failures that should become regression tests in the first place.&lt;/p&gt;

&lt;p&gt;Either way — even just adding the expired-session test and one LLM-in-the-loop eval case this week will catch bugs your current test suite is currently blind to.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>testing</category>
      <category>ai</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
