Threat

Official MCP Python SDK Flaw Can Let Malicious Servers Steal OAuth Credentials

Page published · Page updated

Dossier

Earliest dated coverage: 28 Sep 2026 · First observed: 29 Sep 2026 · Latest dated coverage: 30 Sep 2026

Coverage timeline

28 Sep 2026cycode.com 29 Sep 2026thehackernews.comobserved 30 Sep 2026forkast.news

Why it matters

The MCP Python SDK flaw can turn an untrusted server connection into credential theft and unauthorized service access, making updates important for affected AI integrations.

The official MCP Python SDK vulnerability GHSA-qx49-fqc8-xw99 lets a malicious MCP server divert a client's OAuth client secret, authorization code, and PKCE proof key to an attacker-controlled token endpoint. The stolen credentials can be exchanged for a valid access token from the legitimate service. Fixes are available in versions 1.30.0 and 2.2.0.

vuln-research

Summary

Cycode researchers disclosed an OAuth credential-theft vulnerability in Anthropic's official Model Context Protocol (MCP) Python SDK. A malicious MCP server can hijack the SDK's login discovery flow and cause the client to send its client secret, authorization code, and PKCE proof key to an attacker-controlled endpoint, resulting in full account takeover against the user's real login provider.[0]

The flaw affects MCP Python SDK versions 1.9.1 through 2.1.1 across three auth providers (OAuthClientProvider, ClientCredentialsOAuthProvider, and PrivateKeyJWTOAuthProvider) and is rated High / 7.5. It was reported through Anthropic's MCP security process and fixed in mcp 2.2.0 (2.x line) and 1.30.0 (1.x line); the disclosure followed coordinated disclosure with no in-the-wild exploitation reported.[0][12][14][15]

Attack chain

  1. Discovery downgrade: The attacker's malicious MCP server returns HTTP 404 when the SDK asks who handles its logins, forcing the SDK onto an older fallback discovery path where it queries the malicious server directly for the login configuration.[0]
  2. Issuer check bypass: On the fallback path auth_server_url is None, so the issuer validation (validate_metadata_issuer, guarded by an 'is not None' condition) never runs and the attacker fully controls the returned issuer and endpoint URLs.[0]
  3. Credential binding satisfied by a lie: The attacker sets the issuer field to the name of the victim's real login provider. The credential binding check compares stored credentials against the attacker-controlled issuer value and passes, so the SDK keeps and uses the real credentials.[0]
  4. Real login page presented: The attacker points the authorization URL at the victim's genuine login provider (e.g. Google/Okta/Azure AD), so the user approves an authentic login page with a valid certificate and no phishing indicators.[0]
  5. Credential exfiltration: After approval, the SDK bundles the authorization code, client secret, and PKCE code_verifier and sends them to the attacker-controlled token endpoint; the missing audience binding on the fallback path means the code is not restricted to a specific server.[0]
  6. Token minting and takeover: The attacker posts the stolen code, proof key, and client secret to the real provider's token endpoint, receives a valid access token, and confirms account takeover via the user info endpoint; the long-lived client secret and any refresh token grant persistent access.[0]

Disclosure timeline

DateEvent
2026-09-28Cycode's blog post on the MCP Python SDK OAuth account takeover was originally published.[0]
2026-09-29Cycode blog post last updated.[0]

How it works

The SDK's login discovery normally asks the MCP server for its authorization server URL, fetches that provider's configuration, and validates that the configuration's issuer field matches the discovered URL. This issuer check is the key control that catches a server lying about its login provider.[0]

When the modern discovery request returns 404, the SDK falls back to asking the MCP server directly for the login configuration. On this path auth_server_url is None, and because the issuer validation is written as 'if self.context.auth_server_url is not None: validate_metadata_issuer(...)', the check is silently skipped rather than failing, letting the attacker control which URLs credentials are sent to.[0]

The secondary credential-binding control compares stored credentials against the issuer value from the login configuration — the same field the attacker controls once the first check is skipped — so setting issuer to the victim's real provider name makes the binding check pass with a lie. Audience binding, which would stamp the authorization code as valid only at a specific server, is also only applied on the modern discovery path, so on the forced fallback path the code carries no audience restriction.[0]

The machine-to-machine providers (ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider) had no mechanism to specify which login provider their credentials belong to, so they would follow whichever provider the MCP server named. The fix requires an explicit issuer= parameter (deprecation warning now, required in 3.0) and always validates the login provider's identity even on the fallback path.[0]

Affected versions and patch status

ProductAffectedPatch status
Anthropic MCP Python SDK (mcp) — 1.x line1.9.1 through 1.29.1 (no issuer check and no credential binding on any discovery path)Fixed in 1.30.0[0][14]
Anthropic MCP Python SDK (mcp) — 2.x line2.0.0 through 2.1.1 (checks present on modern discovery path but missing on fallback and 403 step-up paths)Fixed in 2.2.0[0][15]
OAuthClientProvider, ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProviderHTTP clients using these three auth providers that may connect to untrusted MCP servers; not affected: MCP servers built with the SDK, local (stdio) clients, and clients attaching their own tokens/headersFixed by upgrade plus passing issuer= for pre-provisioned credential providers and clearing stored registrations[0]

Key takeaways

  • The vulnerability is a general pattern: a safety check that only runs when certain data is present, where the attacker controls whether that data is present — skip the check, and the unchecked value flows into the next check as trusted input and actively satisfies it.[0]
  • Because the attacker redirects users to a genuine login provider page, there are no phishing indicators; the login page is real and the authorization server sees a legitimate login, so standard monitoring and MFA will not flag the theft.[0]
  • The stolen client secret is long-lived and reusable, and refresh tokens enable indefinite silent access, so the impact extends far beyond a single session into lateral movement across whatever services the compromised OAuth client can reach.[0]
  • CVSS 'requires user interaction' scoring understates real risk: the unattended machine-to-machine providers require no interaction (7.5), and registry poisoning, prompt injection in AI agent workflows, or DNS hijacking can remove the human decision entirely.[0]

Defensive actions

  • Upgrade the MCP Python SDK to 2.2.0 (2.x line) or 1.30.0 (1.x line) or later.: Fixed versions determine the expected login provider before fetching configuration, refuse configuration from a different provider, and label stored credentials with their provider.[0][14][15]
  • For pre-provisioned credentials, pass issuer= to ClientCredentialsOAuthProvider or PrivateKeyJWTOAuthProvider.: Without an explicit issuer, these machine-to-machine providers still follow whichever login provider the MCP server names; it becomes required in version 3.0.[0]
  • Clear stored OAuth client registrations after upgrading.: Registrations saved by older versions have no login provider label and remain unbound, so the client must register fresh to apply the binding.[0]
  • If a client may have connected to an untrusted MCP server before upgrade, rotate the client secret and revoke tokens at the login provider.: The client secret is long-lived and reusable and refresh tokens grant persistent access, so rotation/revocation is needed to cut off any attacker foothold.[0]
  • On older, unpatched versions, connect only to MCP servers you fully trust.: There is no workaround on older versions other than restricting connections to trusted servers.[0]