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.
- mcp_doorman-0.1.0/.env.example +21 -0
- mcp_doorman-0.1.0/.github/workflows/ci.yml +37 -0
- mcp_doorman-0.1.0/.gitignore +32 -0
- mcp_doorman-0.1.0/CHANGELOG.md +45 -0
- mcp_doorman-0.1.0/CONTRIBUTING.md +47 -0
- mcp_doorman-0.1.0/LICENSE +21 -0
- mcp_doorman-0.1.0/PKG-INFO +193 -0
- mcp_doorman-0.1.0/README.md +154 -0
- mcp_doorman-0.1.0/SPEC.md +486 -0
- mcp_doorman-0.1.0/examples/quickstart.py +103 -0
- mcp_doorman-0.1.0/examples/redaction_audit.py +54 -0
- mcp_doorman-0.1.0/mcp_doorman/__init__.py +68 -0
- mcp_doorman-0.1.0/mcp_doorman/audit.py +121 -0
- mcp_doorman-0.1.0/mcp_doorman/auth.py +104 -0
- mcp_doorman-0.1.0/mcp_doorman/cli.py +105 -0
- mcp_doorman-0.1.0/mcp_doorman/config.py +19 -0
- mcp_doorman-0.1.0/mcp_doorman/doorman.py +274 -0
- mcp_doorman-0.1.0/mcp_doorman/errors.py +43 -0
- mcp_doorman-0.1.0/mcp_doorman/exposure.py +156 -0
- mcp_doorman-0.1.0/mcp_doorman/integrations/__init__.py +5 -0
- mcp_doorman-0.1.0/mcp_doorman/integrations/fastmcp.py +251 -0
- mcp_doorman-0.1.0/mcp_doorman/principal.py +40 -0
- mcp_doorman-0.1.0/mcp_doorman/ratelimit.py +174 -0
- mcp_doorman-0.1.0/mcp_doorman/redaction.py +159 -0
- mcp_doorman-0.1.0/mcp_doorman/scopes.py +39 -0
- mcp_doorman-0.1.0/pyproject.toml +59 -0
- mcp_doorman-0.1.0/tests/conftest.py +121 -0
- mcp_doorman-0.1.0/tests/test_audit.py +56 -0
- mcp_doorman-0.1.0/tests/test_doorman.py +235 -0
- mcp_doorman-0.1.0/tests/test_exposure.py +84 -0
- mcp_doorman-0.1.0/tests/test_imports.py +42 -0
- mcp_doorman-0.1.0/tests/test_integration_live.py +76 -0
- mcp_doorman-0.1.0/tests/test_origin.py +33 -0
- mcp_doorman-0.1.0/tests/test_ratelimit.py +98 -0
- mcp_doorman-0.1.0/tests/test_redaction.py +155 -0
- 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
|