Secure credential injection middleware for FastMCP servers.
Keeps secrets out of the LLM. Credentials are resolved server-side and injected into tools transparently, so the agent never sees tokens, API keys, or client secrets.
CredentialMiddlewareintercepts every tool call and resolves credentials via the configured backend.- Credentials are stored in a request-scoped
ContextVar, so they never leak between concurrent requests. - Your tool calls
get_credentials()to read them. It's a plain synchronous function, noawait, noctx. - After the tool returns (or raises), the
ContextVaris always reset in afinallyblock.
The LLM only ever sees your tool's business parameters, never the credentials.
pip install fastmcp-credentialsRequires Python 3.11+ and fastmcp==4.0.5 (pinned exactly, see below).
This package targets FastMCP 4 only, built on MCP Python SDK v2 and the modern,
stateless 2026-07-28 protocol. There's no initialize handshake and no
Mcp-Session-Id. fastmcp<4.0.0 and the older handshake-era protocol are not
supported.
fastmcp is pinned to an exact version, not a range (fastmcp==4.0.5, never
>= or ~=). Every server in the MewCP fleet depends on this package, so they
all need to resolve to the same, tested FastMCP version. A range here would let
a future FastMCP release get picked up silently on the next unrelated redeploy.
To move to a newer FastMCP patch, bump this pin and cut a new release instead
of loosening it into a range.
| Backend | Best for |
|---|---|
EnvCredentialBackend |
Local development, self-hosted single-user servers |
HeaderCredentialBackend |
Gateway-managed multi-user deployments |
Static credentials are arbitrary key/value fields loaded from environment variables. All fields are available on cred.fields.
# Option 1: JSON object (recommended for multi-field providers)
export MYSERVICE_FIELDS='{"apiKey":"sk-abc123","secretKey":"xyz789"}'
# Option 2: individual FIELD_<name> vars (useful with secrets managers)
export MYSERVICE_FIELD_apiKey=sk-abc123
export MYSERVICE_FIELD_secretKey=xyz789import requests
from fastmcp import FastMCP
from fastmcp_credentials import CredentialMiddleware, EnvCredentialBackend, get_credentials
backend = EnvCredentialBackend(prefix="MYSERVICE_")
mcp = FastMCP("My Service", middleware=[CredentialMiddleware(backend, "static")])
@mcp.tool()
def search(query: str) -> list:
creds = get_credentials()
response = requests.get(
"https://api.myservice.com/search",
headers={"Authorization": f"Bearer {creds.fields['apiKey']}"},
params={"q": query},
)
return response.json()For OAuth tokens, set {PREFIX}CRED_TYPE=oauth:
export MYSERVICE_ACCESS_TOKEN=ya29...
export MYSERVICE_REFRESH_TOKEN=1//...
export MYSERVICE_CLIENT_ID=your_client_id
export MYSERVICE_CLIENT_SECRET=your_client_secret
export MYSERVICE_TOKEN_URI=https://auth.myservice.com/token
export MYSERVICE_SCOPES=read writebackend = EnvCredentialBackend(prefix="MYSERVICE_")
mcp = FastMCP("My Service", middleware=[CredentialMiddleware(backend, "oauth")])
@mcp.tool()
def list_items(folder_id: str) -> list:
creds = get_credentials()
response = requests.get(
"https://api.myservice.com/items",
headers={"Authorization": f"Bearer {creds.access_token}"},
params={"folder": folder_id},
)
return response.json()For multi-user deployments where a gateway decrypts, refreshes, and injects credentials as HTTP headers before forwarding requests to your MCP server:
import requests
from fastmcp import FastMCP
from fastmcp_credentials import CredentialMiddleware, HeaderCredentialBackend, get_credentials
backend = HeaderCredentialBackend()
mcp = FastMCP("My Service", middleware=[CredentialMiddleware(backend, "oauth")])
@mcp.tool()
def call_api(resource_id: str) -> dict:
creds = get_credentials()
return requests.get(
f"https://api.example.com/resources/{resource_id}",
headers={"Authorization": f"Bearer {creds.access_token}"},
).json()The gateway sends these headers directly. No tool parameters, no LLM involvement:
X-MCP-Cred-Access-Token: ya29...
X-MCP-Cred-Fields: {"apiKey":"sk-...","secretKey":"..."}
X-MCP-Cred-Scopes: read write
X-MCP-Cred-Extra: {"tenant_id": "..."}
X-MCP-Cred-Expires-At: 2026-05-04T12:00:00Z
Tools access credentials identically to env-based mode via get_credentials().
Some OAuth providers include non-sensitive metadata alongside the token, like a data-centre region or a workspace identifier. These are collected into cred.extra for OAuth credentials only.
Env vars: use the {PREFIX}EXTRA_{NAME} pattern.
export MYSERVICE_CRED_TYPE=oauth
export MYSERVICE_ACCESS_TOKEN=ya29...
export MYSERVICE_EXTRA_DC=us10
export MYSERVICE_EXTRA_WORKSPACE=my-workspace@mcp.tool()
def call_api() -> dict:
creds = get_credentials()
base_url = f"https://{creds.extra['dc']}.api.example.com"
return requests.get(base_url, headers={"Authorization": f"Bearer {creds.access_token}"}).json()Gateway mode: the gateway encodes extras in the X-MCP-Cred-Extra header as a JSON object.
If you need to switch backends at runtime (e.g. env vars locally, header-injected in production), use the get_mode() helper which reads the FASTMCP_CREDENTIAL_MODE environment variable:
from fastmcp_credentials import CredentialMiddleware, EnvCredentialBackend, HeaderCredentialBackend, get_mode, CredentialMode
if get_mode() == CredentialMode.HOSTED:
backend = HeaderCredentialBackend()
else:
backend = EnvCredentialBackend(prefix="MYSERVICE_")
mcp = FastMCP("My Service", middleware=[CredentialMiddleware(backend, "oauth")])# Local / self-hosted (default, no env var needed)
# FASTMCP_CREDENTIAL_MODE=oss
# Production behind a gateway
export FASTMCP_CREDENTIAL_MODE=hostedget_credentials() always returns a ResolvedCredential dataclass, regardless of which backend is used:
@dataclass
class ResolvedCredential:
type: Literal["static", "oauth"]
# Static auth: all provider fields by name
fields: dict[str, str]
# OAuth
access_token: str | None
refresh_token: str | None
client_id: str | None
client_secret: str | None
token_uri: str | None
scopes: list[str] | None
expires_at: datetime | None
# OAuth metadata only (e.g. dc, workspace). Empty for static credentials.
extra: dict[str, Any]
def is_expired(self) -> bool: ...is_expired() returns True if the access token has expired or expires within the next 60 seconds.
All variables use the prefix you pass to EnvCredentialBackend(prefix="...").
| Variable | Default | Description |
|---|---|---|
{PREFIX}FIELDS |
none | JSON object with all static fields, e.g. {"apiKey":"...","secretKey":"..."} |
{PREFIX}FIELD_{NAME} |
none | Individual static field (key name preserved as-is) → cred.fields["NAME"] |
{PREFIX}EXTRA_{NAME} |
none | OAuth metadata only → cred.extra["name"] |
{PREFIX}ACCESS_TOKEN |
none | OAuth access token |
{PREFIX}REFRESH_TOKEN |
none | OAuth refresh token |
{PREFIX}CLIENT_ID |
none | OAuth client identifier |
{PREFIX}CLIENT_SECRET |
none | OAuth client secret |
{PREFIX}TOKEN_URI |
none | Token refresh endpoint URL |
{PREFIX}SCOPES |
none | Space-separated OAuth scopes |
{PREFIX}EXPIRES_AT |
none | ISO 8601 token expiry (e.g. 2026-05-04T12:00:00+00:00) |
{PREFIX}FIELDS takes priority over individual {PREFIX}FIELD_{NAME} vars when both are set.
When using HeaderCredentialBackend, the gateway injects these headers. At least one of the first two must be present.
| Header | Required for | Description |
|---|---|---|
X-MCP-Cred-Access-Token |
"oauth" type |
OAuth access token |
X-MCP-Cred-Fields |
"static" type |
JSON object with all static credential fields |
X-MCP-Cred-Scopes |
No | Space-separated string of OAuth scopes |
X-MCP-Cred-Extra |
No | JSON object with OAuth provider metadata |
X-MCP-Cred-Expires-At |
No | Token expiry as ISO 8601 UTC timestamp |
The required header depends on the credential type configured in CredentialMiddleware. If the type-appropriate header is missing, a MissingCredentialHeaderError is raised.
Clone the repo and install with the dev extras:
git clone https://github.com/AStheTECH/fastmcp-credentials.git
cd fastmcp-credentials
pip install -e ".[dev]"Run the full suite:
python -m pytestRun a specific file or test:
python -m pytest tests/backends/test_env.py
python -m pytest tests/backends/test_headers.py::test_parse_scopesRun with verbose output:
python -m pytest -vApache-2.0