AI Security

A Malicious MCP Server Can Take Your OAuth Client Secret. The Fix Shipped Three Weeks Ago.

Dark cyberpunk illustration of a single brass key hanging on a thin wire in front of a black steel door set with two identical keyholes, one glowing amber and one glowing cyan, reflected on a wet floor.

Your coding agent asks you to sign in to a service. A browser tab opens on the provider's real login page, at the real domain, with the real certificate. You approve the consent screen, the agent reports success, and the tools start working. Somewhere in that exchange an attacker collected your OAuth client secret, and nothing in the flow looked wrong.

That is GHSA-qx49-fqc8-xw99, published on September 28 against the official Model Context Protocol Python SDK. The title is the whole bug: "OAuth client could send credentials to an authorization server chosen by the MCP server." Cycode's research team found it and published the walkthrough the same day. The advisory scores it 7.5 on CVSS v3.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) under CWE-345 and CWE-522. No CVE has been assigned.

Affected: mcp 1.9.1 through 1.29.1, and 2.0.0 through 2.1.1. Fixed: 1.30.0 and 2.2.0. Both fixed versions were uploaded to PyPI on September 7, twenty-one days before the advisory explained why they mattered.

Who this reaches, and who can close the tab

Close the tab if your Python code only serves MCP tools. The flaw lives in the SDK's OAuth client support, in the code path that goes out and authenticates to somebody else's MCP server. A FastMCP process that exposes tools and verifies inbound bearer tokens is not exposed to this one.

Close the tab too if your agent stack is TypeScript. The advisory covers the Python SDK package only. The TypeScript SDK is a separate codebase and is not in scope here, so a Claude Desktop or Cursor install is not what this is about.

This is yours if any Python process you run constructs OAuthClientProvider, ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider, or the deprecated 1.x RFC7523OAuthClientProvider, and connects to an MCP server you do not fully control. The usual shape is an internal agent platform, a retrieval service, or a CI job that reaches out to a third-party MCP endpoint to fetch tickets, repositories, or CRM records. If the endpoint list is editable by anyone outside your security review, treat it as reachable by an attacker.

Managed service providers carry this twice, once for their own automation and once for every client environment where somebody added an MCP endpoint without telling them.

The discovery path that skipped the check

The SDK supports two ways to find out where to log in. On the modern path it reads RFC 9728 protected-resource metadata from the MCP server, learns the authorization server's URL as a separate value, fetches metadata from that URL, and confirms that the issuer field inside the metadata matches the URL it fetched from. Two independent facts have to agree before credentials move.

The fallback path exists for older servers that do not publish protected-resource metadata. When that request returns 404, the SDK asks the MCP server itself for the authorization server configuration. There is no separately discovered URL on this path, so the variable holding it stays empty, and Cycode's write-up shows what the guard did with an empty value:

# Vulnerable versions: the issuer check ran only when the client had
# independently learned the authorization server URL. On the fallback
# path it never had, so auth_server_url was None and the check was skipped.
if self.context.auth_server_url is not None:
    validate_metadata_issuer(asm, self.context.auth_server_url)

A hostile MCP server does not need an exploit to reach that branch. It answers 404 to the protected-resource request, which is normal behaviour for an older server, and the client falls back on its own. The server then returns a configuration document with the authorization_endpoint pointed at the genuine provider, so the victim sees a real, trusted login page, and the token_endpoint pointed at attacker infrastructure.

The second half of the advisory is quieter and worse: stored or pre-provisioned client credentials were not bound to the authorization server they belonged to. A secret you provisioned for one provider could be presented to a different one, because nothing in the client recorded where it came from.

What the attacker walks away with

From one connection attempt, the token request delivers three things to the attacker's endpoint: the client secret, the authorization code, and the PKCE code_verifier.

  • The authorization code is single-use and useless on its own. With the matching code_verifier, it is not.
  • The PKCE verifier is the proof that was supposed to make a stolen code worthless. Handing both to the same party removes the protection entirely.
  • The client secret is the durable loss. Cycode's phrasing is the one to keep: "The authorization code is single-use, but the client secret is not. It's a long-lived credential, often with no expiration."

With the code and the verifier, the attacker posts to the legitimate token endpoint and receives a working access token carrying every scope your client holds. With the secret alone, they keep requesting new tokens afterwards, with no involvement from the victim at all. On the provider's side this is a well-formed request from a registered client. There is no failed login, no MFA prompt, no impossible-travel alert. Your detection stack sees a successful authentication because that is what happened.

Find the installs and the pins before you touch anything

