mcp-secure
by JacquesCyber
README.md
# mcp-secure
**Secure-by-default framework for building [MCP](https://modelcontextprotocol.io) servers on
Azure Entra ID — the OWASP API Security Top 10 (2023), translated into concrete controls.**
An MCP server exposes tools to LLM agents over HTTP. That makes it an API with an unusual
client, and every OWASP API risk applies — plus MCP-specific ones (token passthrough /
confused deputy, tool poisoning). `mcp_secure` wraps the official MCP SDK so a new server is
authenticated, authorized, rate-limited, audited, and hardened out of the box.
- **Auth:** validates Entra ID JWTs (signature, issuer, tenant, expiry, **audience per RFC
8707**), serves RFC 9728 Protected Resource Metadata, and speaks the MCP OAuth 2.1 flow.
- **Both identity models:** delegated user tokens (`scp`) *and* application tokens (`roles`).
- **Batteries included:** per-tool scope/role guards, strict input models, rate limiting,
body caps, security headers, DNS-rebinding protection, an SSRF guard, and a downstream
client that mints new tokens (never forwards the inbound one).
- **Tested:** an OWASP-mapped pytest suite, custom Semgrep rules, **and a live DAST probe**
exercise the guards — static *and* dynamic, in-process *and* against a real deployment.
## Architecture
The MCP server is an **OAuth 2.1 Resource Server**; Entra ID is the Authorization Server.
```
agent ──(no token)──► server ──401 + WWW-Authenticate──► agent
agent ──► GET /.well-known/oauth-protected-resource ──► { authorization_servers:[Entra], resource:<url> }
agent ──► Entra OAuth 2.1 (auth-code + PKCE, resource=<url> per RFC 8707) ──► access token
agent ──(Bearer token)──► server ──validate(sig, iss, aud, exp, scp/roles)──► tool runs
```
## Install
The **source is public**; the **package** is distributed via an internal **JFrog Artifactory**
PyPI feed rather than public PyPI (it encodes a security posture we version and govern internally).
**From a clone** (contributors / evaluating):
```bash
git clone https://github.com/JacquesCyber/mcp-secure.git
cd mcp-secure && make install # uv; installs the dev + azure extras
```
**As a dependency** (once mirrored to your JFrog feed — see [`docs/PUBLISHING.md`](docs/PUBLISHING.md)):
```bash
pip install mcp-secure --index-url https://<host>/artifactory/api/pypi/<repo>/simple
```
> **Working from a clone?** uv's editable install doesn't put `src/` on `sys.path` (a known uv
> quirk), so run the CLI through the Make wrappers — `make init`, `make doctor`, `make dev` — or
> prefix `PYTHONPATH=src`. An installed wheel exposes the bare `mcp-secure` command directly.
**New here? You don't need Entra to start.** `make dev` runs a server with local dev auth —
see [Local dev (no Entra)](#local-dev-no-entra). When you need real auth, hand your identity
team [`docs/IDENTITY-REQUEST.md`](docs/IDENTITY-REQUEST.md) and infra your Terraform module.
## Quickstart
```python
from mcp_secure import SecuritySettings, create_secure_mcp, requires, current_principal
server = create_secure_mcp(SecuritySettings.load(), name="my-mcp")
@server.tool()
@requires("mcp.tools.read") # function-level authz (API5)
def echo(text: str) -> str:
return text
@server.tool()
def whoami() -> dict: # any authenticated caller; returns their own identity
p = current_principal()
return {"subject": p.subject, "scopes": sorted(p.scopes)}
if __name__ == "__main__":
server.run() # or: uvicorn module:app (app = server.asgi_app())
```
Configuration is entirely environment-driven and **fail-closed** — the server refuses to
start if required settings are missing. Copy `.env.example` to `.env` and fill it in.
| Env var | Meaning |
|---|---|
| `MCP_TENANT_ID` | Entra tenant ID (required) |
| `MCP_AUDIENCE` | expected token audience, e.g. `api://<client-id>` (required) |
| `MCP_SERVER_URL` | canonical public URL of this server, RFC 8707 (required) |
| `MCP_REQUIRED_SCOPES` | server-wide minimum scopes (comma-separated) |
| `MCP_ALLOWED_HOSTS` | Host allow-list for DNS-rebinding protection |
| `MCP_RATE_LIMIT` | e.g. `60/minute` · `MCP_MAX_BODY_BYTES`, `MCP_REQUIRE_HTTPS`, `MCP_DEBUG` |
## Local dev (no Entra)
Build and call a real MCP **before** an Entra app registration exists — dev mode validates
locally-minted tokens instead of Entra's JWKS (same validation path; only the key source
differs). It is refused when `MCP_ENVIRONMENT=production`.
```bash
make dev # terminal 1: server with local dev auth (loud warning)
export TOKEN=$(make -s mint-token | sed 's/^Bearer //') # terminal 2: mint a dev token
python scripts/smoke_client.py http://localhost:8000/mcp "$TOKEN"
```
This decouples the dev loop from the identity-team ticket queue. When you're ready for real
auth, see [`docs/IDENTITY-REQUEST.md`](docs/IDENTITY-REQUEST.md).
## OWASP API Security Top 10 (2023) → controls
| OWASP | Control in `mcp_secure` | Where |
|---|---|---|
| **API1** Broken Object Level Authz | `current_principal()` + ownership checks in tools | `auth/scopes.py` |
| **API2** Broken Authentication | Entra JWT validation — RS256-only (no `alg:none`), rotating JWKS, iss/tid, aud, exp; 401 + `WWW-Authenticate` | `auth/entra.py`, `auth/jwks.py` |
| **API3** Broken Object Property Level Authz | **strict inputs on every tool by default** (`extra="forbid"` + `additionalProperties:false`); `StrictModel` for nested models; output caps | `server.py`, `validation.py` |
| **API4** Unrestricted Resource Consumption | rate limiting (429), body cap (413), output cap | `middleware/ratelimit.py`, `middleware/body_limit.py` |
| **API5** Broken Function Level Authz | `@requires(...)` / `require_scopes` / `require_roles`; default-deny (403) | `auth/scopes.py` |
| **API6** Unrestricted Access to Sensitive Flows | per-tool scopes + quotas; mark sensitive tools with a distinct scope | tool metadata + rate limit |
| **API7** Server-Side Request Forgery | `validate_url` / `safe_fetch` — blocks metadata/RFC1918/link-local, scheme allow-list, DNS-rebind check | `ssrf.py` |
| **API8** Security Misconfiguration | fail-closed config, TLS-required, locked CORS, security headers, no debug/stack traces, DNS-rebinding | `config.py`, `middleware/security_headers.py`, `server.py` |
| **API9** Improper Inventory Management | structured JSON audit logs + correlation ids, tool registry | `middleware/audit.py` |
| **API10** Unsafe Consumption of APIs | `DownstreamClient` — OBO / client-credentials, **never forwards the inbound token**, TLS verify, no redirects | `downstream.py` |
MCP-specific threats beyond OWASP are covered in [`SECURITY.md`](SECURITY.md).
## Per-tool authorization
```python
from mcp_secure import requires, current_principal, AuthorizationError
@server.tool()
@requires("mcp.tools.write") # function-level (API5)
def update_item(item_id: str) -> str:
principal = current_principal()
if not owns(principal.subject, item_id): # object-level (API1)
raise AuthorizationError(client_message="Not your item.")
...
```
## Calling a downstream API (safely)
```python
from mcp_secure import DownstreamClient
client = DownstreamClient(tenant_id=..., client_id=..., client_credential=...)
# Mints a NEW token via On-Behalf-Of; the inbound MCP token is never forwarded.
resp = await client.call("GET", "https://api.example.com/data",
scope="api://other/user_impersonation", on_behalf_of=inbound_token)
```
## Test that your MCP is secure
```bash
make test # OWASP-mapped pytest suite (auth bypass, audience confusion, injection, limits, SSRF, passthrough)
make scan # custom Semgrep rules on your code (fails on alg:none, verify=False, token passthrough, …)
make scan-demo # prove the rules fire against an intentionally-vulnerable fixture
make run # run the example server locally on http://localhost:8000
```
## Security evidence — live DAST findings
The pytest suite exercises each guard in isolation; these findings exercise them **dynamically
against a live deployment** — a real Entra-secured Azure Container App, hit over the wire with
real and forged tokens. This is **evidence the controls we tested hold — due diligence, not a
guarantee that no vulnerabilities exist.** Full harness + reproduce steps in
[`security/dast/`](security/dast/).

*(One-page scorecard, regenerated by [`security/dast/build_report.py`](security/dast/build_report.py) from the raw scan artifacts.)*
### 1. Auth-boundary probe — [`security/dast/live_probe.py`](security/dast/live_probe.py)
The layer generic scanners can't reach: crafted and *real* tokens fired at the running server.
| # | OWASP | Attack / check | Expected | Result |
|---|-------|----------------|----------|--------|
| 1 | API2 | no token | `401 + WWW-Auth` | ✅ PASS (401) |
| 2 | API8 | security headers | `all present` | ✅ PASS |
| 3 | API8 | no stack-trace leak | `clean body` | ✅ PASS (74-byte body) |
| 4 | API2 | garbage token | `401` | ✅ PASS |
| 5 | API2 | `alg:none` forgery | `401` | ✅ PASS |
| 6 | API2 | HS256 forgery | `401` | ✅ PASS |
| 7 | API2 | **real token, wrong audience** | `401` | ✅ PASS |
| 8 | API2 | valid token → tools | `tools listed` | ✅ PASS (9 tools) |
| 9 | API3 | extra input field | `rejected` | ✅ PASS |
| 10 | API4 | over-sized body (1.2 MiB) | `413` | ✅ PASS |
| 11 | API4 | rate limit | `429 seen` | ✅ PASS |
**The headline — row 7:** the probe sends a **real, validly-signed Microsoft token for a
different resource** (an ARM token, `aud=management.azure.com`). It is rejected `401` by the
audience binding — RFC 8707 audience-confusion prevention, proven against the live server. No
off-the-shelf scanner can demonstrate that; it needs a real token from the same tenant.
> **A gap this probe found — and we fixed.** The first run showed `echo` *accepting* an
> undeclared input field: FastMCP builds argument models with `extra="ignore"`, so strictness
> only fired when a developer opted into a `StrictModel`. We made it a secure-by-default:
> `strict_tool_inputs` now flips **every** tool to `extra="forbid"` + `additionalProperties:
> false`. Re-run → green (row 9). That DAST → fix → verify loop is exactly what the harness is
> for.
### 2. TLS / transport — testssl.sh (`security/dast/run_testssl.sh`)
- **TLS 1.2 + 1.3 only** — SSLv2/v3 and TLS 1.0/1.1 not offered · **HSTS** 730 days, `includeSubDomains`
- **Not vulnerable** to Heartbleed, ROBOT, POODLE, BEAST, CRIME, BREACH, SWEET32, FREAK, DROWN, LOGJAM, LUCKY13, RC4 …
### 3. OWASP ZAP baseline (`security/dast/run_zap.sh`)
- **66 passive checks PASS · 0 FAIL.** The one WARN is *Non-Storable Content* — caused by our
own `Cache-Control: no-store`, i.e. a control working as intended.
```bash
# reproduce (needs Docker + `az login`):
uv run python security/dast/live_probe.py --url https://<host>/mcp --api-app-id <api-app-guid>
./security/dast/run_testssl.sh <host>
./security/dast/run_zap.sh https://<host>/mcp
```
## Onboarding & ops docs
**New here? Start with the ordered runbook → [`docs/GETTING-STARTED.md`](docs/GETTING-STARTED.md)**
— the golden path in sequence, telling you exactly when to contact Identity, Infra, and Platform
(and what runs in parallel). Because those teams are usually separate, the framework ships
**request kits** so you ask each for exactly the right thing:
| Doc | For | Purpose |
|---|---|---|
| [`docs/GETTING-STARTED.md`](docs/GETTING-STARTED.md) | **You (start here)** | ordered runbook: build in dev mode → open Identity + Infra tickets in parallel → deploy → validate → connect |
| [`docs/IDENTITY-REQUEST.md`](docs/IDENTITY-REQUEST.md) | Identity team | exact Entra app-registration spec (audience, v2, scopes, roles, pre-auth) + what to hand back |
| [`infra/terraform/`](infra/terraform/) | Infra team | starter Terraform module + a "refine for VNet/private-endpoint/regulated" checklist |
| [`docs/PUBLISHING.md`](docs/PUBLISHING.md) | Platform team | publish/consume via internal **JFrog Artifactory** (not public PyPI) |
| [`docs/HOW-THIS-WAS-BUILT.md`](docs/HOW-THIS-WAS-BUILT.md) | Reviewers | the toolchain that built & proved the security |
Scaffold a new project and check your wiring with the CLI:
```bash
mcp-secure init my-mcp # scaffold a runnable project (works in dev mode immediately)
mcp-secure doctor # validate config/env; add --token <t> to check a token's claims
```
> From a **clone** of this repo (not a pip install), use the Make wrappers, which set
> `PYTHONPATH=src`: `make init DIR=my-mcp` · `make doctor` (add `TOKEN=<t>`).
## Deploy
Two equivalent options for Azure Container Apps (Managed Identity, Key Vault, Log Analytics,
HTTPS-only):
- **Terraform** (recommended if your infra team uses it) — [`infra/terraform/`](infra/terraform/),
a starter module to refine for your network.
- **Bicep** — [`infra/main.bicep`](infra/main.bicep), see [`infra/README.md`](infra/README.md).
Both take the same `MCP_*` config; pair with the Entra registration from
[`docs/IDENTITY-REQUEST.md`](docs/IDENTITY-REQUEST.md).
## Layout
```
src/mcp_secure/ framework: config, auth/, middleware/, ssrf, downstream, validation, server,
cli (init/doctor), devmode (local no-Entra auth), templates/ (init scaffold)
examples/scaffold/ plug-and-play starter you copy into your project
tests/ OWASP-mapped security suite + dev-mode + CLI tests + offline token fixtures
security/dast/ live DAST probe + ZAP/testssl wrappers (dynamic proof vs a deployment)
docs/ onboarding kits: IDENTITY-REQUEST, PUBLISHING, HOW-THIS-WAS-BUILT
.semgrep/rules.yml custom SAST rules
infra/ Terraform module + Azure Bicep + Entra app registration
```
## Development
This repo uses `uv`. Local runs set `PYTHONPATH=src` (uv's editable `.pth` is skipped by
CPython — a uv quirk; installed wheels are unaffected). The `Makefile` handles it on
macOS/Linux/WSL.
**Cross-platform:** the framework and server are pure, portable Python and run on Windows
under PowerShell. Only the dev *tooling* is bash-flavored — on Windows either use Git Bash /
WSL for `make`, or run the underlying commands directly (`az`, `uv run pytest`, etc.). The
Entra registration script ships in both flavors (`.sh` and `.ps1`); PowerShell run/deploy
snippets are in `examples/scaffold/README.md` and `infra/README.md`. In PowerShell set the
dev path with `$env:PYTHONPATH = "src"`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing