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.
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 | |
|---|---|---|---|
| mcp_doorman-0.2.0.tar.gz | 50.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_doorman-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
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
|