Guide · AI and GenAI
FastMCP's October 2026 Vulnerabilities Explained: Secret Leaks and Hidden Tools
19 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- The FastMCP vulnerability is really two security advisories, published on 20 October 2026. FastMCP is a popular Python framework for MCP, the open standard AI apps use to call outside tools.
- Client bug, fixed in fastmcp 4.1.2: a client holding an OAuth client secret, a password for programs, let the MCP server choose where to log in. A malicious server could collect the secret.
- The fix adds an issuer setting: the fixed name of the login server you trust. Upgrading alone is not enough. You must set issuer and rotate secrets that may have leaked.
- Server bug, fixed in 4.1.2 and 3.4.10: a transform, a feature that edits a tool's details such as its labels, could make a hidden tool still callable by name. Patched versions recheck access on every call.
- Beginners can stop after the section on what to do now. Engineers then get two runnable checks, our local test of the issuer check refusing a fake login server, and why the warning alone does not prove you are patched.
What goes wrong when an MCP client asks the server where to log in?
The server can lie, and your client hands its secret to whoever the server names. That is the heart of the FastMCP vulnerability disclosed on 20 October 2026, plus a second bug about skipped access checks.
Picture a common setup. A payments business runs an internal agent platform. Product teams register MCP server URLs in a config file. A nightly job connects to each one with a single OAuth client ID and secret, a username and password meant for programs. The setup is made up, but both bugs are real.
The same platform also runs its own FastMCP server with finance tools. One tool, export_invoices, should only work for callers with an admin permission. A reader-level caller should never see it or run it.
Two things could go wrong in that setup. On the client side, one bad URL in the config could leak the shared secret. On the server side, a reader who knew the tool's name could run export_invoices anyway.
Which words do you need before reading the fix?
You need six terms to follow the story. Other terms are explained where they first appear.
- OAuth: the standard way one program gets permission to call another. It hands out short-lived access tokens, so the real password is not sent on every request.
- Login server: the server that checks who a program is and hands out access tokens. The standards call it an authorization server; this guide always says login server.
- Client secret: a password for programs. With machine-to-machine OAuth, the program sends its client ID and secret to the login server and gets a token back.
- Issuer: the official name of a login server, written as a URL like https://auth.example.com. A login server publishes it in its metadata document.
- Discovery: the client reading small JSON documents to learn which login server to use and its token endpoint, the URL where the secret is sent.
- Transform: a FastMCP feature that changes a tool on its way out, such as renaming it or changing its tags, which are labels like admin or public.
What is the FastMCP vulnerability disclosed in October 2026?
It is two separate advisories from the FastMCP maintainers, both rated Moderate and both fixed the same day. The table puts them side by side before we explain each one.
| Question | GHSA-vqqp-x5qm-5qp4 | GHSA-7xgj-8392-5q4r |
|---|---|---|
| Which side is hit? | MCP clients that use machine-to-machine OAuth | MCP servers that use per-tool access rules and transforms |
| What went wrong? | The client sent its secret to whichever login server the MCP server named | A direct call skipped the access check that the tool list applied |
| Affected versions | fastmcp and fastmcp-slim 4.0.0a1 up to 4.1.1 | 3.0.0 up to 3.4.9, and 4.0.0 up to 4.1.1 |
| Fixed in | 4.1.2 | 3.4.10 and 4.1.2 |
| Severity | Moderate, CVSS 5.9 | Moderate, CVSS 6.8 |
| What you must do | Upgrade, set issuer, rotate secrets | Upgrade; until then, check access inside the tool |
FastMCP is not the official MCP Python SDK. The official SDK is the package called mcp, and FastMCP builds on top of it. The official SDK had its own related advisory in September 2026, which is a separate story.
How did a malicious MCP server get the client secret?
It simply named its own login server during discovery, and the client trusted that answer. Nothing in FastMCP 4.0.0a1 through 4.1.1 compared the named server with a value the developer had chosen.
Here is the discovery chain MCP clients follow, based on RFC 9728. The client calls the MCP server and gets a 401 reply, which means log in first. The reply points to a resource metadata document, or the client looks for it at a fixed, well-known path on the same server. That document lists which login servers to use.
The client then fetches the chosen login server's own metadata. That gives the token endpoint, and the client sends its secret there. Every link in that chain started from the MCP server you were connecting to.
Where the secret goes during discovery
Drag to exploreNotice the third box: the MCP server picks the login server. Before 4.1.2 nothing compared that pick with a value you set, so the secret followed the server's choice.
Diagram connections:
- MCP request to 401 with metadata link
- 401 with metadata link to Resource metadata
- Resource metadata to Login server metadata
- Login server metadata to Issuer matches config?
- Issuer matches config? to Flow stops (No)
- Issuer matches config? to Token request with secret (Yes, or no issuer set)
- MCP request: Client → MCP server
- 401 with metadata link: MCP server → Client
- Resource metadata: MCP server → Client: names a login server
- Login server metadata: Named login server → Client: issuer, token URL
- Issuer matches config?: Runs only when issuer is set (4.1.2 and later)
- Flow stops: Issuer mismatch error, no secret sent
- Token request with secret: Client → token endpoint
Think of a courier carrying cash for a shop. The courier asks the shop counter which bank to deposit at. A fake counter can say: give it to my cousin. The courier needs the bank's name written on the job sheet before leaving.
In that picture, the courier is your FastMCP client and the cash is the client secret. The shop counter is the MCP server, the bank is your real login server, and the job sheet is the issuer setting.
Where the picture breaks: a real courier would hand over the cash once. A client secret can be copied and reused until you rotate it, so one leak lasts until you act.
Why the existing metadata checks did not stop it
The official SDK underneath already checked that a login server's metadata names itself correctly, and version 2.2.0 extended that check to every discovery path. RFC 8414, the standard for that metadata, requires the issuer to be identical to the server you asked.
An attacker's login server passes that check easily, because it describes itself honestly. The check proves the documents agree with each other, not that you trust the server they describe.
RFC 9728, the standard for resource metadata, says choosing a trustworthy login server is outside its scope. It assumes the client has its own way to decide. The issuer setting is that way.
This is a cousin of what OAuth security guidance, RFC 9700, calls a mix-up attack. RFC 9700 describes it for codes and tokens in browser logins. Here the same confusion hits a client secret.
How does the issuer setting in fastmcp 4.1.2 stop it?
You now tell the client which login server issued your credentials, and the client refuses to build a token request for any other. The check runs inside the official SDK, which FastMCP 4.1.2 now requires at version 2.2.0 or later.
FastMCP's ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider gained one new argument, issuer. FastMCP passes it straight to the SDK classes it wraps. The fix itself is short, as the 4.1.2 source diff shows.
- If the MCP server advertises several login servers, the client prefers the one matching your issuer.
- Before sending any secret, the client checks that the discovered metadata names your issuer exactly. If not, it stops with an OAuthFlowError, which FastMCP's Client surfaces as a RuntimeError.
- Cached tokens, saved copies of earlier access tokens, are now stored per issuer. Changing issuer makes the client fetch a fresh token.
- Refresh tokens, longer-lived tokens used to get new access tokens, are dropped when loaded from storage. So an expired token sends the client back through discovery and the issuer check.
- An issuer that is not an http or https URL is rejected with a ValueError when the provider is attached to a server URL.
Private key JWT is a variant where the program proves itself with a signed message, called an assertion, instead of a secret. The advisory adds one nuance for it. Your signing key never leaves your machine. A signed assertion could. Whether someone can reuse it depends on how your real login server checks its audience, the field naming who the assertion is for.
Why could a hidden tool still be called in FastMCP?
Because listing tools and calling a tool took different code paths, and only listing checked the final version of the tool. For tools and prompts, the bug needed both a per-tool access rule and a transform that changed what that rule reads. Resources had a second path, covered below, that needed no transform.
FastMCP lets you attach an access rule to a tool with its auth argument. One built-in rule, restrict_tag, says: if this tool carries a given tag, the caller's token must have certain scopes. Scopes are permission labels inside the token.
In our running setup, export_invoices starts with the tag public. A server transform then adds the tag admin. The rule restrict_tag for admin should now demand the admin scope.
The naive mental model says: if a caller cannot see a tool in the list, they cannot use it. That model is wrong, because the protocol lets any client send tools/call with a tool name it guessed or learned elsewhere.
Two paths to one tool
Drag to exploreBoth requests reach the same transformed tool. Listing always checked it after the transform. Calls by name checked only the original tool until 4.1.2 and 3.4.10.
Diagram connections:
- List tools to Original tool, tag public
- Call tool by name to Original tool, tag public
- Original tool, tag public to Transform adds admin tag
- Transform adds admin tag to Rule rechecked after?
- Rule rechecked after? to Hidden or refused (Yes: lists, patched calls)
- Rule rechecked after? to Tool runs for reader (No: calls on old versions)
- List tools: Client → server: tools/list
- Call tool by name: Client → server: tools/call
- Original tool, tag public: A rule checked here sees public and passes
- Transform adds admin tag: Runs on both paths
- Rule rechecked after?: Does restrict_tag see the admin tag?
- Hidden or refused: Reader token lacks the admin scope
- Tool runs for reader: Direct calls on affected versions
The advisory says the attacker needed a valid low-privilege token and the tool's final name. They did not need access to the server's code or config. The same gap existed for resources, resource templates and prompts.
The fix runs the access rule again on the transformed tool, resource, template or prompt. It also closes a side door. If a caller is denied a resource, a resource template matching the same address, such as a proxy's, can no longer fetch it for them.
Which FastMCP versions are affected and who needs to act?
Anyone on FastMCP 4.x before 4.1.2 needs to act on both bugs. Anyone on 3.x before 3.4.10 needs to act on the access-check bug only.
- The OAuth bug exists only in 4.x, because the two machine-to-machine providers were added in 4.0.0a1. On 3.4.10, importing ClientCredentialsOAuthProvider from fastmcp.client.auth fails with an ImportError.
- The OAuth bug matters most when MCP server URLs come from users, tenants or other teams. If you connect only to servers you control, the risk is smaller but rotation is still wise.
- The access-check bug needs a per-tool auth rule plus either a transform that changes what the rule reads, such as tags, or a protected resource that overlaps a proxy template.
- Both packages are affected: fastmcp and fastmcp-slim. Installing fastmcp pulls in fastmcp-slim of the same version.
- Servers reached over STDIO, a local pipe between two processes, skip FastMCP's component auth checks entirely by design. So the access-check bug only matters on HTTP transports, where the checks run.
What should you do now about the FastMCP advisories?
Upgrade, set issuer on every machine-to-machine provider, and rotate any secret that may have reached an untrusted server. The steps below are in the order to do them.
Upgrade FastMCP
Move 4.x to 4.1.2 or later and 3.x to 3.4.10 or later. As of 25 October 2026 the newest are 4.1.3 and 3.4.11.
Set issuer on every provider
Pass issuer to each ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider. Use the exact issuer string from your login server's metadata document.
Rotate exposed secrets
If a client ever connected to an MCP server you do not control, rotate its client secret. Revoke its tokens at the login server too.
Review private key JWT setups
Ask whether your login server accepts assertions meant for a different audience. If it might, treat past assertions as possibly reusable.
Move access checks into sensitive tools
Until every server is upgraded, check the caller's scopes inside sensitive tools too. Avoid overlapping proxy templates for protected resources.
Run the checks below
Confirm the version, the missing warning and a refused direct call. Then add these checks to your test suite.
How do you check that the FastMCP fix is working?
Run two small checks, the issuer check script and a hidden-tool call, then compare with our local test against a fake login server. We ran each one on 25 October 2026 with Python 3.13 and pinned versions.
Check the version and the issuer setting
Save this as check_issuer.py and run it with python check_issuer.py. It turns warnings into errors, so a missing issuer cannot slip past.
import warnings
import fastmcp
from fastmcp import Client
from fastmcp.client.auth import ClientCredentialsOAuthProvider
warnings.simplefilter("error")
print("fastmcp", fastmcp.__version__)
auth = ClientCredentialsOAuthProvider(
client_id="my-client-id",
client_secret="my-client-secret",
issuer="https://auth.example.com",
)
Client("https://mcp.example.com/mcp", auth=auth)
print("no warning raised")On fastmcp 4.1.2 it prints two lines: fastmcp 4.1.2, then no warning raised. On 4.1.1 it fails with TypeError, unexpected keyword argument issuer, which tells you to upgrade.
Now delete the issuer line and run it again on 4.1.2. It stops with an MCPDeprecationWarning saying that omitting issuer is deprecated. The warning appears when the provider is attached to a server URL, not when it is first created.
Do not treat the warning as proof you are patched
The deprecation warning comes from the official SDK, not from FastMCP. As of 25 October 2026, a fresh install of fastmcp 4.1.1 pulls in mcp 2.3.0. That pair also prints the warning, even though 4.1.1 cannot accept issuer at all.
With fastmcp 4.1.1 and mcp 2.1.0, the warning does not appear. Check fastmcp's own version and whether issuer is accepted, not the warning. This is undocumented as of 25 October 2026; pin your version.
The warning text also says issuer will be required in 3.0. That means version 3.0 of the official SDK, not FastMCP, which is already on 4.x.
Watch the issuer check refuse a fake login server
We wrote a tiny fake MCP server on 127.0.0.1 that names itself as the login server. A FastMCP 4.1.2 client then connected with a dummy secret. Pause and predict: with the trusted issuer set, does the client fetch anything from the fake server at all? We never ran this test against an affected version.
| Issuer configured | What the client fetched | Token requests sent | Result |
|---|---|---|---|
| https://auth.example.com (the trusted server) | The 401 reply, resource metadata and the fake login server's metadata | 0 | Client failed to connect: Authorization server metadata issuer mismatch |
| The fake server's own address (control) | The same three, then the token endpoint | 1 | Token exchange failed (400), because the fake server rejects everything |
What just happened? With the trusted issuer, the client still fetched the fake documents but never sent the secret. The control row proves the check is not simply blocking every login. The older self-check and the new issuer check raise the same mismatch text, so compare the two URLs in the message.
Check that hidden tools stay hidden on direct calls
This script starts a local FastMCP server with two dummy tokens, then lists and calls export_invoices as each caller. Run it with python check_hidden_tool.py 2>/dev/null to hide a harmless shutdown traceback. On Windows cmd, use 2>nul instead. If port 8765 is busy, change it in both places.
import asyncio
import fastmcp
from fastmcp import Client, FastMCP
from fastmcp.client.auth import BearerAuth
from fastmcp.server.auth import restrict_tag
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
from fastmcp.server.transforms import ToolTransform
from fastmcp.tools.tool_transform import ToolTransformConfig
verifier = StaticTokenVerifier(tokens={
"reader-token": {"client_id": "reader", "scopes": ["read"]},
"admin-token": {"client_id": "admin", "scopes": ["read", "admin"]},
})
mcp = FastMCP("demo", auth=verifier)
@mcp.tool(tags={"public"}, auth=restrict_tag("admin", scopes=["admin"]))
def export_invoices() -> str:
return "INVOICE DATA"
mcp.add_transform(ToolTransform({"export_invoices": ToolTransformConfig(tags={"admin"})}))
async def main():
server = asyncio.create_task(mcp.run_http_async(host="127.0.0.1", port=8765, show_banner=False, log_level="error"))
await asyncio.sleep(2)
print("fastmcp", fastmcp.__version__)
for token in ("reader-token", "admin-token"):
async with Client("http://127.0.0.1:8765/mcp", auth=BearerAuth(token)) as c:
listed = [t.name for t in await c.list_tools()]
try:
result = (await c.call_tool("export_invoices", {})).content[0].text
except Exception as e:
result = f"refused ({e})"
print(token, "| listed:", listed, "| direct call:", result)
server.cancel()
await asyncio.gather(server, return_exceptions=True)
asyncio.run(main())On 4.1.2 and on 3.4.10 the reader line shows listed: [] and direct call: refused (Unknown tool: 'export_invoices'). The admin line shows listed: ['export_invoices'] and direct call: INVOICE DATA. On affected versions, the advisory says the reader's direct call runs the tool, even though the list hides it.
A refused call says Unknown tool, not access denied. FastMCP's docs say component auth hides a denied component and returns not-found on direct access. So a test that expects a permission error will fail even on patched versions.
What does the FastMCP fix not cover?
Issuer binding decides where your secret goes; it does not make an untrusted MCP server safe to use. Whatever you send that server in normal tool calls, it still sees.
- Without issuer, version 4.1.2 still sends credentials to whichever login server discovery returns, plus a warning. The SDK docstring says so.
- Access tokens already cached for the same issuer stay usable until they expire, according to the repository advisory.
- Secrets that leaked before you upgraded are still leaked. Only rotation fixes that.
- The access-check fix covers component-level auth rules after transforms. If you wrote custom routing around FastMCP, test your own direct paths as well.
How do you explain the FastMCP vulnerability in a system design interview?
Frame it as two trust-boundary lessons: where secrets are allowed to go, and where access is checked. Use these steps as your answer outline.
Name the boundary
An MCP client holds a reusable secret and connects to servers it may not control. The server must not choose where that secret goes.
Show the discovery chain
401 reply, resource metadata, login server metadata, token endpoint. Point out that every hop came from the untrusted server.
Bind the secret to an issuer
Configure the trusted issuer per credential. Refuse to build a token request unless the discovered metadata names that issuer exactly.
Check access on every path
Run authorization on the final object each request uses, after any transform. Hiding something from a list is not access control.
Plan the clean-up
Upgrade, rotate secrets, revoke tokens, and add tests that call hidden tools directly. Mention that the denial looks like not-found.
Questions people ask
- Is the FastMCP vulnerability the same as the MCP Python SDK vulnerability?
- No, they are separate advisories in separate packages. The official SDK, the package called mcp, fixed a related issuer problem in versions 2.2.0 and 1.30.0 in September 2026. FastMCP 4.1.2 requires that SDK fix and adds an issuer argument to its own wrapper classes. You need the FastMCP upgrade and the issuer setting.
- Does upgrading to fastmcp 4.1.2 fix the OAuth problem by itself?
- No, the advisory and release notes both say upgrading alone is not enough. Without issuer, the client still sends credentials to whichever login server discovery returns. You must pass issuer to every ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider, then rotate any secret that may have reached an untrusted server.
- Am I affected if I use FastMCP 3.x?
- Only by the access-check bug, GHSA-7xgj-8392-5q4r, which is fixed in 3.4.10. The OAuth bug needs the machine-to-machine providers, which first appeared in 4.0.0a1. FastMCP 3.4.10 has no such wrappers to configure. If your 3.x client uses the official SDK's own providers directly, follow the SDK advisory: mcp 1.30.0 or later, and set issuer.
- What value should I use for issuer?
- Use the exact issuer string your login server publishes in its metadata document, such as https://auth.example.com. The check compares strings exactly, apart from a trailing slash on a bare host. It must be an http or https URL, or the provider raises a ValueError once it is attached to a server URL.
- Do interactive OAuth logins in FastMCP have the same problem?
- This advisory names only the two machine-to-machine providers, ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider. FastMCP's interactive OAuth class is built on the official SDK's provider, whose own September 2026 advisory covered issuer checks there. Keep the mcp package at 2.2.0 or later, which fastmcp 4.1.2 already requires, or 1.30.0 or later on FastMCP 3.x.
- Why does a denied tool call say Unknown tool instead of access denied?
- FastMCP hides components that fail their own auth rule. Its docs say a denied component is hidden from lists and direct access returns not-found. That avoids telling a caller that a protected tool exists. Your tests should expect a not-found style error, not a permission error.
Sources and further reading
- GitHub Advisory Database: GHSA-vqqp-x5qm-5qp4, FastMCP OAuth providers without issuer binding
- GitHub Advisory Database: GHSA-7xgj-8392-5q4r, post-transform authorization skipped on direct calls
- FastMCP repository advisory GHSA-vqqp-x5qm-5qp4 (required actions and example)
- FastMCP repository advisory GHSA-7xgj-8392-5q4r (attacker prerequisites and workarounds)
- FastMCP pull request 5933: component authorization and issuer binding (main, 4.1.2)
- FastMCP pull request 5934: 3.x backport of the authorization fix (3.4.10)
- FastMCP v4.1.2 release notes
- FastMCP v3.4.10 release notes
- FastMCP docs: client credentials and the issuer parameter
- FastMCP docs: server authorization, restrict_tag and STDIO behaviour
- fastmcp release history on PyPI
- MCP Python SDK advisory GHSA-qx49-fqc8-xw99 (related, separate package)
- RFC 9728: OAuth 2.0 Protected Resource Metadata (section 7.6)
- RFC 8414: OAuth 2.0 Authorization Server Metadata (section 3.3)
- RFC 9700: Best Current Practice for OAuth 2.0 Security (section 4.4, mix-up attacks)