mcp-doorman 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. mcp_doorman-0.1.0/.env.example +21 -0
  2. mcp_doorman-0.1.0/.github/workflows/ci.yml +37 -0
  3. mcp_doorman-0.1.0/.gitignore +32 -0
  4. mcp_doorman-0.1.0/CHANGELOG.md +45 -0
  5. mcp_doorman-0.1.0/CONTRIBUTING.md +47 -0
  6. mcp_doorman-0.1.0/LICENSE +21 -0
  7. mcp_doorman-0.1.0/PKG-INFO +193 -0
  8. mcp_doorman-0.1.0/README.md +154 -0
  9. mcp_doorman-0.1.0/SPEC.md +486 -0
  10. mcp_doorman-0.1.0/examples/quickstart.py +103 -0
  11. mcp_doorman-0.1.0/examples/redaction_audit.py +54 -0
  12. mcp_doorman-0.1.0/mcp_doorman/__init__.py +68 -0
  13. mcp_doorman-0.1.0/mcp_doorman/audit.py +121 -0
  14. mcp_doorman-0.1.0/mcp_doorman/auth.py +104 -0
  15. mcp_doorman-0.1.0/mcp_doorman/cli.py +105 -0
  16. mcp_doorman-0.1.0/mcp_doorman/config.py +19 -0
  17. mcp_doorman-0.1.0/mcp_doorman/doorman.py +274 -0
  18. mcp_doorman-0.1.0/mcp_doorman/errors.py +43 -0
  19. mcp_doorman-0.1.0/mcp_doorman/exposure.py +156 -0
  20. mcp_doorman-0.1.0/mcp_doorman/integrations/__init__.py +5 -0
  21. mcp_doorman-0.1.0/mcp_doorman/integrations/fastmcp.py +251 -0
  22. mcp_doorman-0.1.0/mcp_doorman/principal.py +40 -0
  23. mcp_doorman-0.1.0/mcp_doorman/ratelimit.py +174 -0
  24. mcp_doorman-0.1.0/mcp_doorman/redaction.py +159 -0
  25. mcp_doorman-0.1.0/mcp_doorman/scopes.py +39 -0
  26. mcp_doorman-0.1.0/pyproject.toml +59 -0
  27. mcp_doorman-0.1.0/tests/conftest.py +121 -0
  28. mcp_doorman-0.1.0/tests/test_audit.py +56 -0
  29. mcp_doorman-0.1.0/tests/test_doorman.py +235 -0
  30. mcp_doorman-0.1.0/tests/test_exposure.py +84 -0
  31. mcp_doorman-0.1.0/tests/test_imports.py +42 -0
  32. mcp_doorman-0.1.0/tests/test_integration_live.py +76 -0
  33. mcp_doorman-0.1.0/tests/test_origin.py +33 -0
  34. mcp_doorman-0.1.0/tests/test_ratelimit.py +98 -0
  35. mcp_doorman-0.1.0/tests/test_redaction.py +155 -0
  36. mcp_doorman-0.1.0/tests/test_scopes.py +57 -0
