Skip to main content
Glama
JacquesCyber

mcp-secure

by JacquesCyber

mcp-secure

Secure-by-default framework for building MCP 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

Related MCP server: Golf

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):

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):

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). When you need real auth, hand your identity team docs/IDENTITY-REQUEST.md and infra your Terraform module.

Quickstart

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.

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.

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.

Per-tool authorization

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)

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

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

mcp_secure live DAST security summary

(One-page scorecard, regenerated by security/dast/build_report.py from the raw scan artifacts.)

1. Auth-boundary probe — 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.

# 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 — 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

You (start here)

ordered runbook: build in dev mode → open Identity + Infra tickets in parallel → deploy → validate → connect

docs/IDENTITY-REQUEST.md

Identity team

exact Entra app-registration spec (audience, v2, scopes, roles, pre-auth) + what to hand back

infra/terraform/

Infra team

starter Terraform module + a "refine for VNet/private-endpoint/regulated" checklist

docs/PUBLISHING.md

Platform team

publish/consume via internal JFrog Artifactory (not public PyPI)

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:

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):

Both take the same MCP_* config; pair with the Entra registration from 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".

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/JacquesCyber/mcp-secure'

If you have feedback or need assistance with the MCP directory API, please join our Discord server