The demo takes four minutes.
A product manager types a request into an internal support console built on top of an AI agent: "Find the Acme renewal record and update the account owner to Priya." The agent calls a search tool, gets back a single match, calls an update tool, and reports success. The account record on screen refreshes. The owner field now reads "Priya Nandan." Everyone in the room nods. The feature has been in development for six weeks, connected to three internal systems through MCP servers, and this is the moment it was supposed to prove itself.
Someone on the QA side asks a question that isn't part of the demo script: "What happens if there are two accounts named Acme?"
Nobody knows. The engineer who built the CRM integration checks the tool call log from the demo and confirms that the search tool returned exactly one result, because the demo database happens to contain exactly one company with that name. In the staging environment, seeded with more realistic data, there are three: Acme Corp, Acme Holdings, and Acme Corp (Legacy), an inactive tenant left over from a 2023 migration. The same request — same words, same user, same agent, same tools — now has to choose among them. Nothing about the MCP connection changed. Nothing about the tool schema changed. The demo passed. The question the demo never asked is still open.
That gap is the subject of this article.
What a Passing Demo Actually Proves
Model Context Protocol gives an AI application a standard way to discover tools, read structured context, and invoke capabilities exposed by a server, whether that server sits in front of a CRM, a billing platform, a documentation index, or a browser. Once that connection exists, the AI application isn't just generating text anymore. It is issuing calls that search live business data, mutate records, and in some deployments, drive a real browser against a real production application. The protocol is no longer an experimental integration living behind a "beta" flag. For a growing number of organizations, it is part of the execution path that customer-facing and internally-facing systems run through every day.
An execution path is something a QA and engineering organization already knows how to think about: it has inputs, contracts, failure modes, versioning, latency, authorization boundaries, and observability requirements. What's new is not the category of problem. What's new is that a probabilistic decision-maker — the model — now sits inside that path, choosing which capability to invoke, with which arguments, in which order, based on a natural-language request. Testing that path means testing more than whether a JSON-RPC call returns the expected shape. It means testing whether the right operation happens, for the right target, under the right authorization, with a result the business can trust — even when a dependency times out, a schema changes underneath the integration, or the model makes a defensible but wrong choice about which tool to call.
This article works through that territory the way a production readiness review would: by asking, layer by layer, what a team actually needs to know before it lets an MCP-connected agent touch systems that matter.
Four kinds of correctness that fail independently
It helps to separate four questions that are easy to collapse into one:
| Layer | Question it answers | Example failure |
|---|---|---|
| Protocol correctness | Does the exchange conform to MCP and JSON-RPC requirements? | A tool call is missing a required _meta field and the server rejects it with a malformed-request error. |
| Tool correctness | Did the invoked tool execute according to its own contract? | crm.update_account returns "success": true but the underlying write silently failed on a foreign-key constraint. |
| Agent correctness | Did the model choose the appropriate tool, arguments, and sequence? | The model calls crm.update_account before calling crm.search_account, using a cached account ID from an earlier, unrelated conversation turn. |
| Business correctness | Was the right real-world object changed, and was that change permitted? | The agent updated the correct schema fields on the wrong tenant's Acme record. |
A system can be fully correct at three of these layers and still be wrong. A tool can execute flawlessly against a target the agent should never have touched. A protocol exchange can be flawless while the tool it invoked lies about what it did. Most of the testing gaps this article walks through live in the seams between these four layers, not inside any one of them.
Enough Architecture to Follow the Rest
MCP defines three roles. A host is the AI application the person is actually using — a support console, an IDE, an agent framework. The host embeds one or more clients, each of which holds a one-to-one connection to a single server. The server exposes context and capabilities: tools the model can invoke, resources it can read, and prompts it can request. A server is typically a thin layer in front of something else — a CRM API, a filesystem, a browser, a search index — and it's that "something else" that ultimately does the work.
User
↓
AI Application / MCP Host
↓
MCP Client
↓
MCP Server
↓
Business System / Browser / API / Database
Production systems rarely stop at one server. A realistic deployment looks more like this:
AI Host
├─ MCP Client → CRM MCP Server → CRM API
├─ MCP Client → Billing MCP Server → Billing API
├─ MCP Client → Browser MCP Server → Browser
└─ MCP Client → Knowledge MCP Server → Search / Documents
Each arrow is a boundary with its own reliability characteristics, its own authorization surface, and its own failure modes. An application that connects to four MCP servers inherits four sets of operational risk, not one. This is the architecture referenced throughout the rest of this article, attached to a composite, explicitly fictional B2B SaaS system: a customer-operations agent used by account managers and support staff at a company that sells subscription software. It exposes tools such as crm.search_account, crm.update_account, billing.list_invoices, billing.issue_credit, support.create_ticket, knowledge.search, and a small set of browser-automation tools (browser.navigate, browser.click, browser.fill_form). None of this maps to a real customer or a real QAtronic engagement; it exists as a stable reference point for the testing discussion.
Two things about the protocol have changed enough recently to matter for anyone designing a test strategy, and they're worth stating precisely rather than glossing over.
MCP moved to a stateless core. The current specification, dated 2026-07-28, retires the initialize/initialized handshake and the Mcp-Session-Id header that earlier revisions used to establish a session. Every request now carries its own protocol version and client identity, and any request can land on any server instance behind an ordinary round-robin load balancer, because there's no session state pinned to a particular instance. A server that needs to track state across calls — a shopping cart, a multi-step workflow, a draft object — now does it explicitly, by minting a handle and having the model pass that handle back as a tool argument on subsequent calls, rather than relying on a hidden transport-level session. That single architectural decision changes what "session testing" means for an MCP integration, and it introduces a specific new attack class — state handle hijacking — discussed later in this article.
Server-initiated requests changed shape. Previously, patterns like elicitation (the server asking the user something mid-call) or sampling (the server asking the client's model to generate something) depended on a held-open bidirectional stream. The current spec replaces this with Multi Round-Trip Requests (MRTR): the server returns a resultType: "input_required" result along with the specific things it needs answered, and the client retries the original call with those answers attached. This matters directly for testing confirmation flows — "does the agent ask the user before issuing a credit?" is now a testable request/response pattern rather than a stream-lifecycle problem.
Long-running work moved out of the core and into an extension. Tasks — the mechanism for a tool call that can't complete synchronously — shipped experimentally in the previous revision and has since been redesigned and relocated to the io.modelcontextprotocol/tasks extension, with a polling-based tasks/get and tasks/update replacing the earlier blocking call. This is directly relevant to anything in the fictional system that can't complete in one request/response cycle — a bulk billing reconciliation, a large export, a long browser workflow — and it introduces its own lifecycle testing surface, covered later.
None of this is 101-level trivia included for padding. Each of these changes redraws the boundary of what "the transport" is responsible for versus what the application built on top of MCP is responsible for, and that boundary is exactly where testing coverage tends to have gaps.
The Tool Exists. That Does Not Mean It Is Usable.
Tool discovery — the moment a host asks a server what it can do, and the server answers with a list of tools, their schemas, and their descriptions — feels like a solved problem because it's mechanically simple. It's a single call, tools/list, returning a JSON array. Under the 2026-07-28 spec, that response now carries a ttlMs and cacheScope, meaning clients are explicitly encouraged to cache the tool catalog rather than re-fetch it on every turn. That's good for latency and good for keeping prompt caches stable across reconnects. It also means a stale tool catalog is now a first-class, spec-anticipated failure mode rather than an edge case, and it deserves test coverage as one.
Concretely, a production readiness review for the fictional CRM integration should be able to answer:
- What happens when
crm.update_accountis removed from the server's catalog — through a deprecation, a misconfiguration, or a partial rollout — but the host's cached copy oftools/liststill contains it? Does the subsequenttools/callfail cleanly, or does the client assume the tool still exists and hang waiting for a response that never comes? - What happens when two servers in the same host session expose confusingly similar tools — say, a
knowledge.searchfrom the documentation server and acrm.search_accountfrom the CRM server, both described loosely as "search"? Does the model reliably route "find the Acme account" to the CRM tool, or does it occasionally reach for the documentation search because the description overlaps? - What happens when the catalog is large? A host wiring together CRM, billing, support, knowledge, and browser servers may be exposing forty or fifty tools to a single model context. Does discovery latency degrade linearly, and does tool-selection accuracy degrade as the catalog grows, independent of any single tool's correctness?
- What happens when discovery partially fails — three of four servers respond, one times out? Does the host proceed with a partial catalog and silently drop capabilities the workflow needs, or does it surface the gap?
None of these are protocol bugs. The spec doesn't promise that a partially-failed discovery will be handled gracefully by the application built on top of it, and it shouldn't — that's an application-layer decision. But it is a decision, and if nobody tested it, the behavior in production is whatever the client library happened to do by default, which is not the same thing as a decision anyone made on purpose.
The broader point: static integration assumptions — "the agent has access to these seven tools" — are a snapshot of a runtime capability that can and does change. Treating tool discovery as fixed at build time, rather than as a live surface with its own compatibility and staleness risks, is one of the more common gaps a production readiness review turns up in systems that otherwise look solid.
A Tool Description Is Input to the Model Too
An HTTP API has one contract: the machine-readable one. A JSON schema either validates a request or it doesn't. An MCP tool has two contracts simultaneously — the same machine-readable schema, and a natural-language description whose entire purpose is to shape a probabilistic decision about when and how to call the tool. That second contract doesn't show up in a schema validator, and it is just as capable of being wrong.
Consider three tools that might plausibly coexist in the fictional CRM server:
crm.search_account — "Find a customer."
crm.search_account_by_org — "Search customer accounts by organization."
crm.get_current_account — "Retrieve the current customer account."
An engineer reading this list understands the differences immediately: one is a general lookup, one filters by organization, and one presumably depends on some notion of session or conversation context for what "current" means. A model choosing among these based on a user's phrasing doesn't have the engineer's implicit knowledge of the system. If "current" isn't grounded in anything the model can observe — no handle, no prior tool result establishing which account is "current" — the model may guess, and the guess may be wrong in ways that are individually rare but systematically present across a large enough volume of real conversations.
This is why tool schema validation and tool semantic validation are different disciplines, and a test plan that only exercises the first is only doing half the job. Schema validation asks "does this JSON conform to the declared types?" Semantic validation asks "given this description, does a model reliably choose the intended tool over its closest neighbors, with arguments that mean what the description implies?" The second question doesn't have a deterministic pass/fail the way the first does, but it can still be tested systematically: construct a set of representative user requests, run them against the live tool catalog with the production model repeatedly, and check whether tool selection and argument construction land within an acceptable envelope. Ambiguous, overlapping, outdated, or misleading tool descriptions are a defect class of their own, and they're invisible to any test that only checks whether a call, once made, executed correctly.
There's a second-order version of this problem worth naming directly, because current MCP security guidance addresses it explicitly: a tool's description is untrusted content when it comes from a server the host doesn't fully control. A description can contain instructions aimed at the model rather than at the human reading the tool list — hidden directives, requests to exfiltrate data, or attempts to reframe a destructive operation as safe. Reviewing tool descriptions the way a security team reviews any other untrusted input the model will read is not optional hardening for a browser-connected agent with access to billing tools; it's baseline due diligence, and it belongs in the same review cycle as schema changes, not a one-time approval at onboarding.
The Schema Passed Validation. The Meaning Still Changed.
Schema testing for MCP tools follows a shape that will be familiar to anyone who has tested APIs, with one addition: the caller constructing the request is a model, not a deterministic client, so malformed and semantically-drifted calls are a routine occurrence rather than an edge case reserved for adversarial testing.
The standard input-schema surface still needs coverage: required versus optional fields, nullability, enum exhaustiveness, nested object structures, array bounds (including empty and unexpectedly large arrays), unexpected additional properties, type mismatches, numeric boundaries, string length limits, Unicode handling, date and time formats, timezone assumptions, identifier formats, and pagination defaults. None of that is unique to MCP. What is worth designing for specifically is the malformed-call pattern that comes from a model's own generation process. An expected call for issuing a billing credit might look like:
{
"account_id": "A-1821",
"credit_amount": 100
}
A model, working from an ambiguous user request and an imperfect memory of the schema, might instead emit:
{
"customer": "Acme",
"amount": "$100"
}
Every field name is different. The amount is a currency-formatted string instead of a number. This is not a hypothetical — it's the ordinary shape of model output drift, and a server's behavior in this situation is a design decision that needs to be made deliberately and then tested, not discovered in production. The options span a spectrum: strict rejection with a clear, model-legible error that gives the agent enough information to retry correctly; permissive coercion that normalizes "$100" to 100; or a clarification round-trip using MRTR to ask the model (or the user) what was actually meant.
The right answer depends entirely on the tool's position in the side-effect risk model discussed later in this article. Silent coercion on a read-only search tool — normalizing "acme" to "Acme" before a lookup — is a reasonable usability improvement. Silent coercion on billing.issue_credit is not, because it papers over exactly the kind of ambiguity that should stop a financial operation and force clarification. A test suite that only checks "does the malformed call get rejected" without distinguishing between these two tools by risk tier is testing the wrong invariant.
Output schemas deserve the same scrutiny and routinely get less of it, because it's tempting to treat a tool's result as the end of the story rather than the beginning of the next decision. A result can be syntactically perfect JSON and still be logically impossible:
{
"success": true,
"account_id": null
}
What does the agent do with that? Does it treat success: true as authoritative and proceed to reference account_id in a follow-up call, propagating null into a downstream tool argument? Does anything in the client or the application layer catch the contradiction between a positive status and a missing identifier before the agent acts on it? Testing output contracts means testing invariants — success implies a populated identifier, an error implies a non-empty message, a paginated list implies a consistent cursor — and testing what happens when a tool violates its own stated contract, because eventually one will, whether through a bug, an incomplete migration, or a downstream timeout that the tool author didn't fully handle.
A 200 Response Can Modify the Wrong Record
MCP servers are very often a thin translation layer in front of something else. The crm.update_account tool exists because there's a CRM API underneath it; the MCP contract can be perfectly stable while that underlying API evolves independently, and that evolution surfaces to the agent as unexplained MCP behavior unless someone is testing the seam deliberately.
This is a consumer/provider contract testing problem, and MCP doesn't remove it — it relocates it. A field rename in the CRM API (owner_id becomes owner), an enum that gains a new value the MCP server doesn't know how to map, a pagination scheme that changes from offset-based to cursor-based, an error format that changes from a flat message to a structured problem-details object, a change in required authentication headers, a tightened rate limit — any of these can happen entirely inside the CRM vendor's release cycle, with zero changes to the MCP server's declared tool schema, and still break the integration in a way that looks, from the agent's point of view, like an MCP failure.
The failure scenario worth designing a test for specifically: a downstream field's meaning changes while its type stays technically valid. Say the CRM API's owner field is refactored from a plain internal user ID string to a generic "account owner" object that happens to still serialize as a string in the common case but now represents something structurally different — perhaps a resolved display value rather than a stable identifier. The MCP tool's JSON schema, if it was declared loosely as "type": "string", doesn't catch this. Nothing crashes. The agent, working from its existing understanding of what that field means, supplies what it believes is a valid owner identifier and the operation succeeds — against the wrong target, or with a value that silently fails to resolve to anything meaningful downstream. This is semantic contract drift: a change that a JSON validator is structurally incapable of catching, because the contract broke at the level of meaning, not type. Contract tests that pin down not just types but concrete example values, and that fail loudly when a downstream API's actual response stops matching those examples, are one of the few effective defenses against this category.
Discovery Is Part of the Runtime Contract
This section returns to a theme from tool discovery, but from the compatibility angle rather than the runtime-behavior angle, because production systems don't upgrade atomically.
An MCP-connected production system has several independent version axes moving at different rates: the MCP protocol revision itself, the SDK version each server and client is built on, the individual server's own release version, the tool schema version for any given tool, the version of the downstream API the server wraps, and — separately again — the agent's prompt or system instructions and the underlying model version. A production incident can involve a mismatch among any subset of these, and diagnosing it requires knowing which axis actually moved.
A useful way to organize this, without claiming it as an official compatibility guarantee MCP itself makes, is a conceptual matrix:
| Client | Server | Protocol Revision | Tool Schema | Expected Result |
|---|---|---|---|---|
| Current | Current | 2026-07-28 | v3 | Full compatibility |
| Current | Rolling deploy (old instance) | 2025-11-25 semantics on some pods | v2 (cached) | Partial — some instances behind load balancer respond with deprecated fields |
| Cached catalog | Updated | 2026-07-28 | v3 (server), v2 (client's stale cache) | Client calls a tool with an argument shape the server no longer expects |
| Current | Third-party vendor server, slow to adopt | 2025-11-25 | v2 | Client must negotiate down or fail gracefully |
This is where the stateless core has a concrete, testable consequence: because any request can now land on any server instance behind a plain load balancer, a rolling deployment where 30% of instances are running the new tool schema and 70% are still running the old one is not a transient blip that resolves itself — it's a steady state for the duration of the rollout, and every request during that window has a real chance of hitting either version. A test plan needs to simulate this directly: send the same logical request repeatedly against a mixed-version backend and confirm the client handles both response shapes without erroring or, worse, silently misinterpreting one as the other.
The specification's formal deprecation policy is useful context here rather than trivia: features are marked Active, then Deprecated, then Removed, with a minimum twelve-month window between deprecation and the earliest possible removal. That window exists specifically so that consuming applications have time to migrate rather than being broken by surprise — but only if someone is actually tracking which features the integration depends on and where those features sit in that lifecycle. Roots, Sampling, and Logging, for instance, are deprecated as of the current spec revision: still functional, not going away imminently, but not the right foundation for anything new being built today. A compatibility test suite that doesn't track deprecation status alongside functional correctness will pass today and quietly become technical debt within the year.
Permission to Connect Is Not Permission to Do Everything
Authentication answers "who is this." Authorization answers "what is this identity allowed to do, right now, to this specific object." MCP's current specification addresses the first question in real depth for HTTP-based deployments — OAuth-based flows, resource server concepts, token audience validation, scope negotiation — and the security best practices that accompany it are explicit that a server successfully authenticating a connection says nothing about which of its exposed tools that connection should be permitted to invoke.
The testing question that matters in production is narrower and more concrete than "is OAuth configured correctly": can the caller perform only the operations that caller should be permitted to perform, for every tool, every time, regardless of what tool discovery advertised?
A realistic authorization matrix for the fictional system, covering four roles against five representative tools, makes the shape of this concrete:
| Role | customer.read |
customer.update |
billing.list |
billing.issue_credit |
admin.change_permissions |
|---|---|---|---|---|---|
| Support Agent | Allow | Deny | Allow | Deny | Deny |
| Billing Specialist | Allow | Deny | Allow | Allow (≤ threshold) | Deny |
| Account Manager | Allow | Allow | Allow | Deny | Deny |
| Administrator | Allow | Allow | Allow | Allow | Allow |
The matrix itself is easy to design. The test coverage that actually protects it is less about the happy-path grid and more about the edge conditions around it: an authorized operation for the correct role; an unauthorized operation attempted anyway; an expired credential presented mid-session; a token whose audience claim doesn't match this specific server; a missing scope; a cross-tenant access attempt; access that was revoked after the session started but before this particular call; a scope that was silently downgraded by an intermediary; stale authorization cached from an earlier point in the conversation; and — a scenario that's easy to overlook — the same tool called under a different user's session within the same host process, to confirm that authorization state doesn't leak across concurrent conversations sharing infrastructure.
The single sentence worth pinning to the top of every review checklist here: a tool appearing in tools/list must never be treated as evidence that the current caller may execute it. Discovery and authorization are different systems that happen to sit next to each other in the request flow, and an integration that conflates "the server advertised this capability" with "this specific user, right now, may use it" has a defect regardless of how solid its OAuth configuration looks on paper.
The Search Term Was "Acme." The Tenant Boundary Was Not Enforced.
For a multi-tenant B2B system, cross-tenant isolation is not a subcategory of authorization testing — it's close to the central risk the whole integration exists to manage, because the cost of getting it wrong (one customer's data or actions leaking into another customer's context) is severe and often invisible until an audit or a customer complaint surfaces it.
Return to the opening scenario. Three accounts share or nearly share a name: Acme Corp, Acme Holdings, and an inactive legacy tenant. A search for "Acme" is genuinely ambiguous from the model's point of view, and there is no amount of prompt engineering that reliably resolves genuine ambiguity — the fix has to live in the system, not in the instructions given to the model. Specifically, the fix has to live in enforcement that happens outside probabilistic judgment: the search tool should be tenant-scoped by the calling identity's actual permissions, not by trusting the model to remember which tenant the conversation started in, and any operation with more than one plausible match should force disambiguation rather than silently picking the first or most recently modified result.
The concrete test conditions worth building into a regression suite: identical or near-identical customer names across tenants; account identifiers that are similar enough to be mistaken for one another (A-1821 versus A-1281); a search request missing an explicit tenant qualifier; a request carrying an invalid or manipulated tenant field; a conversation that started in one tenant's context and later, in the same session, references an account that turns out to belong to a different tenant; and a check for whether tenant context from a previous, unrelated conversation ever leaks into a new one through cached tool results or a reused handle.
The reason this belongs under "server-side enforcement" rather than "prompt design" deserves to be stated plainly, because it's the kind of thing that's easy to nod along with and then not actually build: relying on the model to keep tenant boundaries straight is not authorization. It's a best-effort behavior from a system that is, by design, probabilistic. Authorization is a guarantee; the model's judgment is not one, and testing should be built around that distinction rather than around hoping the distinction doesn't matter in practice.
Trust, But Verify the Server Itself
Every MCP capability discussed so far assumes the server behaves the way its schema and description claim. That assumption deserves its own scrutiny, because MCP's architecture makes it unusually easy to connect a host to a server whose provenance nobody has actually reviewed.
Current MCP tool annotations — readOnlyHint, destructiveHint, idempotentHint, and openWorldHint — give a server a vocabulary for describing a tool's risk profile: does it modify its environment, is that modification potentially destructive rather than purely additive, is it safe to retry with identical arguments, does it reach outside the local system into an open world of external services. These are useful, and a testing and governance strategy should absolutely make use of them. But the specification is explicit that they are hints, not enforced contracts, and that clients must treat annotations as untrusted unless they come from a server the client has actual reason to trust. A malicious or simply careless server can declare readOnlyHint: true on a tool that writes to a database, and nothing at the protocol level prevents that mismatch or automatically catches it. The defaults are deliberately conservative for exactly this reason — an unannotated tool is assumed potentially destructive, non-idempotent, and open-world until proven otherwise.
That gap between claimed and actual behavior is directly testable, and it's one of the more valuable, under-exercised test categories in an MCP production readiness review: build a harness that exercises a tool declared readOnlyHint: true and asserts, by checking the actual state of the downstream system before and after the call, that nothing changed. Do the same for idempotentHint — call the tool twice with identical arguments and confirm the second call doesn't produce a different observable effect than the first. This is cheap to build once and catches a category of defect that schema validation, by construction, cannot see: a schema says nothing about side effects, only about shapes.
That leads to the broader supply-chain question a production review has to ask and often doesn't: who actually built this server? A first-party server maintained by an internal platform team carries a very different risk profile than an open-source server pulled in because it happened to expose a convenient capability, or a third-party hosted service the team has no visibility into. This isn't a call for generic software supply-chain hygiene lifted wholesale from a different domain — it's specifically about the fact that an MCP server sits inside the model's decision loop, with the ability to shape what the model believes about the world through both its tool descriptions and its tool results. Code review, dependency review, version pinning, and artifact integrity checks matter more here than they would for a library that merely returns data the human, not the model, will read and evaluate.
Local servers carry a distinct risk shape worth calling out on its own, because it's easy to assume "runs on my machine" means "safe." A locally-running MCP server inherits whatever privileges the process that launched it has: filesystem access, environment variables (which frequently contain credentials), browser session state, and local network reachability. Current MCP security guidance treats this directly — local servers should run with the minimum privileges the workflow actually requires, ideally sandboxed, with file-system and network access scoped rather than inherited wholesale, and with genuine user consent shown at configuration time rather than assumed. "It never left my laptop" is not a security boundary; it's a description of where the blast radius is contained, and testing whether that containment actually holds — what can a compromised or misbehaving local server reach — is a legitimate part of a hardening review rather than an academic exercise.
Remote servers trade that risk for a different set: network availability, TLS configuration, multi-tenant isolation on the server side, rate limiting, and version skew across a fleet the client doesn't control the release cadence of. Neither category is universally safer than the other; the right posture depends on what the server can reach and what happens if it's compromised or simply misbehaves, and a review should evaluate each server the system connects to on those terms rather than applying a blanket policy based on transport alone.
What the Confused Deputy Actually Looks Like Here
MCP's official security guidance names a specific, well-documented attack pattern worth understanding precisely rather than gesturing at, because the mitigation only works if the underlying mechanics are understood: the confused deputy problem, arising specifically in MCP proxy server architectures — a server that sits between MCP clients and a third-party API, acting as a single OAuth client with a static client ID on the third-party's side while allowing many different MCP clients to register dynamically against itself.
The vulnerable combination is precise: the proxy uses a static client ID against the third-party authorization server; it allows MCP clients to register dynamically, each getting their own client ID on the MCP side; the third-party authorization server sets a consent cookie after a user's first approval; and the proxy doesn't enforce its own, separate per-client consent before forwarding a request into that third-party flow. When all four conditions hold, an attacker can register a malicious client against the proxy with a crafted redirect URI, send a targeted link to a user who has already consented once before, and rely on the third-party authorization server's consent cookie to skip the consent screen — silently redirecting the resulting authorization code to the attacker's own endpoint instead of the legitimate client's.
The mitigation, per current guidance, is for the MCP proxy to maintain its own per-client consent registry — checked before initiating any third-party authorization flow, not after — with strict validation of the OAuth state parameter and the registered redirect URI, using exact string matching rather than any pattern-based approach. This is testable directly: attempt the attack sequence in a staging environment (register a second client, replay a consent cookie, verify the proxy correctly demands fresh, per-client consent rather than honoring the existing cookie) and confirm the mitigation actually holds rather than trusting that it was implemented correctly because the code review said so.
Token passthrough is a related but distinct anti-pattern, and current MCP authorization guidance forbids it outright: an MCP server accepting a token from a client without verifying that the token was actually issued to that server — checking its audience claim — and then forwarding that same token, unmodified, to a downstream API. The server must not accept tokens whose audience doesn't match its own identity, full stop. This isn't a hardening suggestion; it's a stated requirement, because a server that skips audience validation breaks a boundary the rest of the security model depends on: downstream rate limiting, downstream audit trails, and the downstream system's own trust assumptions about who's calling it all assume the token they're seeing was actually intended for the party presenting it. Test cases here are direct and mechanical to build: present a token minted for Server A to Server B and confirm rejection; present an expired token; present one with an incorrect issuer; present one with insufficient scope; confirm the server never simply relays an unvalidated bearer token to whatever's behind it.
Server-side request forgery enters through a specific, less obvious door in MCP deployments: OAuth metadata discovery. A client following the protocol's discovery flow fetches URLs supplied by the server it's connecting to — the resource_metadata URL from a WWW-Authenticate header, the authorization server URLs from protected resource metadata, and the token and authorization endpoints from the authorization server's own metadata document. A malicious or compromised server can populate any of these with a URL pointing at an internal address, a cloud metadata endpoint (169.254.169.254 is the canonical example across AWS, GCP, and Azure), or a loopback service, and an MCP client that follows these URLs without validation becomes an unwitting network proxy into infrastructure it should never have reached. Current guidance is specific about mitigation: enforce HTTPS in production (with an explicit, narrow exception for loopback addresses during local development), block private and reserved IP ranges outright rather than attempting to hand-roll IP validation (encoding tricks like octal or hex representations, or IPv4-mapped IPv6 addresses, routinely defeat custom parsers), validate redirect targets with the same rigor as the original URL, and route discovery traffic through an egress proxy that enforces these policies at the network layer as a second line of defense rather than depending solely on application-level checks. This is squarely a defensive testing target, not a target for a step-by-step exploitation writeup: the useful test is confirming the client actually refuses a discovery URL pointing at a private range, not documenting how to reach one.
One further pattern is worth naming because it's new to the current spec revision and specific to the stateless architecture discussed earlier: state handle hijacking. Because MCP no longer carries a protocol-level session, a server that needs to track state across calls mints an explicit handle — a workflow ID, a draft object reference — and expects the model to pass it back as an ordinary argument. If an attacker obtains or guesses that handle, and the server treats mere possession of it as sufficient proof of identity, the attacker can act on another user's in-progress state. Current guidance requires servers to bind handles server-side to the authenticated identity that created them — for instance, keying stored state as <user_id>:<handle>, where the user ID comes from a verified token rather than anything the client supplies — and to reject a handle presented by any principal other than the one it was minted for. This is directly testable: mint a handle as User A, present it in a request authenticated as User B, and confirm the server rejects it rather than silently operating on User A's state.
None of this is offered as an exploitation guide, and it shouldn't be read as evidence that MCP is inherently insecure — every one of these patterns has a documented, specific mitigation, and the specification and its accompanying security guidance treat them directly rather than leaving implementers to discover them independently. The point for a testing organization is narrower: these are known, named attack classes with known, named defenses, which means "did we actually test for this" is a fair and answerable question, not a speculative one.
Secrets Leave Through the Door You Weren't Watching
Testing and debugging an agentic system generates an unusual amount of incidental capture — traces, screenshots, tool call logs, LLM context windows that get persisted for evaluation — and every one of those capture points is a place a secret can leak that wasn't leaking anywhere else.
The categories worth explicit sanitization checks: authorization headers and bearer tokens appearing verbatim in request logs; API keys embedded in tool call arguments that get written to a trace store; session cookies captured incidentally by a browser-automation tool's screenshot or accessibility snapshot; and customer PII flowing into an LLM's context window and then persisted alongside a conversation transcript for later human review or model fine-tuning. A test suite that validates functional correctness but never asks "did anything sensitive end up somewhere it shouldn't be" is missing a failure mode that tends to surface, if it surfaces at all, during a security audit rather than during development — which is exactly the wrong time to find it. The practical test: run a representative set of authenticated, high-privilege scenarios through the full observability pipeline — traces, logs, screenshots, support-ticket exports — and grep the output for anything that looks like a credential, a token, or an unredacted PII field that policy says shouldn't be stored raw.
The Browser Clicked Exactly What It Was Told To Click
Browser automation earns its own section because it makes the gap between mechanical execution and business correctness unusually visible — a gap that's easy to discuss abstractly and hard to ignore once you've watched it happen.
Playwright provides an official MCP server that exposes browser automation to MCP-compatible clients. Its central design choice is that it drives the browser primarily through structured accessibility snapshots rather than screenshots or vision-model interpretation of pixels — the model reads a semantic tree of the page (roles, labels, states) and issues actions like navigate, click, type, and fill against elements identified within that tree. This is a meaningfully different interaction model from earlier vision-based browser agents, and it changes where the failure modes live: less "the model misread a screenshot," more "the model matched an element in an accessibility tree that didn't correspond to what a human would consider the same element," or "the tree changed between the snapshot the model reasoned about and the click that was actually issued."
The project's own documentation states this plainly, and it's worth repeating exactly because it's the correct frame for everything else in this section: Playwright MCP is not a security boundary. The tool gives a model control of a real browser, with real network access, real cookies, and in permissive configurations real file-system reach. Responsibility for authentication, authorization, network placement, session isolation, and least privilege sits entirely with whoever deploys it — the tool itself makes no such guarantees, and current guidance points operators to MCP's general security best practices for exactly that reason.
The specific test worth walking through end to end, because it's the one that most cleanly demonstrates the point of this whole article: the agent is asked to update a customer's billing address. It navigates to the correct page, locates the address form through the accessibility tree, fills in the fields, and clicks Save. Every one of those steps executes successfully. The tool call log shows no errors. And yet the test isn't finished, because none of those steps answer the questions that actually determine whether the operation was correct: was it the right customer's record, confirmed against the same tenant and account ID the conversation started with — not just a name match? Was it the right browser tab, in a session where the agent may have multiple tabs open across a multi-step workflow? Was it the right environment — staging credentials pointed at a staging URL, not a production URL reached through a stale bookmark or a misconfigured base URL? Was it the right field — a page redesign that changed a form's field order can produce a mechanically successful fill into the wrong input, especially if the tool matched by position rather than by a stable accessible label? Did the operation cross a threshold that should have triggered a confirmation step before the click, rather than after? Was the user who issued the natural-language request actually authorized to change billing information for this account?
A tool call succeeding is evidence that the click landed. It is not evidence that any of the above are true, and treating "the browser tool returned success" as proof of business correctness is the single most common shortcut a browser-automation test suite takes that it shouldn't.
Related failure conditions specific to browser-driven tools deserve deliberate coverage rather than being left to chance: duplicate accessible labels on a page (two buttons both labeled "Save" in different sections); dynamic content that changes the accessibility tree between the snapshot the model reasoned from and the action it issues; stale element references from an earlier snapshot being reused after the DOM has changed; modal overlays or cookie-consent banners intercepting a click aimed at content underneath them; multiple open tabs or windows within one browser context; content inside iframes with separate accessibility trees; loading states where an element exists in the tree but isn't yet interactive; localized versions of a page changing visible label text the model may have been matching against; and layout differences from responsive breakpoints or live A/B experiments changing which element actually receives a click at a given set of coordinates.
None of this reflects poorly on Playwright as an automation engine — it reflects the fact that the model, not the tool, decides how to use the automation, turn by turn, based on an interpretation of what it's looking at. Testing has to cover both halves: whether the mechanical action executes as instructed, and separately, whether the agent's decision about what to instruct was the right one.
Retries Become Dangerous When Tools Have Side Effects
This is worth treating as one of the highest-leverage sections in a production readiness review, because the failure mode it addresses is uniquely dangerous: individually correct behavior at every layer producing a wrong, and sometimes costly, outcome in aggregate.
Retrying a read is close to free. If crm.search_account times out, retrying it costs latency and nothing else — the operation has no side effect to duplicate. Retrying a write is not free, and the danger scales directly with how consequential that write is. Walk through the scenario precisely, because the precision is what makes it testable rather than just cautionary: the agent calls billing.issue_credit for a customer. The downstream billing system receives the request and processes the credit — money moves, or a ledger entry is created. Before the HTTP or MCP-level response makes it back to the client, the connection drops — a load balancer restart, a transient network partition, a proxy timeout, doesn't matter which. The client never receives confirmation. From the client's point of view, and therefore from the agent's point of view, the call's outcome is genuinely unknown — not failed, unknown. If the agent's failure-handling logic treats "no response" as equivalent to "the operation didn't happen" and automatically retries with a freshly generated transaction identifier, the downstream system — which has no way to know this is a retry of an operation it already completed, because the new request carries no reference back to the old one — processes a second, independent credit. The customer receives two credits. Every component behaved according to a locally reasonable rule. The system's behavior, in aggregate, was wrong.
This is the classic distributed-systems "unknown outcome" state, and it deserves to be named precisely rather than softened into "failure," because the correct response to "the operation might have already happened" is categorically different from the correct response to "the operation definitely didn't happen." Testing has to construct this exact condition deliberately — inject a connection drop after the downstream write completes but before the response reaches the client — rather than only testing the simpler and less dangerous case of a connection dropping before the write happens at all.
The defense is idempotency, and it needs to be a property of the operation's contract, not an assumption layered on top after the fact. A well-designed high-impact tool accepts a caller-supplied idempotency key — a value the agent generates once per logical intent and reuses across retries of that same intent — and the downstream system deduplicates on that key, returning the original result for any repeated request rather than processing the operation again. Where a tool doesn't support that pattern, the alternative is a status-query capability: instead of blindly retrying an ambiguous operation, the agent (or the client on its behalf) calls something like billing.get_transaction_status to determine what actually happened before deciding whether a retry is safe. Neither of these mechanisms is exotic engineering — they're the standard toolkit for exactly-once-in-practice semantics over an at-least-once transport — but they only protect a system if someone deliberately built and tested the ambiguous-outcome path, rather than assuming retries are safe because they usually are.
Side Effects Are Not All the Same Risk
A practical, honestly-labeled risk model — not a claim about anything MCP itself formally specifies — helps calibrate exactly how much of the above rigor a given tool actually needs, because applying billing-credit-level scrutiny to every read-only search call is its own kind of failure: it slows delivery without buying proportional safety.
| Category | Examples | Testing emphasis |
|---|---|---|
| Read | crm.search_account, knowledge.search, billing.list_invoices |
Correctness, latency, tenant scoping |
| Reversible write | Updating a draft field, changing non-critical metadata | Correctness, basic idempotency |
| High-impact write | billing.issue_credit, sending a customer-facing message, crm.update_account on an owner field |
Idempotency, authorization, confirmation, audit trail |
| Destructive / irreversible | Deleting a record, terminating a resource, an irreversible transaction | All of the above, plus explicit human confirmation and rollback planning |
The strength of a tool's requirements around authorization, confirmation, idempotency, audit logging, and test coverage should scale with where it sits in this table — not be applied uniformly, and not be left to individual engineers' judgment on a tool-by-tool basis without a shared framework to calibrate against.
Human Approval Belongs at Specific Points, Not Everywhere
"Keep a human in the loop" is advice specific enough to feel responsible and vague enough to be nearly useless in practice, because it doesn't say where. The more useful version of the question is precise: for this specific tool, at this specific point in the workflow, does a human need to confirm before the action executes?
Read-only search generally doesn't need it — the cost of a wrong search result is a wrong answer the user can catch and correct, not an action that already happened. Sending external communication is genuinely context-dependent: an automated ticket acknowledgment probably doesn't need approval; a message that commits the company to a specific resolution or refund amount probably does. A financial adjustment above some threshold almost always does. A permission change nearly always does. A destructive or irreversible operation should essentially always require it, with the bar for what counts as an acceptable confirmation UX rising in proportion to how hard the action is to undo.
MRTR, discussed earlier, is the concrete protocol mechanism this maps onto in the current spec: a tool that needs confirmation returns resultType: "input_required" with the specific question that needs answering, rather than either executing silently or requiring the entire connection to stay open waiting on a human. That's a testable request/response pattern, not an abstract design goal — a test suite can assert that billing.issue_credit above a configured threshold always returns an input_required result rather than executing directly, and can further assert that this behavior can't be bypassed by an alternate tool path (an admin tool that also happens to be able to adjust a balance, called instead of the one everyone remembers to guard) that achieves the same effect without the same gate. Testing approval boundaries means testing that they can't be routed around, not just that they exist on the path everyone tested first.
Sequences Are Behavior Worth Testing on Their Own
Some of the most consequential defects in an agentic system live not in any single tool call but in the order calls happen in, and individual tool correctness says nothing about workflow correctness. A representative example: the intended workflow for updating an account owner is find the account, verify the requester's identity, check that the requester has permission to make this change, then update the account. An agent under time pressure — or simply working from an ambiguous prompt — might execute find account, then update account, skipping verification and permission checks entirely. Every individual call in that shortened sequence can succeed without error. The workflow is still wrong, and no amount of per-tool testing catches it, because per-tool testing by definition doesn't look at sequence.
This argues for treating sequence constraints as explicit, testable invariants rather than instructions embedded in a prompt and hoped for: Tool B must never execute before Tool A completes successfully. Tool D requires a successful Tool C in the same session. Tool X may execute at most once per logical transaction. Tool Y requires an explicit confirmation step regardless of what path led to it. Tool Z must never execute for a given role, full stop, regardless of what any prompt says. Where these constraints matter — and for anything in the high-impact or destructive tiers, they generally do — the right home for enforcing them is a deterministic guard sitting outside the model's own judgment, not an instruction the model is expected to follow reliably every time. Prompts are guidance. Guards are guarantees. A workflow that depends on the model always remembering to check permissions before acting is one bad prompt revision, one context-window truncation, or one unusual phrasing away from skipping that check, and testing should treat that as the expected failure mode to defend against, not an unlikely edge case.
What Should Never Be Tested With an LLM in the Loop
It's worth stating this directly, because building an evaluation suite that runs a model in the loop for every test case is both expensive and, for a large share of what actually needs verifying, unnecessary — and unnecessary LLM-in-the-loop tests are also flaky in ways that erode trust in the suite over time.
Deterministic, and therefore better tested deterministically, without spending a model call: JSON schema validation, authorization enforcement, token audience checks, rate-limit response mapping, idempotency behavior, the basic mechanical server contract, and the transformation logic between an MCP tool's schema and the downstream API it wraps. If the server returns an invalid schema, the right test hits the server directly with a crafted request and asserts on the response — it doesn't route the defect through an expensive, non-deterministic model call to discover the same thing less reliably.
What genuinely requires a model in the loop: tool selection given an ambiguous or naturally-phrased request, clarification behavior when a request is genuinely underspecified, argument construction from natural language, multi-step workflow sequencing under realistic prompt variation, interpretation of a tool's result in deciding the next action, and fallback or recovery behavior when a tool fails partway through a task. These are the areas where the same scenario should be run multiple times, because non-determinism is expected and the right test asserts on invariants — did the agent ever skip the permission check, regardless of which valid path it took to get there — rather than demanding an identical tool-call sequence on every run, which will produce constant, uninformative false failures for behavior that was never actually wrong.
Getting this split correct is what keeps a test suite fast, reliable, and trusted rather than slow, flaky, and eventually ignored.
Never Trust the Agent's Own Statement of Success
This is close to the single strongest quality principle applicable to MCP-connected systems, and it's worth stating as its own rule rather than folding it into the end-to-end testing section, because it's the mistake that's easiest to make by accident: the agent's final natural-language response is not evidence that the underlying operation succeeded.
"Your billing address has been updated" is a sentence the model generated based on a tool result it interpreted as success. It is not, itself, proof that the CRM record changed, that it changed correctly, or that it changed for the right customer. An end-to-end test that only asserts on the user-visible response — checking that the agent's reply contains the phrase "has been updated" — can pass with a 100% success rate while the underlying system state is completely wrong, because the test measured the model's confidence in its own narration rather than the actual state of the world.
The correct pattern queries the downstream system of record directly, after the agent's response, and confirms the state actually changed as intended: the CRM record's owner field genuinely reads the new value; the billing ledger genuinely shows one credit, not zero and not two; the support ticket genuinely exists in the ticketing system with the fields the agent claimed to have set. This holds for the same reason "success": true in a tool's JSON response deserves independent verification rather than blind trust, discussed earlier under output schema testing — an unverified claim of success, whether it comes from a tool's status field or from the model's own sentence describing what it believes it did, is a claim, not a fact, and testing exists specifically to convert claims into facts or disprove them.
What Not to Trust, as a Checklist
Pulling several of the article's threads into one place, because it's useful as a standalone review artifact: a production-grade MCP integration should not automatically trust tool names (a name implying safety, like get_account_summary, is not evidence the tool is read-only); tool descriptions (they're written by whoever built the server, and they may be stale, ambiguous, or actively deceptive); the model's own claims about what it did; a bare HTTP 200 or a JSON-RPC success envelope (transport-level success and business-level correctness are different claims); a "success": true field in a tool's own response body; client-side assumptions about authorization inherited from an earlier point in the session; a cached tool schema; the assumption that a retry is safe; an environment name like update_customer_test as if it were an access control (it's a label, not a boundary — nothing prevents a staging agent from being pointed at a production endpoint by a misconfiguration); and the agent's final natural-language confirmation to the user. Every one of these needs an independent, verifiable source of truth behind it before a production system can be trusted to have actually done what it reports having done.
A Production Incident, Traced Back
Return to the fictional system for a moment, because the abstract version of the retry-and-idempotency problem is easy to nod along with and easy to underestimate until it's traced through concretely.
billing.issue_credit was built correctly, by the team's own account: it accepts a transaction key, and the downstream billing system deduplicates on that key, returning the original result for a repeated request rather than processing it twice. The agent calls the tool to issue a $40 service credit for a customer complaint. The downstream system receives the request, processes the credit, and begins writing its response. A transient network failure — a load balancer restart during a routine deployment, unrelated to anything about this specific request — drops the connection before that response reaches the MCP server, and in turn before the MCP server's response reaches the client. The agent's tool call resolves to a timeout, not a success and not an explicit failure. Its retry logic, reasonably enough on its own terms, treats a timeout as "this probably didn't happen" and retries the operation to complete the customer's request. But the retry generates a new transaction key rather than reusing the original one — because the code path responsible for generating that key lived in a layer that didn't have visibility into the fact that this was a retry of a specific earlier attempt rather than a fresh request. The downstream system, doing exactly what it was built to do, sees a new key and processes what looks like a distinct, valid credit request. The customer receives two credits: $80 instead of $40.
Every individual component behaved according to a locally reasonable rule. The billing system correctly deduplicated on the key it was given. The MCP server correctly reported a timeout rather than fabricating a result it didn't have. The agent correctly treated an ambiguous outcome as worth retrying rather than silently giving up on the customer's request. The defect lived entirely in the seam between "the agent decided to retry" and "the retry preserved the original transaction key" — a seam that no single component owned, and that a test suite exercising each component in isolation would never have exposed, because each component in isolation was doing its job correctly.
The test that would have caught this before production: inject a connection drop specifically after the downstream write completes but before the response reaches the client — the exact "unknown outcome" condition described earlier — and assert that the retry, wherever it originates in the stack, reuses the original transaction key rather than generating a new one. The telemetry that would have surfaced it faster in production, if the pre-release test had been missed: a trace correlating the user's original request, the agent's tool calls, and the downstream transaction records by a shared identifier would show two transaction keys under one logical customer request within a short time window — a pattern worth an automated alert on its own, independent of anyone manually investigating the ticket. The deterministic guard that should exist regardless of whether the test or the telemetry ever runs: transaction-key generation belongs at the point where the agent first forms the intent to issue a credit, generated once and threaded through every subsequent retry of that same intent — not regenerated at the point where a retry happens to occur, where the code has no way of knowing it's part of the same logical operation as the attempt before it.
The workflow change that follows from all of this: idempotency keys need to be treated as part of the intent, established once per logical operation and passed through explicitly on every retry, not as an implementation detail generated wherever happens to be convenient in the retry path.
Failures Should Become Permanent Test Assets
An MCP-related production incident is expensive to produce and, if it's discarded after the postmortem closes, expensive again the next time a structurally similar defect slips through in a different tool. The loop worth building deliberately:
Incident
↓
Captured request / safe, redacted metadata
↓
Reproducible tool and downstream state
↓
Contract test (deterministic layer)
↓
Agent scenario (model-in-the-loop layer)
↓
Regression suite
↓
Release gate
The double-credit incident above becomes, after the fact, a deterministic contract test asserting that a retry after a mid-write connection drop reuses the original idempotency key, plus an agent-scenario test confirming the model-facing retry behavior doesn't regenerate a fresh transaction identifier on retry. Both live permanently in the regression suite from that point forward, and both run on every release touching billing tools. This is where a production incident stops being purely a cost and starts becoming infrastructure — but only if the loop is built deliberately rather than assumed to happen naturally, because in practice it doesn't happen naturally; it happens because someone owns making it happen.
Observability Has to Answer "Which One," Not Just "Did It Work"
"The MCP call failed" is a genuinely unhelpful incident signal on its own, and it's worth being specific about exactly what it's missing rather than gesturing at "more observability" as the fix. A team debugging that signal needs to know, at minimum: which server, exposing which tool, at which version of that tool's schema; under which user's authorization context; against which downstream API and which version of that API; on which attempt number, if this was a retry; and which specific business transaction or customer this call was part of.
Correlating across a user's task, the agent's full reasoning trace, the specific MCP server and tool invoked, tool version, a request ID that survives across retries, the authorization decision made for that specific call, latency at each hop, the result status, the downstream system actually touched, whether a side effect occurred, and how many retry attempts preceded the final outcome — is what turns "the MCP call failed" into an actually actionable engineering signal rather than a starting point for another hour of manual log archaeology. This isn't a wholesale restatement of general AI-agent observability practice; it's specifically about the MCP boundary, and specifically about the fact that a single user-facing failure can originate at any of several independent hops — client, server, downstream API — each of which needs to be individually identifiable in the trace for a postmortem to be fast rather than slow.
Auditability is a related but distinct requirement, particularly for the high-impact and destructive tool tiers, and it shouldn't be conflated with general debug logging. A reliable audit record for a consequential operation needs to answer: who initiated the underlying user request; which agent instance and session executed it; which specific MCP tool was invoked, with which arguments; what target object was actually changed; when, precisely; under which authorization context, specifically enough to reconstruct the permission decision later; and what result was actually produced downstream, verified independently rather than taken from the tool's own success claim. This is deliberately a narrower, more disciplined record than a full debug trace — it should not become an excuse to indiscriminately capture and retain raw sensitive content just because it happened to pass through the pipeline at some point. Debug logs, distributed traces, security audit events, and business audit records serve different purposes and different retention policies, and treating them as one undifferentiated stream is how sensitive data ends up retained somewhere nobody intended it to be.
Release Gates, Stated Precisely
"All tests must pass" is not a release gate; it's a description of the absence of one. A release gate specific enough to actually be checked before an MCP integration reaches production looks more like a list of direct, answerable questions:
Can every tool exposed to this agent in production be identified by name, owning team, and risk tier? Are input and output schemas validated, including the semantic contract-drift scenario, not just structural typing? Are the downstream dependencies behind each MCP server documented, including their own versioning behavior? Have authorization boundaries been tested against the full matrix — not just the happy path for each role, but expired, wrong-audience, and cross-tenant attempts? Are high-impact and destructive operations independently auditable, with a verified record of what actually happened downstream, not just a tool's self-reported status? Are retries provably safe for every tool that has a side effect — meaning idempotency has been tested under the specific ambiguous-outcome condition, not just the simple case of a call failing before it reaches the server? Can the system handle a tool becoming unavailable mid-session without producing uncontrolled agent behavior — duplicated actions, silent failures reported as success, or an agent stuck retrying indefinitely? Can the agent be reliably prevented from crossing a tenant boundary, tested against the specific ambiguity conditions (similar names, similar IDs) that make it likely to happen by accident rather than only tested against obviously malformed input? Can this specific integration be rolled back independently of the rest of the platform? Can production behavior for a single failed request actually be traced end to end, hop by hop, without manual log correlation across systems that weren't built to be correlated? And can a specific server or a specific tool be disabled quickly, without a full deployment cycle, if something in production turns out to be behaving unexpectedly?
A "yes" to every one of these is a meaningfully higher bar than "the test suite is green," and it's the bar a genuine production readiness review is checking against.
Kill Switches Are an Assumption Until Someone Pulls Them
Operational controls worth having in place before they're needed, not designed reactively during an incident: the ability to disable an entire MCP server; disable a single tool independently of the rest of that server's catalog; drop the whole integration into a read-only mode that blocks all write-capable tools while leaving search and lookup functional; restrict access to a specific set of users or a specific set of tenants; roll back to a previous version of a tool's schema; and fall back to a non-agentic workflow for a specific task if the agentic path needs to be pulled entirely.
The point worth making explicitly, because it's the part teams skip under delivery pressure: a kill switch that has never actually been exercised in a realistic drill is an assumption, not a control. The right way to validate it is the same way any disaster-recovery capability gets validated — deliberately trigger it in a staging or controlled production environment, under conditions that resemble a real incident, and confirm it behaves the way the runbook says it will, before the first time it's needed is also the first time anyone finds out whether it actually works.
Who Owns This, Exactly
Ownership questions are easy to leave implicit in early development and expensive to leave implicit once an integration is carrying real production traffic. A partial list of plausible stakeholders for a single MCP-connected feature: the AI or ML team that built the agent and its prompts; the platform engineering team that runs the MCP servers; the application developers who own the host; the security team responsible for the authorization model; QA; the SRE or on-call team responsible for production incidents; the product team that defined the workflow; and — easy to forget, because it's furthest from the AI part of the system — whoever actually owns the downstream business system the agent is ultimately touching (the CRM, the billing platform).
The gap this list is meant to expose: it's entirely possible for the AI team to have thoroughly tested tool selection, the backend team to have thoroughly tested the CRM API integration, and the security team to have thoroughly tested the OAuth flow — and for nobody to have tested whether the agent can issue the same financial action twice after an ambiguous network timeout, because that specific failure lives precisely in the seam between all three teams' areas of ownership, and seams are exactly where unowned risk accumulates. This is less a call for a new organizational chart than an observation about where quality engineering's actual value tends to concentrate in these systems: not owning any single layer better than the specialist team that already owns it, but being the function that deliberately tests the connective tissue between layers that no single specialist team has full visibility into on its own.
What This Changes for QA — and What It Doesn't
An MCP-connected production system genuinely does expand what a QA organization needs to be able to validate: protocol-level behavior, tool contracts and their schemas, authorization boundaries under the specific ambiguity conditions that make agentic mistakes likely, the range of decisions a model can plausibly make given a tool catalog and a natural-language request, side effects and their idempotency guarantees, version compatibility across the several independent axes discussed earlier, distributed-systems failure modes under an ambiguous-outcome retry, and both technical and business auditability.
This is additive, not a replacement for the disciplines that already existed. API testing, integration testing, security testing, UI testing, and performance testing don't stop mattering because a model is choosing which API call to make — if anything, each of them needs to be exercised with the added awareness that the caller constructing requests is non-deterministic rather than a fixed test harness generating the same request every time. What MCP genuinely adds is the connective layer between these established disciplines: schema correctness on its own, security controls on their own, and agent decision-making on its own can each look solid in isolation while the seams between them — a retry that regenerates an idempotency key, a tenant boundary that depends on the model remembering context it was never guaranteed to retain, a tool description ambiguous enough to occasionally route a request to the wrong capability — carry defects invisible to any test that only looks at one layer at a time.
What This Changes for Founders and CTOs
Translated into business terms rather than engineering ones, the risks discussed throughout this article map onto consequences that are worth stating without hedging, because the technical framing can obscure how directly they land on the business:
A wrong tool selection is an incorrect action taken against a real customer's real account — not a UI bug a user can shrug off, but something that already happened. An over-permissioned server is a security and compliance exposure that an audit or a breach will eventually surface, whichever comes first. A duplicated write on a financial operation is a direct financial and data-integrity problem, and at scale, a recurring one rather than a one-off. Unbounded or poorly-guarded retries against a struggling downstream dependency are both a cost problem and an availability problem — retry storms make outages worse at precisely the moment capacity is already constrained. Server or protocol incompatibility introduced by an unplanned upgrade is a production outage with a root cause that's genuinely difficult to diagnose quickly unless the observability discussed earlier was built in advance rather than after the fact. Weak auditability on high-impact operations is an enterprise trust and compliance problem long before it becomes a specific incident — it's the kind of gap that shows up in a due-diligence review or a customer security questionnaire regardless of whether anything has gone wrong yet. And a cross-tenant error in a multi-tenant SaaS product is, depending on what data or action crossed the boundary, a potentially severe legal and customer-trust issue, not a bug ticket.
None of these are hypothetical categories invented for this article. Every one maps directly to a testing gap discussed in a specific section above, which is the point: the business risk and the engineering test that prevents it are the same conversation, viewed from two different vantage points, not two separate ones.
A Working Quality Model for MCP Integrations
Pulling the article's territory into a single, restrained framework — offered as an original organizing structure, not a claim about anything MCP formally specifies:
Contract — Can the parties communicate correctly? Protocol conformance, schema validation, both input and output.
Capability — Does each exposed tool actually do what its description and annotations claim, verified against real downstream state rather than the tool's own reported status?
Control — Can only the correct identity perform the correct operation, on the correct target, tested against realistic ambiguity — similar names, similar IDs, similar-looking tenants — not just obviously malformed input?
Behavior — Does the agent select and sequence capabilities correctly, including the workflow-level invariants (verification before action, confirmation before high-impact writes) that no single tool call enforces on its own?
Outcome — Did the intended real-world result actually happen, verified independently of the agent's own narration of what it believes it did?
Resilience — Does the system behave safely — not just tolerably, but safely — when a dependency fails, times out, or returns an ambiguous result, specifically including the unknown-outcome retry condition?
Operability — Can the team observe what happened at each hop, disable a misbehaving server or tool without a full deployment cycle, investigate an incident without manual cross-system log correlation, and recover?
A gap at any one of these seven layers can undermine confidence built at all the others. An integration that's airtight on Contract and Capability but untested on Outcome is an integration where every component works and the business result is still occasionally wrong — which is, not coincidentally, exactly the state the fictional CRM integration was in at the start of this article, before anyone asked what happens when there are two accounts named Acme.
Closing the Review
At the start of the review, the integration looked ready, because the demo worked, and a working demo is a genuinely reasonable thing to feel confident about — right up until someone asks what it doesn't cover.
By the end of a review structured the way this article has walked through, the same team knows something more specific and more useful than "it works": they know what happens when a tool disappears mid-session because a schema deployment rolled partway through the fleet; what happens when a tenant boundary is tested against a name collision rather than obviously malformed input; what happens when authorization is present but insufficient for the specific operation being attempted; what happens when a downstream API changes a field's meaning while its type stays technically valid; what happens when a write succeeds but its acknowledgment is lost in transit, and whether the resulting retry is safe; and what happens when the agent, the server, and the protocol version it's speaking to are not all on the same page at the same moment, because in a system with independent release cycles at every layer, they eventually won't be.
Standardized connectivity was never the same claim as validated behavior. MCP gives an AI application a consistent, well-specified way to reach a tool, a data source, or a browser. It does not, and was never intended to, decide whether reaching that tool with a given set of arguments, under a given identity, at a given moment, was the correct thing to do. That decision is made by the model, executed by the server, and verified — or not — by whatever test strategy the team building on top of the protocol chose to build alongside it.
The moment an MCP tool can change something the business actually cares about, its contract belongs in the test strategy, on the same terms as any other production dependency: versioned, monitored, gated, and owned by someone who can be asked, precisely, what happens when it fails.
Production AI quality increasingly sits across disciplines that used to be tested separately — API and integration testing, security validation, performance and resilience under real failure conditions, and the behavioral testing of an agent's own decision-making — and MCP is the layer where all four now meet in a single execution path. QAtronic works across exactly that combination of disciplines for teams building production software, including the AI-connected systems this article describes.
Frequently Asked Questions
What is MCP testing? MCP testing is the practice of validating an AI application's use of the Model Context Protocol as part of its production execution path — covering protocol conformance, tool schema and semantic correctness, authorization and tenant isolation, side-effect safety and idempotency, version compatibility, and whether the agent's tool calls produce the correct real-world outcome, not just a successful response.
How do you test an MCP server? Test it at two levels. Deterministically, against its own contract: schema validation for inputs and outputs, authorization enforcement per role and per tenant, error mapping, idempotency under retry, and compatibility across protocol and schema versions — none of which require a model in the loop. Then, with a model in the loop, test whether an agent reliably selects and calls the server's tools correctly given realistic, ambiguous natural-language requests.
Should MCP tools be tested without an LLM? Yes, wherever the behavior being tested is deterministic. Schema validation, authorization boundaries, idempotency, and the mapping between an MCP tool and the downstream API it wraps don't need a model call to verify — testing them directly is faster, cheaper, and more reliable than routing the same check through an agent.
What is the difference between MCP testing and API testing? API testing validates a fixed client calling a fixed contract. MCP testing includes that, but adds a layer API testing doesn't need to cover: the caller constructing the request is a model choosing which tool to call, with which arguments, based on natural language — so tool selection, argument construction, and workflow sequencing all need their own test coverage alongside the underlying API contract.
How should MCP authorization be tested? Build a matrix of roles against tools and test each cell — including negative cases such as expired credentials, wrong-audience tokens, missing scopes, cross-tenant access attempts, and revoked access mid-session. Critically, verify that a tool appearing in tool discovery is never treated as proof that the current caller is authorized to invoke it; authorization must be enforced independently, on every call.
Can Playwright be used through MCP? Yes. Playwright provides an official MCP server that exposes browser automation — navigation, clicking, typing, form filling — to MCP-compatible clients, primarily through structured accessibility snapshots rather than screenshots. Its own documentation states plainly that it is not a security boundary; authorization, network placement, and session isolation are the deploying team's responsibility.
How do you test MCP tool schemas? Cover both input and output contracts. For inputs: required and optional fields, type boundaries, malformed or semantically-drifted calls a model might realistically generate, and how the server handles them (reject, coerce, or clarify). For outputs: missing fields, contradictory combinations (a success flag with a null identifier), and whether the schema can stay technically valid while the meaning of a field silently changes underneath it.
How do you test state-changing MCP tools? Classify the tool by side-effect risk first — reversible write, high-impact write, or destructive — then scale requirements accordingly: idempotency under an ambiguous, unknown-outcome retry (not just a simple pre-write failure), authorization, audit logging, and, for high-impact and destructive operations, an enforced confirmation step that can't be bypassed through an alternate tool path.
How should MCP failures be monitored in production? Correlate, don't just log. A useful signal ties together the user's task, the agent's tool-call trace, the specific server and tool version invoked, the authorization decision made, latency at each hop, the downstream system actually touched, and the retry count — so "the MCP call failed" becomes "this tool, this version, this downstream dependency, this attempt number," which is the information an on-call engineer actually needs.
Is a successful MCP tool call proof that an AI task succeeded? No. A tool returning success, or the agent's own natural-language claim that it completed the task, is not independent verification. The reliable test queries the actual downstream system of record after the fact and confirms the intended state change genuinely happened, for the intended target, correctly.
Sources and Further Reading
- Model Context Protocol — Specification, version 2026-07-28
- The 2026-07-28 Specification — MCP Blog
- MCP Security Best Practices — official documentation
- Tool Annotations as Risk Vocabulary: What Hints Can and Can't Do — MCP Blog
- SEP-2663: Tasks Extension — Model Context Protocol
- The 2026 MCP Roadmap — MCP Blog
- Playwright MCP — official repository and documentation, Microsoft
- MCP Security — OWASP Cheat Sheet Series