← All posts / Meta

The SDK Trusted the Server: Inside the MCP Python OAuth Flaw That Steals Real Logins

A high-severity flaw in the official MCP Python SDK let any malicious tool server harvest OAuth client secrets, authorization codes, and PKCE keys by answering one 404 — fixed in 1.30.0 and 2.2.0.

The SDK Trusted the Server: Inside the MCP Python OAuth Flaw That Steals Real Logins

On September 29, the maintainers of the official MCP Python SDK published a security advisory with an unusually blunt bottom line: a malicious MCP server could trick an application built on the SDK into handing over the OAuth credentials it uses to log in to a real service. Not a phishing copy of a login page — the genuine article — while the client secret, the authorization code, and the PKCE proof key quietly travel to an attacker-controlled token endpoint instead.

The flaw, discovered and reported by security firm Cycode, is rated High (7.5) for the two providers that run without a human present, and 6.5 for the interactive provider. Fixes shipped in versions 1.30.0 and 2.2.0 of the mcp package. As of September 29, no CVE identifier had been assigned.

For an ecosystem that has spent 2026 wiring AI agents to external tools through the Model Context Protocol, this is the kind of vulnerability that deserves a slow read rather than a headline skim — because the mechanics explain a lot about why agentic security is hard.

What the MCP SDK was supposed to do

The Model Context Protocol is an open standard, introduced by Anthropic and now stewarded by the Linux Foundation’s Agentic AI Foundation, for connecting AI assistants to external tools and data sources. When an MCP client connects to a server that demands login, the SDK has to answer two questions: where should the user be sent to log in, and where should the resulting authorization code be exchanged for a token?

Both answers are URLs, and getting them right is a security property. Send your credentials to the wrong place, and they are stolen.

The SDK resolves this through a process called discovery. The safe path: the client asks the MCP server who handles its logins, receives a URL pointing at an authorization server (Google, Okta, Azure AD), fetches that provider’s configuration, and then verifies that the configuration’s issuer field — the provider’s self-declared identity — matches the URL it was given in the first place. That issuer check is the control that catches a server lying about who its login provider is.

There is also a fallback path. If the modern discovery request fails — if the server returns a 404 — the SDK falls back to asking the MCP server itself for the login configuration directly.

The 404 that disarms the safety check

That fallback is where everything breaks. On the fallback path, the SDK never received a URL to check against, so the internal value is None. The issuer validation code reads:

if self.context.auth_server_url is not None:
    validate_metadata_issuer(asm, self.context.auth_server_url)

In plain English: “if we got a URL, verify the provider’s identity against it.” But the attacker controls whether that URL ever exists. Returning a 404 to the discovery request — doing nothing, essentially — routes the SDK onto the fallback path where the check doesn’t fail. It never runs. The SDK then accepts whatever login configuration the malicious server supplies, unverified.

A second defense fails in tandem. The SDK labels stored credentials with the login provider they belong to, and checks that label before using them. But it checks against the issuer field from the configuration — the same field the attacker controls, because the first check never ran. The attacker simply sets issuer to the name of the victim’s real login provider, and the credential binding check passes with a lie. Your real credentials are kept and used, but they are sent to the attacker’s endpoint.

The login page is real — that’s the point

What elevates this from a theoretical flaw to a practical attack is that the user’s one moment of scrutiny is spent approving a genuine login page. The attacker’s configuration points the login URL at the real provider. The user is redirected to actual Google, actual Okta, actual Azure AD — real URL, real certificate. Nothing looks wrong because nothing is wrong with that page.

After approval, the authorization code returns to the client, and the SDK bundles it with the client secret and the PKCE proof key — the one-time secret specifically designed to prevent stolen authorization codes from being redeemed — and posts the package to what it believes is the provider’s token endpoint. That URL came from the attacker’s configuration. Cycode demonstrated the full chain end-to-end against a real authorization server with PKCE enforcement: the attacker redeems the stolen bundle and gets back a genuine access token.

From the victim’s side, the MCP server simply seemed to hang after login. A flaky server. You close the tab and move on.

Cycode’s three-configuration experiment isolates the control that matters. In the attack configuration (404 discovery), credentials are stolen. In the control — identical except the server answers discovery — the issuer check runs, the mismatch is caught, and the flow stops before anything leaves the machine. The only difference between account takeover and safety is whether that one check executed.

Who is affected, and what to do

Affected: applications using the MCP Python SDK as an HTTP client with OAuthClientProvider, ClientCredentialsOAuthProvider, or PrivateKeyJWTOAuthProvider (plus the deprecated 1.x RFC7523OAuthClientProvider), that can connect to servers they don’t fully control while holding credentials for a legitimate login provider. The version ranges are 1.9.1–1.29.1 and 2.0.0–2.1.1. Not affected: MCP servers built with the SDK, local stdio clients, and clients that attach their own tokens.

The fix: upgrade to mcp 2.2.0 (2.x line) or 1.30.0 (1.x line). The patched SDK determines the expected login provider before fetching any configuration, refuses configuration naming a different provider, and labels stored credentials so credentials for one provider can never be sent to another.

But upgrading alone is not the whole fix. Three follow-ups from the advisory:

  1. Pass issuer= explicitly if you use ClientCredentialsOAuthProvider or PrivateKeyJWTOAuthProvider. Without it, these providers still follow whichever login provider the MCP server names. On 1.30.0 the warning is a standard Python deprecation warning — hidden by default, and easy to miss; it becomes mandatory in 3.0.
  2. Clear stored registrations. Registrations saved by older versions carry no provider label and remain unbound. Clear stored OAuth client information once after upgrading so the client re-registers with the label in place.
  3. Rotate if exposed. If the client may have connected to an untrusted server before the upgrade, rotate the client secret and revoke tokens at the login provider. The client secret is long-lived and often non-expiring; code rotation and token revocation don’t help if the secret itself survives.

Why the pattern matters more than the CVE

Zoom out and the vulnerability is a general template: a safety check that only runs when certain data is present, where the attacker controls whether that data is present. Worse, the skipped value doesn’t just go unverified — it flows into the next safety check as trusted input and actively satisfies it. The issuer validation existed. The credential binding existed. The audience restriction existed. All of them were fed the same unverified input, and all of them said “looks good.”

The severity math also understates the real-world risk. The 6.5 score for the interactive provider assumes a user knowingly chose to connect to a malicious server. Registry poisoning (a typosquatted entry in an MCP directory), prompt injection steering an agent that selects servers autonomously, or a DNS hijack of a legitimate server name — each removes the “user chose this” assumption entirely. And for the machine-to-machine providers, there was never a human in the loop to begin with.

MCP has become the connective tissue of the agentic ecosystem, with hundreds of millions of downloads. This flaw is a reminder that in that world, the tool server your agent talks to is not just a capability — it’s a participant in your authentication flow. Trusting it less is now a patch requirement, not a posture.