I Audited the MCP SDK OAuth Fix: 3 Checks Upgrading Misses
Last week the official MCP Python SDK shipped a fix for a credential theft bug, and the fix itself has a trap that a version check will not catch. I was researching the disclosure for my own MCP servers and the remediation notes stopped me cold: patch the package, and part of the vulnerability survives unless you also change how you construct your OAuth providers. Here is the short version. On September 28, a GitHub advisory landed on the modelcontextprotocol python-sdk repo: GHSA-qx49-fqc8-xw99, titled "OAuth client could send credentials to an authorization server chosen by the MCP server." A malicious MCP server can steer the SDK's OAuth discovery so that the client_secret , the authorization code, and the PKCE code_verifier all get delivered to an attacker's token endpoint. No CVE, no known exploitation in the wild, CVSS 3.1 7.5. The patched releases, 1.30.0 and 2.2.0, have been on PyPI since September 7. The trap is in the advisory's remediation paragraph, not the version bump. This post is the audit I ran against my own config, the three checks I'd now run on any MCP client, and one argument I expect readers to push back on. Why doesn't upgrading actually fix it? Start with what the advisory says verbatim about the two providers that matter for unattended workloads: # VULNERABLE even on mcp==1.30.0 or 2.2.0 from mcp.client.auth import ClientCredentialsOAuthProvider provider = ClientCredentialsOAuthProvider( client_id="my-client", client_secret="s3cret", # no issuer argument: issuer validation never binds ) If you use ClientCredentialsOAuthProvider or PrivateKeyJWTOAuthProvider , upgrading "changes nothing until you also pass issuer= ". I had to read that three times. The patched SDK knows issuer validation exists, but for these providers it is opt-in. Omitting it emits a DeprecationWarning , and on the 1.x patch line that warning class is hidden by default in Python. Your audit tool sees mcp>=1.30.0 , passes, and the credential-theft path stays open. The fix on my side looked like this: provider = ClientCredentialsOAuthProvider( client_id="my-client", client_secret="s3cret", issuer="https://auth.example.com", # binds registration to this issuer ) Two more remediation steps from the advisory that are easy to skip: a registration stored without an issuer stays unbound, so stored OAuth client information needs to be cleared once so the client re-registers. And if such a client may have connected to an untrusted MCP server before the fix, the client secret should be rotated and its tokens revoked at the authorization server. How does the bug actually steal credentials? The mechanics are worth understanding because the failure is not a memory-safety bug or an injection. It is a missing identity check. During OAuth discovery, the SDK asks the MCP server where its authorization server lives. If that lookup returns a 404, the SDK falls back to an older discovery method, and on the affected versions the fallback never validated that the issuer it ended up talking to matched the issuer it expected. Cycode's write-up puts it bluntly: the check doesn't fail, it never runs. So a malicious server advertises its own token endpoint, the client hands over the proof key and secrets meant for the real one, and everything looks normal. CWE classifies it as CWE-345 (insufficient verification of data authenticity) plus CWE-522 (insufficiently protected credentials). The nuance most summaries get wrong The legacy 404 fallback is not the whole defect. Per the advisory, the 1.x line (1.9.1 through 1.29.1) lacked issuer validation on every path, and the 2.x line (2.0.0a1 through 2.1.1) also lacked it on the 403 step-up path and on credential binding. If you write "the fallback was the bug," you understate the affected surface. What should an audit check besides the version? Three checks, none of which a plain pip-audit run gives you. I wrote them as a quick script: # mcp_oauth_audit.py: three checks beyond the version bump import subprocess, sys def check_version(): r = subprocess.run([sys.executable, "-m", "pip", "show", "mcp"], capture_output=True, text=True) for line in r.stdout.splitlines(): if line.startswith("Version:"): ver = line.split()[1] print(f"[1] mcp version: {ver} (fix needs >=1.30.0 or >=2.2.0)") return ver print("[1] mcp not installed") def check_provider_issuer(): # [2] grep your own config for provider construction without issuer= r = subprocess.run( ["grep", "-rnE", r"(ClientCredentialsOAuthProvider|PrivateKeyJWTOAuthProvider)(", "."], capture_output=True, text=True) hits = [l for l in r.stdout.splitlines() if "issuer" not in l] print(f"[2] provider constructions missing issuer=: {len(hits)}") for h in hits: print(" ", h) def check_warning_visibility(): # [3] DeprecationWarning is hidden by default; prove you can see it r = subprocess.run([sys.executable, "-W", "error::DeprecationWarning", "-c", "import warnings; warnings.warn('x', DeprecationWarning)"], capture_output=True, text=True) print("[3] -W error::DeprecationWarning exits", r.returncode, "(nonzero = warnings visible in CI)") check_version() check_provider_issuer() check_warning_visibility() Honest hedge: I have run the version check and the grep against my own repos, but I have not yet re-registered a stored OAuth registration end to end after adding issuer= , so treat the stored-registration cleanup as advisory-guided, not lab-verified by me. Why does this keep happening to MCP? Because the same shape keeps recurring: a trust decision delegated to whoever the server points at. This is the third recent MCP trust-boundary story I've dug into, and they rhyme. The MCP 2026-07-28 spec going stateless turned a session handle into a string in the model's context, which means a planted prompt can act as a credential. A read-only Postgres MCP server let one SQL keyword (COPY ... TO PROGRAM ) punch past its app-layer enforcement. And the Cursor allowlist bypass started with a file named curl that satisfied an allowlist check without ever being the real tool. In each case, the enforcement lived in a convenience layer that assumed good faith somewhere it shouldn't. The SDK advisory is the auth version of the same pattern: discovery data from the server was trusted to choose where secrets go. The ecosystem numbers back up that this is structural, not a one-off. Bloomberry found 38.7% of public MCP servers run with no authentication at all, an arXiv measurement study put unauthenticated live servers at 40.55%, and an Astrix audit of 5,205 repos found only 8.5% using OAuth while 53% rely on static API keys. Against that baseline, a client-side bug that leaks OAuth credentials is the fix for a problem most deployments never got to have. Can the auth gap actually close? Forkast counted five MCP authentication mechanisms shipping between mid-September and September 30: Okta Agent SSO, SSOJet, Rubrik MCP, GitHub Copilot's MCP customizer, and Operant AI's gateway. None interoperate. Meanwhile an NSA information sheet from May noted that MCP lacks support for exchanging RBAC permissions at instantiation, so even perfect identity answers only "who is connecting," never "what may it do." I'm skeptical that five vendor-specific auth paths converge on their own. Standards converge when the alternative is worse, and right now the alternative, hand-rolled static keys, is merely bad rather than catastrophic. The part I'd argue about Here's my debate hook, and I genuinely don't know where the line sits: if a patched package leaves the vulnerability exploitable unless the developer also passes a keyword argument, did the project ship a fix or a migration? The advisory is honest about it, the deprecation policy gives until 3.0, and there are good compatibility reasons. But from an operator's seat, pip install -U passing a security audit while the flaw persists feels like a category error. I'd rather the unattended providers hard-fail without an issuer and force the one-line change. You may think that's gatekeeping that breaks every deployed client overnight. Both of us have a point; tell me which failure you'd rather own at 3 a.m. What will I do before trusting an MCP client again? - Pin mcp>=2.2.0 (or>=1.30.0 on the 1.x line) and add the issuer grep to CI so provider construction withoutissuer= fails the build. - Treat DeprecationWarning as visible:-W error::DeprecationWarning in test runs, since Python hides it by default and that hiding is exactly what makes this fix silent. - Rotate client secrets and revoke tokens for any MCP client that may have done OAuth against a server I don't fully trust before the patch window. - Read the remediation section of advisories, not just the affected-ranges table. The version table said I was safe. The remediation section said otherwise. If you run MCP servers in production, run the three checks above this week. And if you think hard-failing on a missing issuer= is the wrong call, the comments are open: convince me. Top comments (0)
Comments
No comments yet. Start the discussion.