Version drift is the real problem here, because the fix shipped three weeks before the disclosure. Anything that installed the SDK with a floating constraint picked up 1.30.0 or 2.2.0 on or after September 7 without anyone noticing. Anything pinned to an exact version has been sitting on a published fix it never took. Inventory before you upgrade, and inventory the lockfiles rather than the intent:

# 1. What is actually installed in a given environment
python -c "import importlib.metadata as m; print(m.version('mcp'))"

# 2. Every copy on the host, including images you build and forget
find / -type d -name 'mcp-*.dist-info' \
     -not -path '/proc/*' -not -path '/sys/*' 2>/dev/null

# 3. The pins that will re-introduce it on the next clean build
grep -rnE '(^|[^a-z])mcp(\[[a-z,]+\])?[[:space:]]*(==|>=|~=|<)' \
     --include='requirements*.txt' --include='pyproject.toml' \
     --include='uv.lock' --include='poetry.lock' .

# 4. Which provider classes you construct, which decides your remediation
grep -rnE 'OAuthClientProvider|ClientCredentialsOAuthProvider|PrivateKeyJWTOAuthProvider|RFC7523OAuthClientProvider' \
     --include='*.py' .

Step 4 matters more than it looks. The upgrade alone closes the hole for OAuthClientProvider. For the two client-credentials providers it does not.

Then ask the network whether it already happened

There is no vendor indicator list for this, so the retrospective question has to be answered from your own egress records. A token request goes to a host, and the host is the tell. Three places to look, in the order they are usually available:

  • Outbound proxy or DNS resolver logs for the hosts your agent runners resolved. Compare them against the authorization server hostnames you actually provisioned clients with. A token endpoint on the same host as the MCP server, rather than on the identity provider, is the pattern to find.
  • The provider's own client activity. Most identity providers record token grants per client id, including the source address. A grant from an address that is not your agent infrastructure is the confirmation you would otherwise be guessing at.
  • Your MCP endpoint allowlist, if you have one. If you do not, the honest finding for this review is that you cannot scope the blast radius, and that is the item to fix before the next advisory.

Set the retention window from when the affected versions entered your builds, not from September 28. The vulnerable range starts at 1.9.1, so for some teams the exposure window is months rather than weeks.

The issuer argument the upgrade will not add for you

In 1.30.0 and 2.2.0, ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider take an issuer argument that names the authorization server your credentials belong to. Leave it out and you get a deprecation warning, the flow still works, and the MCP server still gets to choose where your secret goes. The SDK says so in the warning text itself: "Without it, the MCP server decides which authorization server receives this client's credentials." It becomes mandatory in 3.0.

from mcp.client.auth.extensions import ClientCredentialsOAuthProvider

provider = ClientCredentialsOAuthProvider(
    server_url="https://api.example.com",   # the MCP server: untrusted input
    storage=my_token_storage,
    client_id=os.environ["MCP_CLIENT_ID"],
    client_secret=os.environ["MCP_CLIENT_SECRET"],
    issuer="https://auth.example.com",      # the only AS allowed to receive them
)

With issuer set, the provider builds a token request only from metadata whose issuer field matches that exact string, and raises OAuthFlowError with a mismatch message rather than proceeding. The fix for the interactive provider is smaller and worth understanding: instead of skipping validation when no authorization server URL was discovered, the client now falls back to the MCP server's own origin as the expected issuer. A server that wants your credentials has to claim to be the authorization server, which is a claim you can see.

Two more changes rode along in the same release and are worth taking while you are there. The HTTP client now restricts redirects to the origin of the endpoint being called, and servers gain AuthSettings.validate_token_resource, which makes a server accept only tokens its own verifier reports as issued for it. Turn that on if you run a server as well as a client.

Re-issue the secrets your clients were free to hand out

Upgrade, then add issuer, then rotate. The order matters, and the third step is the one teams skip. A client secret that a vulnerable client could have presented to a server outside your control is a secret you cannot prove is still private, and it does not expire on its own. Build the list from your inventory: every OAuth client whose credentials were loaded by an affected version and pointed at an MCP endpoint you do not own, then re-issue each one at the provider and confirm the old value is revoked rather than merely replaced.

Nobody has published evidence of this being exploited in the wild, and I would not run a rotation on speculation for a bug with no exploitation signal. I would run it here, because the cost is an afternoon of coordinated secret changes and the thing at risk is a credential with no expiry and every scope your agent platform holds. If you cannot answer which of your automations hold OAuth client secrets, that inventory gap is worth more of your week than this advisory is.

Do you know which of your automations hold OAuth client secrets?

We inventory the agent runtimes, MCP endpoints, and service credentials in small environments, then tell you which ones can reach production and what to rotate first. Book a session to walk through your agent stack.