@@ -0,0 +1,21 @@
1
+ # mcp-doorman settings (env_prefix DOORMAN_). Copy to .env and edit.
2
+
3
+ # Where the MCP endpoint is mounted.
4
+ DOORMAN_ENDPOINT=/mcp
5
+
6
+ # Rate limit spec: "COUNT/UNIT [per_caller|per_tool]" joined by ";". Empty disables.
7
+ DOORMAN_RATE_LIMIT=60/min per_caller; 10/min per_tool
8
+
9
+ # Audit sink: stderr | otel | none
10
+ DOORMAN_AUDIT=stderr
11
+
12
+ # OAuth token claim carrying the tenant id (for per-tenant scope/audience isolation).
13
+ DOORMAN_TENANT_CLAIM=tenant_id
14
+
15
+ # Fail closed: refuse to mount a remote bridge with no auth configured.
16
+ DOORMAN_REQUIRE_AUTH_FOR_REMOTE=true
17
+
18
+ # Comma-separated allowed Origins (empty -> same-origin/localhost only).
19
+ # DOORMAN_ALLOWED_ORIGINS=https://app.example.com
20
+
21
+ DOORMAN_SERVER_NAME=mcp-doorman
@@ -0,0 +1,37 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v5
19
+ - name: Set up Python ${{ matrix.python-version }}
20
+ run: uv python install ${{ matrix.python-version }}
21
+ - name: Install (core + dev)
22
+ run: uv pip install --system -e '.[dev]'
23
+ - name: Ruff
24
+ run: uv run --no-project ruff check .
25
+ - name: Tests (core, no optional extras)
26
+ run: uv run --no-project pytest -q
27
+
28
+ test-with-mcp:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - name: Install uv
33
+ uses: astral-sh/setup-uv@v5
34
+ - name: Install (with the mcp extra)
35
+ run: uv pip install --system -e '.[dev,mcp,fastapi]'
36
+ - name: Tests (integration extras present)
37
+ run: uv run --no-project pytest -q
@@ -0,0 +1,32 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ wheels/
9
+
10
+ # Envs
11
+ .venv/
12
+ venv/
13
+ env/
14
+ .env
15
+ .env.*
16
+ !.env.example
17
+
18
+ # Lockfile — this is a library; consumers resolve their own deps
19
+ uv.lock
20
+
21
+ # Tooling caches
22
+ .pytest_cache/
23
+ .ruff_cache/
24
+ .mypy_cache/
25
+ .coverage
26
+ htmlcov/
27
+
28
+ # OS / editor
29
+ .DS_Store
30
+ *.swp
31
+ .idea/
32
+ .vscode/
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to `mcp-doorman` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] — 2026-06-20
10
+
11
+ Initial beta. The five secure-by-default guarantees, implemented as a dependency-light,
12
+ offline-testable core with a lazy-imported MCP transport.
13
+
14
+ ### Added
15
+ - **Deny-by-default exposure** — `@expose(...)` opt-in; destructive HTTP methods require
16
+ `destructive=True` and auto-emit the `destructiveHint` annotation (`exposure.py`).
17
+ - **Fail-closed scope authorization**, including STDIO — an unverified principal can never
18
+ satisfy a scoped tool (`scopes.py`).
19
+ - **Token-bucket rate limiting**, per-caller and per-tool, no Redis (`ratelimit.py`).
20
+ - **PII redaction at the source** — sensitive key globs + PII value patterns, recursive,
21
+ non-mutating, cycle-safe (`redaction.py`).
22
+ - **Structured audit** — one record per call carrying argument *shape* (keys/types/lengths),
23
+ never values; `StderrSink` / `NullSink` / custom sinks (`audit.py`).
24
+ - **Resource-server auth seam** — `AccessToken`, `TokenVerifier`, audience binding
25
+ (RFC 8707/9068), RFC 9728 metadata body, `StaticVerifier` for tests (`auth.py`).
26
+ - **`Doorman`** orchestrator with the `guard_call` / `aguard_call` pipeline, `Doorman.dev()`
27
+ localhost preset, and a fail-closed `mount()` (refuses a remote bridge with no auth).
28
+ - **MCP integration** (`integrations/fastmcp.py`) building on the official `mcp` SDK —
29
+ lazy-imported behind the `[mcp]` extra.
30
+ - **CLI** — `mcp-doorman doctor | lint | version`.
31
+ - Offline test suite + live-wire integration tests, and a CI matrix.
32
+
33
+ ### Security (0.1.0 hardening, from a 6-agent red-team pass)
34
+ - Redaction now covers `bytes`, `set`/`frozenset`, dataclasses, pydantic models, int
35
+ card/SSN values, and non-str sensitive keys — closing five silent value-leak gaps.
36
+ - Audit `shape()` no longer emits dict **keys** (which can be caller-controlled data); it
37
+ records container types, lengths, and counts only.
38
+ - Rate limiting bounds its bucket map (LRU + lossless eviction) against high-cardinality
39
+ callers, and keys anonymous callers by a per-connection hint instead of one shared bucket;
40
+ `parse_rate_spec` rejects a `0/UNIT` limit.
41
+ - The Origin / DNS-rebinding check promised by the integration is now actually implemented
42
+ and wired to `allowed_origins`; `scan_app` fails closed on routes with unknown methods.
43
+
44
+ [Unreleased]: https://github.com/shaxzodbek-uzb/mcp-doorman/compare/v0.1.0...HEAD
45
+ [0.1.0]: https://github.com/shaxzodbek-uzb/mcp-doorman/releases/tag/v0.1.0
@@ -0,0 +1,47 @@
1
+ # Contributing to mcp-doorman
2
+
3
+ Thanks for helping make MCP servers secure by default. This is a small, focused library —
4
+ contributions that keep it small and sharp are the most welcome.
5
+
6
+ ## Project shape
7
+
8
+ - The **core** (`mcp_doorman/` minus `integrations/`) is dependency-light and must import
9
+ **no** `mcp`/FastAPI SDK. The five guarantees live here and are tested fully offline.
10
+ - The **integration** (`mcp_doorman/integrations/`) is the only place allowed to import the
11
+ MCP SDK, and it does so lazily.
12
+ - [`SPEC.md`](SPEC.md) is the canonical description of every public signature and behavior.
13
+ Change the spec in the same PR as the code.
14
+
15
+ ## Dev setup
16
+
17
+ ```bash
18
+ uv venv && uv pip install -e '.[dev]' # core + test tooling
19
+ uv run pytest -q # 57 offline tests
20
+ uv run ruff check . # lint (line length 100)
21
+ ```
22
+
23
+ To exercise the wire integration: `uv pip install -e '.[dev,mcp,fastapi]'`.
24
+
25
+ ## The bar for changes
26
+
27
+ This is a **security-positioned** project, so:
28
+
29
+ 1. **Fail closed.** Any new decision path must default to denying when context is missing.
30
+ 2. **No values in logs.** The audit layer records argument *shapes*, never values. Keep it
31
+ that way; add a leakage assertion for anything new.
32
+ 3. **Tests are load-bearing, not smoke.** The fail-closed (`test_scopes.py`), redaction
33
+ leakage (`test_redaction.py`), audit-no-values (`test_audit.py`), and rate-limit
34
+ (`test_ratelimit.py`) tests are the product's contract. Extend them precisely.
35
+ 4. **Ruff-clean, typed, docstringed** on every public symbol.
36
+
37
+ ## Reporting a security issue
38
+
39
+ If you find a redaction bypass, an auth fail-open, or a token-confusion path, please open a
40
+ GitHub issue marked **security** (or email shaxzodbek@blaze.uz). A reproducer beats a
41
+ description.
42
+
43
+ ## Pull requests
44
+
45
+ - Keep PRs scoped to one concern; update `SPEC.md`, `CHANGELOG.md`, and tests together.
46
+ - New public API needs a spec entry, a docstring, and a test. New behavior that loosens a
47
+ default needs an explicit, documented reason.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shaxzodbek Sobirov / Blaze
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-doorman
3
+ Version: 0.1.0
4
+ Summary: Secure-by-default FastAPI->MCP bridge. Deny-by-default exposure, scope->tool auth that fails closed (even on STDIO), built-in rate limiting, PII redaction at the source, and a structured audit log — in-process, no gateway.
5
+ Project-URL: Homepage, https://github.com/shaxzodbek-uzb/mcp-doorman
6
+ Project-URL: Repository, https://github.com/shaxzodbek-uzb/mcp-doorman
7
+ Project-URL: Issues, https://github.com/shaxzodbek-uzb/mcp-doorman/issues
8
+ Author-email: Shaxzodbek Sobirov <shaxzodbek@blaze.uz>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,audit,fastapi,llm,mcp,model-context-protocol,oauth,pii-redaction,rate-limit,security
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: pydantic-settings>=2.5
25
+ Provides-Extra: all
26
+ Requires-Dist: fastapi>=0.110; extra == 'all'
27
+ Requires-Dist: mcp>=1.2; extra == 'all'
28
+ Requires-Dist: starlette>=0.37; extra == 'all'
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6; extra == 'dev'
33
+ Provides-Extra: fastapi
34
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
35
+ Provides-Extra: mcp
36
+ Requires-Dist: mcp>=1.2; extra == 'mcp'
37
+ Requires-Dist: starlette>=0.37; extra == 'mcp'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # mcp-doorman
41
+
42
+ **Secure-by-default FastAPI→MCP bridge.** FastMCP gives you the tools; this gives you the
43
+ seatbelts — deny-by-default exposure, scope→tool authorization that fails **closed** (even
44
+ on STDIO), built-in rate limiting, PII redaction at the source, and a structured audit
45
+ log. In-process. No gateway.
46
+
47
+ ```bash
48
+ pip install mcp-doorman # core (dependency-light)
49
+ pip install "mcp-doorman[mcp]" # + the official MCP transport
50
+ ```
51
+
52
+ > **Status:** beta (`0.1.0`). The five security guarantees are implemented and covered by
53
+ > an offline test suite. The MCP wire transport is built on the official `mcp` SDK and is
54
+ > evolving toward the 2026-07-28 spec revision — see [Known limits](#known-limits--honest-caveats).
55
+
56
+ ---
57
+
58
+ ## Why
59
+
60
+ Turning a FastAPI app into an MCP server is already a commodity. [`fastapi_mcp`][fam] and
61
+ [`FastMCP.from_fastapi`][fastmcp] do it in five minutes — and ship security as opt-in
62
+ primitives you assemble yourself, or omit entirely. The result, in the wild:
63
+
64
+ - An **open RFC-8707 token-confusion advisory** ([GHSA-5h2m-4q8j-pqpj][ghsa]) — a token
65
+ minted for one MCP server can be replayed against another.
66
+ - A documented **~$400 runaway** from an uncontrolled tool-call loop — no rate or cost guard.
67
+ - **No per-tool RBAC**, no output sanitization, no audit trail out of the box.
68
+ - FastMCP's own docs: *"for bootstrapping and prototyping, not for mirroring your API."*
69
+
70
+ Everything that *does* ship the full governance stack — scopes, rate limits, redaction,
71
+ audit — is a heavyweight **network gateway** (Kong, Higress, TrueFoundry): an extra hop and
72
+ extra infrastructure to stand up and operate. Overkill for one service exposing eight
73
+ endpoints.
74
+
75
+ `mcp-doorman` is the missing middle: a single `pip install` that runs **in-process** and
76
+ makes the **secure configuration the default**. Insecure-by-omission is impossible.
77
+
78
+ ## The five guarantees
79
+
80
+ 1. **Deny-by-default exposure.** A route is never a tool unless you `@expose` it.
81
+ Destructive methods (POST/PUT/PATCH/DELETE) refuse to publish without an explicit
82
+ `destructive=True`, which auto-emits the MCP `destructiveHint` annotation.
83
+ 2. **Scope→tool authorization, fail-closed — including STDIO.** A scoped tool is denied
84
+ unless the caller presents a **verified** principal with sufficient scopes. When there's
85
+ no verified context (no token, or STDIO where other bridges silently return `None` and
86
+ *bypass* every check), doorman **denies**.
87
+ 3. **Rate limiting + call budget.** Per-caller and per-tool token buckets, in-process, no
88
+ Redis — the direct answer to the runaway-loop failure mode.
89
+ 4. **PII redaction at the source.** Sensitive keys (`*_token`, `email`, …) and value
90
+ patterns (email/phone/SSN/card) are redacted from tool **results** before they reach the
91
+ model, and from anything the audit layer would record.
92
+ 5. **Structured audit out of the box.** One OTel-friendly record per call: caller, tool,
93
+ argument **shape** (container types/lengths/counts — *never values, never
94
+ caller-controlled keys*), status, latency.
95
+
96
+ ## 60-second quickstart
97
+
98
+ ```python
99
+ from fastapi import FastAPI
100
+ from mcp_doorman import Doorman, expose
101
+
102
+ app = FastAPI()
103
+
104
+ # Deny-by-default: nothing is a tool unless decorated.
105
+ @expose(name="get_invoice", scopes=["invoices:read"])
106
+ @app.get("/invoices/{id}")
107
+ async def get_invoice(id: str):
108
+ return {"id": id, "email": "alice@example.com"} # email auto-redacted in the result
109
+
110
+ @expose(name="refund", scopes=["invoices:write"], destructive=True, redact=["amount"])
111
+ @app.post("/invoices/{id}/refund")
112
+ async def refund(id: str, amount: float):
113
+ return {"id": id, "refunded": amount}
114
+
115
+ # One secure mount: Streamable HTTP, RFC 9728 metadata, audience-bound tokens,
116
+ # rate limits, redaction, and audit — all on by default.
117
+ Doorman(
118
+ app,
119
+ auth=Doorman.oauth(
120
+ issuer="https://sso.example.com",
121
+ resource="https://api.example.com/mcp", # RFC 8707 audience binding
122
+ ),
123
+ rate_limit="60/min per_caller; 10/min per_tool",
124
+ redact=["email", "phone", "ssn", "*_token"],
125
+ audit="stderr",
126
+ ).mount("/mcp")
127
+ ```
128
+
129
+ Prototyping locally? `Doorman.dev(app)` loosens auth for localhost so the secure default
130
+ never blocks first-run DX (it prints a warning and must never be used in production).
131
+
132
+ Lint what you're about to expose, in CI:
133
+
134
+ ```bash
135
+ mcp-doorman lint myapp.main:app # lists tools, scopes, destructive flags; exits non-zero on warnings
136
+ mcp-doorman doctor # settings + whether the [mcp] extra is installed
137
+ ```
138
+
139
+ ## How it compares
140
+
141
+ | | `fastapi_mcp` | `FastMCP.from_fastapi` | **mcp-doorman** |
142
+ |---|---|---|---|
143
+ | Exposure default | expose-all (opt-out) | expose-all (RouteMap) | **deny-by-default**, destructive gated |
144
+ | Per-tool scopes | by hand in each handler | `require_scopes()` primitive, **bypassed on STDIO** | **enforced at the library layer, fail-closed on STDIO** |
145
+ | Multi-tenant token safety (RFC 8707) | open advisory, replayable | DIY audience checks | **audience binding by default** |
146
+ | Rate / loop & cost guard | none (documented $400) | none ("build it yourself") | **built-in token bucket, no Redis** |
147
+ | PII redaction (results) | none | not provided | **redaction-at-source, on by default** |
148
+ | Audit / observability | none built-in | custom middleware | **structured per-call audit (shape, not values)** |
149
+ | Deployment shape | in-process, security DIY | in-process, primitives DIY | **in-process with gateway-grade secure defaults** |
150
+
151
+ For *full* enterprise governance at the edge, a gateway (Kong/Higress/TrueFoundry) is still
152
+ the right tool. `mcp-doorman` is for the single service that wants those controls **without**
153
+ standing one up.
154
+
155
+ ## What this is NOT
156
+
157
+ - **Not** a JSON-RPC / transport reimplementation. It builds **on** the official `mcp`
158
+ Python SDK / FastMCP.
159
+ - **Not** an authorization server. It's a *resource server* seam: bring your own AS
160
+ (Auth0 / Keycloak / your SSO); doorman validates audience + scope and serves the RFC 9728
161
+ metadata pointer.
162
+ - **Not** a network gateway. No extra hop, no Envoy/K8s — it lives in your app process.
163
+
164
+ ## Known limits & honest caveats
165
+
166
+ - **Thin-layer risk.** This is a curated, opinionated layer on top of the SDK; FastMCP could
167
+ ship "secure defaults" presets in future. The moat is the opinionated config + redaction
168
+ engine + audit schema + fail-closed-on-STDIO, not novel protocol work.
169
+ - **Spec churn.** The MCP spec's largest revision lands **2026-07-28** (sessions removed,
170
+ `Mcp-Method` headers, JSON Schema 2020-12, error-code changes). The core guarantees are
171
+ transport-independent; the wire integration tracks the official SDK as it absorbs the RC.
172
+ - **Scoped vs unscoped tools.** `mount()` refuses a bridge with no `AuthConfig`, and any
173
+ tool that declares `scopes` requires a verified caller. An exposed tool with **no**
174
+ scopes stays *anonymously callable by design* (a public tool) — `mcp-doorman lint` warns
175
+ on every scopeless exposed tool so this is a deliberate choice, not an accident.
176
+ - **Redaction is heuristic.** Sensitive **keys** (any depth, any container — dicts, lists,
177
+ sets, bytes, dataclasses, pydantic models) are masked, and common PII **value** shapes
178
+ (email/phone/SSN/card) are stripped. But a novel secret hidden in free text, or a phone
179
+ shorter than ~9 digits, can slip through — add your own `value_patterns` for stricter
180
+ coverage. Undecodable binary is passed through unscanned.
181
+ - **Security bar.** A security-positioned library that ships a redaction bypass hurts more
182
+ than a convenience library would. The fail-closed, redaction-leakage, audit-no-values, and
183
+ rate-limit tests are load-bearing and run on every change (a 6-agent red-team pass shaped
184
+ the 0.1.0 hardening). Found a hole?
185
+ [Open an issue.](https://github.com/shaxzodbek-uzb/mcp-doorman/issues)
186
+
187
+ ## License
188
+
189
+ MIT © 2026 Shaxzodbek Sobirov / Blaze. See [LICENSE](LICENSE).
190
+
191
+ [fam]: https://github.com/tadata-org/fastapi_mcp
192
+ [fastmcp]: https://github.com/jlowin/fastmcp
193
+ [ghsa]: https://github.com/advisories
@@ -0,0 +1,154 @@
1
+ # mcp-doorman
2
+
3
+ **Secure-by-default FastAPI→MCP bridge.** FastMCP gives you the tools; this gives you the
4
+ seatbelts — deny-by-default exposure, scope→tool authorization that fails **closed** (even
5
+ on STDIO), built-in rate limiting, PII redaction at the source, and a structured audit
6
+ log. In-process. No gateway.
7
+
8
+ ```bash
9
+ pip install mcp-doorman # core (dependency-light)
10
+ pip install "mcp-doorman[mcp]" # + the official MCP transport
11
+ ```
12
+
13
+ > **Status:** beta (`0.1.0`). The five security guarantees are implemented and covered by
14
+ > an offline test suite. The MCP wire transport is built on the official `mcp` SDK and is
15
+ > evolving toward the 2026-07-28 spec revision — see [Known limits](#known-limits--honest-caveats).
16
+
17
+ ---
18
+
19
+ ## Why
20
+
21
+ Turning a FastAPI app into an MCP server is already a commodity. [`fastapi_mcp`][fam] and
22
+ [`FastMCP.from_fastapi`][fastmcp] do it in five minutes — and ship security as opt-in
23
+ primitives you assemble yourself, or omit entirely. The result, in the wild:
24
+
25
+ - An **open RFC-8707 token-confusion advisory** ([GHSA-5h2m-4q8j-pqpj][ghsa]) — a token
26
+ minted for one MCP server can be replayed against another.
27
+ - A documented **~$400 runaway** from an uncontrolled tool-call loop — no rate or cost guard.
28
+ - **No per-tool RBAC**, no output sanitization, no audit trail out of the box.
29
+ - FastMCP's own docs: *"for bootstrapping and prototyping, not for mirroring your API."*
30
+
31
+ Everything that *does* ship the full governance stack — scopes, rate limits, redaction,
32
+ audit — is a heavyweight **network gateway** (Kong, Higress, TrueFoundry): an extra hop and
33
+ extra infrastructure to stand up and operate. Overkill for one service exposing eight
34
+ endpoints.
35
+
36
+ `mcp-doorman` is the missing middle: a single `pip install` that runs **in-process** and
37
+ makes the **secure configuration the default**. Insecure-by-omission is impossible.
38
+
39
+ ## The five guarantees
40
+
41
+ 1. **Deny-by-default exposure.** A route is never a tool unless you `@expose` it.
42
+ Destructive methods (POST/PUT/PATCH/DELETE) refuse to publish without an explicit
43
+ `destructive=True`, which auto-emits the MCP `destructiveHint` annotation.
44
+ 2. **Scope→tool authorization, fail-closed — including STDIO.** A scoped tool is denied
45
+ unless the caller presents a **verified** principal with sufficient scopes. When there's
46
+ no verified context (no token, or STDIO where other bridges silently return `None` and
47
+ *bypass* every check), doorman **denies**.
48
+ 3. **Rate limiting + call budget.** Per-caller and per-tool token buckets, in-process, no
49
+ Redis — the direct answer to the runaway-loop failure mode.
50
+ 4. **PII redaction at the source.** Sensitive keys (`*_token`, `email`, …) and value
51
+ patterns (email/phone/SSN/card) are redacted from tool **results** before they reach the
52
+ model, and from anything the audit layer would record.
53
+ 5. **Structured audit out of the box.** One OTel-friendly record per call: caller, tool,
54
+ argument **shape** (container types/lengths/counts — *never values, never
55
+ caller-controlled keys*), status, latency.
56
+
57
+ ## 60-second quickstart
58
+
59
+ ```python
60
+ from fastapi import FastAPI
61
+ from mcp_doorman import Doorman, expose
62
+
63
+ app = FastAPI()
64
+
65
+ # Deny-by-default: nothing is a tool unless decorated.
66
+ @expose(name="get_invoice", scopes=["invoices:read"])
67
+ @app.get("/invoices/{id}")
68
+ async def get_invoice(id: str):
69
+ return {"id": id, "email": "alice@example.com"} # email auto-redacted in the result
70
+
71
+ @expose(name="refund", scopes=["invoices:write"], destructive=True, redact=["amount"])
72
+ @app.post("/invoices/{id}/refund")
73
+ async def refund(id: str, amount: float):
74
+ return {"id": id, "refunded": amount}
75
+
76
+ # One secure mount: Streamable HTTP, RFC 9728 metadata, audience-bound tokens,
77
+ # rate limits, redaction, and audit — all on by default.
78
+ Doorman(
79
+ app,
80
+ auth=Doorman.oauth(
81
+ issuer="https://sso.example.com",
82
+ resource="https://api.example.com/mcp", # RFC 8707 audience binding
83
+ ),
84
+ rate_limit="60/min per_caller; 10/min per_tool",
85
+ redact=["email", "phone", "ssn", "*_token"],
86
+ audit="stderr",
87
+ ).mount("/mcp")
88
+ ```
89
+
90
+ Prototyping locally? `Doorman.dev(app)` loosens auth for localhost so the secure default
91
+ never blocks first-run DX (it prints a warning and must never be used in production).
92
+
93
+ Lint what you're about to expose, in CI:
94
+
95
+ ```bash
96
+ mcp-doorman lint myapp.main:app # lists tools, scopes, destructive flags; exits non-zero on warnings
97
+ mcp-doorman doctor # settings + whether the [mcp] extra is installed
98
+ ```
99
+
100
+ ## How it compares
101
+
102
+ | | `fastapi_mcp` | `FastMCP.from_fastapi` | **mcp-doorman** |
103
+ |---|---|---|---|
104
+ | Exposure default | expose-all (opt-out) | expose-all (RouteMap) | **deny-by-default**, destructive gated |
105
+ | Per-tool scopes | by hand in each handler | `require_scopes()` primitive, **bypassed on STDIO** | **enforced at the library layer, fail-closed on STDIO** |
106
+ | Multi-tenant token safety (RFC 8707) | open advisory, replayable | DIY audience checks | **audience binding by default** |
107
+ | Rate / loop & cost guard | none (documented $400) | none ("build it yourself") | **built-in token bucket, no Redis** |
108
+ | PII redaction (results) | none | not provided | **redaction-at-source, on by default** |
109
+ | Audit / observability | none built-in | custom middleware | **structured per-call audit (shape, not values)** |
110
+ | Deployment shape | in-process, security DIY | in-process, primitives DIY | **in-process with gateway-grade secure defaults** |
111
+
112
+ For *full* enterprise governance at the edge, a gateway (Kong/Higress/TrueFoundry) is still
113
+ the right tool. `mcp-doorman` is for the single service that wants those controls **without**
114
+ standing one up.
115
+
116
+ ## What this is NOT
117
+
118
+ - **Not** a JSON-RPC / transport reimplementation. It builds **on** the official `mcp`
119
+ Python SDK / FastMCP.
120
+ - **Not** an authorization server. It's a *resource server* seam: bring your own AS
121
+ (Auth0 / Keycloak / your SSO); doorman validates audience + scope and serves the RFC 9728
122
+ metadata pointer.
123
+ - **Not** a network gateway. No extra hop, no Envoy/K8s — it lives in your app process.
124
+
125
+ ## Known limits & honest caveats
126
+
127
+ - **Thin-layer risk.** This is a curated, opinionated layer on top of the SDK; FastMCP could
128
+ ship "secure defaults" presets in future. The moat is the opinionated config + redaction
129
+ engine + audit schema + fail-closed-on-STDIO, not novel protocol work.
130
+ - **Spec churn.** The MCP spec's largest revision lands **2026-07-28** (sessions removed,
131
+ `Mcp-Method` headers, JSON Schema 2020-12, error-code changes). The core guarantees are
132
+ transport-independent; the wire integration tracks the official SDK as it absorbs the RC.
133
+ - **Scoped vs unscoped tools.** `mount()` refuses a bridge with no `AuthConfig`, and any
134
+ tool that declares `scopes` requires a verified caller. An exposed tool with **no**
135
+ scopes stays *anonymously callable by design* (a public tool) — `mcp-doorman lint` warns
136
+ on every scopeless exposed tool so this is a deliberate choice, not an accident.
137
+ - **Redaction is heuristic.** Sensitive **keys** (any depth, any container — dicts, lists,
138
+ sets, bytes, dataclasses, pydantic models) are masked, and common PII **value** shapes
139
+ (email/phone/SSN/card) are stripped. But a novel secret hidden in free text, or a phone
140
+ shorter than ~9 digits, can slip through — add your own `value_patterns` for stricter
141
+ coverage. Undecodable binary is passed through unscanned.
142
+ - **Security bar.** A security-positioned library that ships a redaction bypass hurts more
143
+ than a convenience library would. The fail-closed, redaction-leakage, audit-no-values, and
144
+ rate-limit tests are load-bearing and run on every change (a 6-agent red-team pass shaped
145
+ the 0.1.0 hardening). Found a hole?
146
+ [Open an issue.](https://github.com/shaxzodbek-uzb/mcp-doorman/issues)
147
+
148
+ ## License
149
+
150
+ MIT © 2026 Shaxzodbek Sobirov / Blaze. See [LICENSE](LICENSE).
151
+
152
+ [fam]: https://github.com/tadata-org/fastapi_mcp
153
+ [fastmcp]: https://github.com/jlowin/fastmcp
154
+ [ghsa]: https://github.com/advisories