mcp-doorman
Secure-by-default FastAPI→MCP bridge. FastMCP gives you the tools; this gives you the seatbelts — deny-by-default exposure, scope→tool authorization that fails closed (even on STDIO), built-in rate limiting, PII redaction at the source, and a structured audit log. In-process. No gateway.
pip install mcp-doorman # core (dependency-light)
pip install "mcp-doorman[mcp]" # + the official MCP transport
Status: beta (
0.1.0). The five security guarantees are implemented and covered by an offline test suite. The MCP wire transport is built on the officialmcpSDK and is evolving toward the 2026-07-28 spec revision — see Known limits.
Why
Turning a FastAPI app into an MCP server is already a commodity. fastapi_mcp and
FastMCP.from_fastapi do it in five minutes — and ship security as opt-in
primitives you assemble yourself, or omit entirely. The result, in the wild:
- An open RFC-8707 token-confusion advisory (GHSA-5h2m-4q8j-pqpj) — a token minted for one MCP server can be replayed against another.
- A documented ~$400 runaway from an uncontrolled tool-call loop — no rate or cost guard.
- No per-tool RBAC, no output sanitization, no audit trail out of the box.
- FastMCP's own docs: "for bootstrapping and prototyping, not for mirroring your API."
Everything that does ship the full governance stack — scopes, rate limits, redaction, audit — is a heavyweight network gateway (Kong, Higress, TrueFoundry): an extra hop and extra infrastructure to stand up and operate. Overkill for one service exposing eight endpoints.
mcp-doorman is the missing middle: a single pip install that runs in-process and
makes the secure configuration the default. Insecure-by-omission is impossible.
The five guarantees
- Deny-by-default exposure. A route is never a tool unless you
@exposeit. Destructive methods (POST/PUT/PATCH/DELETE) refuse to publish without an explicitdestructive=True, which auto-emits the MCPdestructiveHintannotation. - Scope→tool authorization, fail-closed — including STDIO. A scoped tool is denied
unless the caller presents a verified principal with sufficient scopes. When there's
no verified context (no token, or STDIO where other bridges silently return
Noneand bypass every check), doorman denies. - Rate limiting + cost budget. Per-caller and per-tool token buckets, in-process, no
Redis — the direct answer to the runaway-loop failure mode. Tighten one expensive tool
with
@expose(rate=…), and cap total spend with@expose(cost=…)plus a budget. Both fail closed. - PII redaction at the source. Sensitive keys (
*_token,email, …) and value patterns (email/phone/SSN/card) are redacted from tool results before they reach the model, and from anything the audit layer would record. - Structured audit out of the box. One OTel-friendly record per call: caller, tool, argument shape (container types/lengths/counts — never values, never caller-controlled keys), status, latency.
60-second quickstart
from fastapi import FastAPI
from mcp_doorman import Doorman, expose
app = FastAPI()
# Deny-by-default: nothing is a tool unless decorated.
@expose(name="get_invoice", scopes=["invoices:read"])
@app.get("/invoices/{id}")
async def get_invoice(id: str):
return {"id": id, "email": "alice@example.com"} # email auto-redacted in the result
@expose(name="refund", scopes=["invoices:write"], destructive=True, redact=["amount"])
@app.post("/invoices/{id}/refund")
async def refund(id: str, amount: float):
return {"id": id, "refunded": amount}
# One secure mount: Streamable HTTP, RFC 9728 metadata, audience-bound tokens,
# rate limits, redaction, and audit — all on by default.
Doorman(
app,
auth=Doorman.oauth(
issuer="https://sso.example.com",
resource="https://api.example.com/mcp", # RFC 8707 audience binding
),
rate_limit="60/min per_caller; 10/min per_tool",
redact=["email", "phone", "ssn", "*_token"],
audit="stderr",
).mount("/mcp")
Prototyping locally? Doorman.dev(app) loosens auth for localhost so the secure default
never blocks first-run DX (it prints a warning and must never be used in production).
Lint what you're about to expose, in CI:
mcp-doorman lint myapp.main:app # lists tools, scopes, destructive flags; exits non-zero on warnings
mcp-doorman doctor # settings + whether the [mcp] extra is installed
Rate limits and budgets
A rate limit answers how often. A budget answers how much. The runaway-loop failure mode needs both: a loop can stay comfortably under 60 calls a minute and still spend a fortune if each call hits a paid API.
Per-tool rate override. Tighten one tool without loosening the rest:
@expose(scopes=["reports:read"], rate="5/min per_caller")
async def generate_report(...): ...
Enforced in addition to the Doorman-wide limit, never instead of it. A malformed spec raises at decoration time, so it fails on import rather than on the first call.
Cost budget. Declare what a tool is worth and cap the total:
@expose(scopes=["ai:write"], cost=25) # this one hits a paid API
async def summarize(...): ...
@expose(scopes=["invoices:read"]) # cost defaults to 1
async def get_invoice(...): ...
doorman = Doorman(app, auth=..., budget="500/day per_caller")
summarize now costs 25 units of that 500; twenty calls exhaust the day. The unit is
whatever you decide — cents, credits, API calls, seconds. There is no default budget:
a cost unit means nothing until a deployment defines one, and a guessed default would be
security theatre.
Exceeding it raises BudgetExceeded and audits status="budget_exceeded", distinct from
rate_limited so you can tell calling too fast from calling too expensively — the
first is fixed by backing off, the second usually isn't. It subclasses RateLimited, so
existing handlers keep working.
Ordering matters and is tested: the budget is checked after the rate limit, so a call refused for being too frequent keeps its allowance. And rules are probed before any are committed, so a rejected call never burns tokens from the limits that would have passed.
doorman.remaining_budget(tool="summarize", caller="user-1") # 475.0
Every audit record carries the call's declared cost, so spend is reconstructable from
the log without a second system.
In-process, like everything else here. Buckets live in one process's memory; two replicas each enforce the budget independently. That is the same trade-off the rate limiter already makes — no Redis, no gateway — and the reason this is a guard rail against a runaway loop, not a billing system.
How it compares
fastapi_mcp |
FastMCP.from_fastapi |
mcp-doorman | |
|---|---|---|---|
| Exposure default | expose-all (opt-out) | expose-all (RouteMap) | deny-by-default, destructive gated |
| Per-tool scopes | by hand in each handler | require_scopes() primitive, bypassed on STDIO |
enforced at the library layer, fail-closed on STDIO |
| Multi-tenant token safety (RFC 8707) | open advisory, replayable | DIY audience checks | audience binding by default |
| Rate / loop & cost guard | none (documented $400) | none ("build it yourself") | built-in token bucket + cost budget, no Redis |
| PII redaction (results) | none | not provided | redaction-at-source, on by default |
| Audit / observability | none built-in | custom middleware | structured per-call audit (shape, not values) |
| Deployment shape | in-process, security DIY | in-process, primitives DIY | in-process with gateway-grade secure defaults |
For full enterprise governance at the edge, a gateway (Kong/Higress/TrueFoundry) is still
the right tool. mcp-doorman is for the single service that wants those controls without
standing one up.
What this is NOT
- Not a JSON-RPC / transport reimplementation. It builds on the official
mcpPython SDK / FastMCP. - Not an authorization server. It's a resource server seam: bring your own AS (Auth0 / Keycloak / your SSO); doorman validates audience + scope and serves the RFC 9728 metadata pointer.
- Not a network gateway. No extra hop, no Envoy/K8s — it lives in your app process.
Known limits & honest caveats
- Thin-layer risk. This is a curated, opinionated layer on top of the SDK; FastMCP could ship "secure defaults" presets in future. The moat is the opinionated config + redaction engine + audit schema + fail-closed-on-STDIO, not novel protocol work.
- Spec churn. The MCP spec's largest revision lands 2026-07-28 (sessions removed,
Mcp-Methodheaders, JSON Schema 2020-12, error-code changes). The core guarantees are transport-independent; the wire integration tracks the official SDK as it absorbs the RC. - Scoped vs unscoped tools.
mount()refuses a bridge with noAuthConfig, and any tool that declaresscopesrequires a verified caller. An exposed tool with no scopes stays anonymously callable by design (a public tool) —mcp-doorman lintwarns on every scopeless exposed tool so this is a deliberate choice, not an accident. - Redaction is heuristic. Sensitive keys (any depth, any container — dicts, lists,
sets, bytes, dataclasses, pydantic models) are masked, and common PII value shapes
(email/phone/SSN/card) are stripped. But a novel secret hidden in free text, or a phone
shorter than ~9 digits, can slip through — add your own
value_patternsfor stricter coverage. Undecodable binary is passed through unscanned. - Limits are per process. Rate buckets and budgets live in one process's memory, so N replicas each enforce the limit independently and the effective ceiling is N× what you configured. That is the price of "no Redis, no gateway". Size accordingly, or put a shared limiter in front when the exact number matters.
- Security bar. A security-positioned library that ships a redaction bypass hurts more than a convenience library would. The fail-closed, redaction-leakage, audit-no-values, and rate-limit tests are load-bearing and run on every change (a 6-agent red-team pass shaped the 0.1.0 hardening). Found a hole? Open an issue.
License
MIT © 2026 Shaxzodbek Qambaraliyev / Blaze. See LICENSE.
Metadata
Release files for mcp-doorman 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| mcp_doorman-0.2.0.tar.gz | 50.8 kB | Without provenance | Details for mcp_doorman-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| mcp_doorman-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for mcp_doorman-0.2.0-py3-none-any.whl |
Total release size: 84.5 kB
Release files / mcp_doorman-0.2.0.tar.gz
| Download URL | mcp_doorman-0.2.0.tar.gz |
|---|---|
| Size | 50.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3f134d8b0a2491e73bb0e8757ab514b8950c51d13895550894dae1b69b0917d8
|
|
BLAKE2b-256 checksum How to use checksums |
c5907ceaafc6b4c2582fe9280bf8df8978e1fe66a95e1fc6907ba58bf744e2e4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.3
|
Release files / mcp_doorman-0.2.0-py3-none-any.whl
| Download URL | mcp_doorman-0.2.0-py3-none-any.whl |
|---|---|
| Size | 33.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
accfe08f05303d967e9cb983f6197ab3b67ce953a010db18ffd58819f6cb95d4
|
|
BLAKE2b-256 checksum How to use checksums |
1b3ce50d9684239a8cc520c6d4e761b1e29cb4d1e5012769b34eeae5774b9cfb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.3
|
Security for mcp-doorman 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.