Metadata-Version: 2.5
Name: fastmcp-credentials
Version: 0.2.0
Summary: Credential injection middleware for FastMCP servers — keeps secrets out of the LLM
Project-URL: Homepage, https://github.com/AStheTECH/fastmcp-credentials
Project-URL: Issues, https://github.com/AStheTECH/fastmcp-credentials/issues
Project-URL: Changelog, https://github.com/AStheTECH/fastmcp-credentials/blob/main/CHANGELOG.md
Author-email: AStheTECH <contact@asthetech.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: credentials,fastmcp,mcp,middleware,oauth,security
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: fastmcp==4.0.5
Provides-Extra: dev
Requires-Dist: asgi-lifespan>=2.1.0; extra == 'dev'
Requires-Dist: httpx>=0.27.0; extra == 'dev'
Requires-Dist: mypy>=1.0.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Description-Content-Type: text/markdown

# fastmcp-credentials

Secure credential injection middleware for [FastMCP](https://github.com/jlowin/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.

---

## How it works

1. `CredentialMiddleware` intercepts every tool call and resolves credentials via the configured backend.
2. Credentials are stored in a request-scoped `ContextVar`, so they never leak between concurrent requests.
3. Your tool calls `get_credentials()` to read them. It's a plain synchronous function, no `await`, no `ctx`.
4. After the tool returns (or raises), the `ContextVar` is always reset in a `finally` block.

The LLM only ever sees your tool's business parameters, never the credentials.

---

## Installation

```bash
pip install fastmcp-credentials
```

Requires 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.

---

## Backends

| Backend | Best for |
|---|---|
| `EnvCredentialBackend` | Local development, self-hosted single-user servers |
| `HeaderCredentialBackend` | Gateway-managed multi-user deployments |

---

## Quick start: static credentials (env vars)

Static credentials are arbitrary key/value fields loaded from environment variables. All fields are available on `cred.fields`.

```bash
# 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=xyz789
```

```python
import 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()
```

---

## Quick start: OAuth (env vars)

For OAuth tokens, set `{PREFIX}CRED_TYPE=oauth`:

```bash
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 write
```

```python
backend = 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()
```

---

## Quick start: gateway-injected credentials (hosted mode)

For multi-user deployments where a gateway decrypts, refreshes, and injects credentials as HTTP headers before forwarding requests to your MCP server:

```python
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()`.

---

## OAuth extras

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.

```bash
export MYSERVICE_CRED_TYPE=oauth
export MYSERVICE_ACCESS_TOKEN=ya29...
export MYSERVICE_EXTRA_DC=us10
export MYSERVICE_EXTRA_WORKSPACE=my-workspace
```

```python
@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.

---

## Selecting a backend based on deployment mode

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:

```python
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")])
```

```bash
# Local / self-hosted (default, no env var needed)
# FASTMCP_CREDENTIAL_MODE=oss

# Production behind a gateway
export FASTMCP_CREDENTIAL_MODE=hosted
```

---

## The `ResolvedCredential` object

`get_credentials()` always returns a `ResolvedCredential` dataclass, regardless of which backend is used:

```python
@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.

---

## Environment variable reference

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.

---

## Header reference (gateway-injected mode)

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.

---

## Running the tests

Clone the repo and install with the `dev` extras:

```bash
git clone https://github.com/AStheTECH/fastmcp-credentials.git
cd fastmcp-credentials
pip install -e ".[dev]"
```

Run the full suite:

```bash
python -m pytest
```

Run a specific file or test:

```bash
python -m pytest tests/backends/test_env.py
python -m pytest tests/backends/test_headers.py::test_parse_scopes
```

Run with verbose output:

```bash
python -m pytest -v
```

---

## License

Apache-2.0
