mcp-authflow-resource 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_authflow_resource-0.1.0/.github/workflows/ci.yml +43 -0
- mcp_authflow_resource-0.1.0/.github/workflows/publish.yml +21 -0
- mcp_authflow_resource-0.1.0/.gitignore +15 -0
- mcp_authflow_resource-0.1.0/.python-version +1 -0
- mcp_authflow_resource-0.1.0/LICENSE +21 -0
- mcp_authflow_resource-0.1.0/PKG-INFO +429 -0
- mcp_authflow_resource-0.1.0/README.md +400 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/__init__.py +44 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/auth/__init__.py +9 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/auth/ssrf_protection.py +40 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/auth/token_verifier.py +117 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/__init__.py +31 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/controller.py +304 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/decorator.py +137 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/logging.py +101 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/models.py +108 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/registry.py +150 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/window.py +69 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/middleware.py +137 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/oauth_discovery.py +196 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/py.typed +0 -0
- mcp_authflow_resource-0.1.0/mcp_resource_framework/validation.py +167 -0
- mcp_authflow_resource-0.1.0/pyproject.toml +67 -0
- mcp_authflow_resource-0.1.0/tests/conftest.py +33 -0
- mcp_authflow_resource-0.1.0/tests/friction/__init__.py +0 -0
- mcp_authflow_resource-0.1.0/tests/friction/test_controller.py +224 -0
- mcp_authflow_resource-0.1.0/tests/friction/test_decorator.py +153 -0
- mcp_authflow_resource-0.1.0/tests/friction/test_load.py +447 -0
- mcp_authflow_resource-0.1.0/tests/friction/test_registry.py +128 -0
- mcp_authflow_resource-0.1.0/tests/friction/test_window.py +69 -0
- mcp_authflow_resource-0.1.0/tests/test_middleware.py +362 -0
- mcp_authflow_resource-0.1.0/tests/test_oauth_discovery.py +221 -0
- mcp_authflow_resource-0.1.0/tests/test_ssrf_protection.py +60 -0
- mcp_authflow_resource-0.1.0/tests/test_token_verifier.py +442 -0
- mcp_authflow_resource-0.1.0/tests/test_validation.py +250 -0
- mcp_authflow_resource-0.1.0/uv.lock +989 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
lint:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: astral-sh/setup-uv@v6
|
|
15
|
+
with:
|
|
16
|
+
enable-cache: true
|
|
17
|
+
- run: uv sync --frozen
|
|
18
|
+
- run: uv run ruff check .
|
|
19
|
+
- run: uv run ruff format --check .
|
|
20
|
+
|
|
21
|
+
typecheck:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: astral-sh/setup-uv@v6
|
|
26
|
+
with:
|
|
27
|
+
enable-cache: true
|
|
28
|
+
- run: uv sync --frozen
|
|
29
|
+
- run: uv run pyright
|
|
30
|
+
|
|
31
|
+
test:
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
strategy:
|
|
34
|
+
matrix:
|
|
35
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
- uses: astral-sh/setup-uv@v6
|
|
39
|
+
with:
|
|
40
|
+
enable-cache: true
|
|
41
|
+
- run: uv python install ${{ matrix.python-version }}
|
|
42
|
+
- run: uv sync --frozen --python ${{ matrix.python-version }}
|
|
43
|
+
- run: uv run pytest --tb=short
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v6
|
|
18
|
+
with:
|
|
19
|
+
enable-cache: true
|
|
20
|
+
- run: uv build
|
|
21
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 brooksmcmillin
|
|
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,429 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-authflow-resource
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OAuth 2.0 Resource Server framework for MCP (Model Context Protocol) servers
|
|
5
|
+
Project-URL: Homepage, https://github.com/brooksmcmillin/mcpauth-resource
|
|
6
|
+
Project-URL: Documentation, https://github.com/brooksmcmillin/mcpauth-resource#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/brooksmcmillin/mcpauth-resource
|
|
8
|
+
Project-URL: Issues, https://github.com/brooksmcmillin/mcpauth-resource/issues
|
|
9
|
+
Author: Brooks McMillin
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
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: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: httpx>=0.27.0
|
|
25
|
+
Requires-Dist: mcp>=1.24.0
|
|
26
|
+
Requires-Dist: pydantic>=2.0.0
|
|
27
|
+
Requires-Dist: starlette>=0.40.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# mcp-authflow-resource
|
|
31
|
+
|
|
32
|
+
OAuth 2.0 Resource Server framework for [MCP](https://modelcontextprotocol.io/) servers. Validate tokens and control tool-call rates with a proportional feedback loop.
|
|
33
|
+
|
|
34
|
+
Pair with [mcp-authflow](https://github.com/brooksmcmillin/mcpauth) on the authorization server side.
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **Token verification** via RFC 7662 introspection with SSRF protection
|
|
39
|
+
- **OAuth discovery** endpoints (RFC 9908, RFC 8414, OIDC)
|
|
40
|
+
- **Friction control** -- dynamic tool-call rate limiting using a proportional feedback loop
|
|
41
|
+
- **Response validation** helpers for MCP tool implementations
|
|
42
|
+
- **ASGI middleware** for path normalization and request logging
|
|
43
|
+
- **Async-first** design, built on Starlette and MCP SDK
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install mcp-authflow-resource
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start: Protect an MCP Server in 5 Minutes
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from mcp.server.fastmcp.server import FastMCP
|
|
55
|
+
from mcp.server.auth.settings import AuthSettings
|
|
56
|
+
from pydantic import AnyHttpUrl
|
|
57
|
+
|
|
58
|
+
from mcp_resource_framework import (
|
|
59
|
+
IntrospectionTokenVerifier,
|
|
60
|
+
register_oauth_discovery_endpoints,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# 1. Create a token verifier pointing at your auth server
|
|
64
|
+
verifier = IntrospectionTokenVerifier(
|
|
65
|
+
introspection_endpoint="http://localhost:8000/introspect",
|
|
66
|
+
server_url="https://mcp.example.com",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# 2. Create an MCP server with OAuth protection
|
|
70
|
+
app = FastMCP(
|
|
71
|
+
name="My Protected MCP Server",
|
|
72
|
+
token_verifier=verifier,
|
|
73
|
+
auth=AuthSettings(
|
|
74
|
+
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
|
75
|
+
required_scopes=["read"],
|
|
76
|
+
resource_server_url=AnyHttpUrl("https://mcp.example.com"),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# 3. Register OAuth discovery endpoints (RFC 9908 + RFC 8414)
|
|
81
|
+
register_oauth_discovery_endpoints(
|
|
82
|
+
app,
|
|
83
|
+
server_url="https://mcp.example.com",
|
|
84
|
+
auth_server_public_url="https://auth.example.com",
|
|
85
|
+
scopes=["read"],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# 4. Define tools -- they're now protected by OAuth
|
|
90
|
+
@app.tool()
|
|
91
|
+
async def hello(name: str) -> str:
|
|
92
|
+
"""Greet someone."""
|
|
93
|
+
return f"Hello, {name}!"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
That's it. Clients must now present a valid Bearer token to call any tool.
|
|
97
|
+
|
|
98
|
+
## Architecture
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
MCP Client
|
|
102
|
+
|
|
|
103
|
+
| Bearer token
|
|
104
|
+
v
|
|
105
|
+
+---------------------------+
|
|
106
|
+
| Resource Server | <-- this package
|
|
107
|
+
| (your MCP tools) |
|
|
108
|
+
| |
|
|
109
|
+
| 1. Extract Bearer token |
|
|
110
|
+
| 2. Introspect token ----+---> Auth Server (/introspect)
|
|
111
|
+
| 3. Check scopes | |
|
|
112
|
+
| 4. Friction check | "active": true/false
|
|
113
|
+
| 5. Execute tool |
|
|
114
|
+
+---------------------------+
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Token Verification Flow
|
|
118
|
+
|
|
119
|
+
1. Client sends request with `Authorization: Bearer <token>` header
|
|
120
|
+
2. `IntrospectionTokenVerifier` calls the auth server's introspection endpoint (RFC 7662)
|
|
121
|
+
3. Auth server responds with token metadata (`active`, `scope`, `client_id`, `exp`, `aud`)
|
|
122
|
+
4. If active and scopes match, the tool executes
|
|
123
|
+
5. If `validate_resource=True`, the `aud` claim must match the server URL (RFC 8707)
|
|
124
|
+
|
|
125
|
+
## API Reference
|
|
126
|
+
|
|
127
|
+
### Token Verification
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from mcp_resource_framework import IntrospectionTokenVerifier
|
|
131
|
+
|
|
132
|
+
verifier = IntrospectionTokenVerifier(
|
|
133
|
+
introspection_endpoint="http://auth-server:8000/introspect",
|
|
134
|
+
server_url="https://mcp.example.com",
|
|
135
|
+
validate_resource=False, # Set True for RFC 8707 resource binding
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Returns AccessToken or None
|
|
139
|
+
token = await verifier.verify_token("Bearer_token_here")
|
|
140
|
+
# token.client_id, token.scopes, token.expires_at, token.resource
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### SSRF Protection
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from mcp_resource_framework import is_safe_url
|
|
147
|
+
|
|
148
|
+
is_safe_url("https://api.example.com") # True (HTTPS always safe)
|
|
149
|
+
is_safe_url("http://localhost:8000") # True (localhost allowed by default)
|
|
150
|
+
is_safe_url("http://mcp-auth") # True (Docker/k8s service name)
|
|
151
|
+
is_safe_url("http://evil.example.com") # False (HTTP to external host)
|
|
152
|
+
is_safe_url("http://localhost", allow_localhost=False) # False
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### OAuth Discovery
|
|
156
|
+
|
|
157
|
+
Auto-configures `.well-known` endpoints so MCP clients can discover your auth server:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from mcp_resource_framework import register_oauth_discovery_endpoints
|
|
161
|
+
|
|
162
|
+
register_oauth_discovery_endpoints(
|
|
163
|
+
app,
|
|
164
|
+
server_url="https://mcp.example.com",
|
|
165
|
+
auth_server_public_url="https://auth.example.com",
|
|
166
|
+
scopes=["read", "write"],
|
|
167
|
+
resource_documentation="https://docs.example.com/mcp",
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Registered endpoints:**
|
|
172
|
+
|
|
173
|
+
| Endpoint | Spec |
|
|
174
|
+
|----------|------|
|
|
175
|
+
| `GET /.well-known/oauth-protected-resource` | RFC 9908 |
|
|
176
|
+
| `GET /mcp/.well-known/oauth-protected-resource` | RFC 9908 (path-scoped) |
|
|
177
|
+
| `GET /.well-known/oauth-authorization-server` | RFC 8414 |
|
|
178
|
+
| `GET /.well-known/oauth-authorization-server/mcp` | RFC 8414 (path-scoped) |
|
|
179
|
+
| `GET /.well-known/openid-configuration` | OIDC Discovery |
|
|
180
|
+
|
|
181
|
+
### Friction Control
|
|
182
|
+
|
|
183
|
+
Dynamic tool-call rate limiting that adjusts friction per-tool based on observed usage, converging toward configured targets. Inspired by proof-of-work difficulty adjustment.
|
|
184
|
+
|
|
185
|
+
#### Setup
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from mcp_resource_framework import (
|
|
189
|
+
ControllerConfig,
|
|
190
|
+
FrictionRegistry,
|
|
191
|
+
ToolFrictionConfig,
|
|
192
|
+
ToolGroupConfig,
|
|
193
|
+
friction_controlled,
|
|
194
|
+
init_friction,
|
|
195
|
+
record_tool_call,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Initialize at server startup
|
|
199
|
+
init_friction(FrictionRegistry(
|
|
200
|
+
default_config=ControllerConfig(
|
|
201
|
+
window_size=100, # Sliding window of last 100 calls
|
|
202
|
+
time_decay_rate=0.001, # ~11.5 min half-life for idle decay
|
|
203
|
+
warmup_calls=20, # No adjustment during first 20 calls
|
|
204
|
+
),
|
|
205
|
+
tool_configs={
|
|
206
|
+
"delete_task": ToolFrictionConfig(target_rate=0.03), # 3% of calls
|
|
207
|
+
"update_task": ToolFrictionConfig(target_rate=0.10), # 10% of calls
|
|
208
|
+
},
|
|
209
|
+
tool_groups={
|
|
210
|
+
"mutations": ToolGroupConfig(
|
|
211
|
+
tools=["delete_task", "update_task"],
|
|
212
|
+
aggregate_target=0.20, # Combined 20% of all calls
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
))
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
#### Decorators
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
# Mutation tools: checks friction before execution, blocks if too high
|
|
222
|
+
@app.tool()
|
|
223
|
+
@friction_controlled()
|
|
224
|
+
async def delete_task(task_id: str) -> str:
|
|
225
|
+
...
|
|
226
|
+
|
|
227
|
+
# Read tools: records call without friction checks (for rate denominator)
|
|
228
|
+
@app.tool()
|
|
229
|
+
@record_tool_call()
|
|
230
|
+
async def get_tasks(status: str) -> str:
|
|
231
|
+
...
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
#### How It Works
|
|
235
|
+
|
|
236
|
+
The friction controller tracks tool calls in a sliding window and computes an exponential moving average (EMA) of each tool's usage rate. When a tool's rate exceeds its target, friction increases -- raising the cost and eventually blocking calls. When usage drops, friction decreases (2x faster than it rises).
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
Friction Level Effect
|
|
240
|
+
---------------------------------------------------------------------------
|
|
241
|
+
0.0 - 0.59 NONE/LOW/MEDIUM -- tool executes normally
|
|
242
|
+
0.60 - 0.94 HIGH -- justification_required=True in FrictionResult
|
|
243
|
+
0.95 - 1.0 BLOCKED -- tool call denied, error returned
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Key parameters:**
|
|
247
|
+
|
|
248
|
+
| Parameter | Default | Description |
|
|
249
|
+
|-----------|---------|-------------|
|
|
250
|
+
| `window_size` | 100 | Number of recent calls to track |
|
|
251
|
+
| `time_decay_rate` | 0.001 | Exponential friction decay (~11.5 min half-life) |
|
|
252
|
+
| `warmup_calls` | 20 | Calls before friction adjustment begins |
|
|
253
|
+
| `target_rate` | 0.05 | Desired tool usage fraction (0.0-1.0) |
|
|
254
|
+
| `justification_threshold` | 0.6 | Friction level requiring justification |
|
|
255
|
+
| `hard_block_threshold` | 0.95 | Friction level that blocks the call |
|
|
256
|
+
| `saturation_threshold` | 0.9 | Triggers automatic relief if sustained |
|
|
257
|
+
|
|
258
|
+
#### Observability
|
|
259
|
+
|
|
260
|
+
Friction events are emitted as structured JSON via Python's `logging` module:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
# Logger names
|
|
264
|
+
"mcp_resource_framework.friction" # check/record events (INFO)
|
|
265
|
+
"mcp_resource_framework.friction.block" # blocked calls (WARNING)
|
|
266
|
+
"mcp_resource_framework.friction.registry" # client lifecycle (DEBUG)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Event types: `friction_check`, `friction_block`, `friction_justification`, `friction_saturation`
|
|
270
|
+
|
|
271
|
+
Fields: `event_type`, `client_id`, `tool_name`, `friction_level`, `ema_rate`, `target_rate`, `cost`, `allowed`
|
|
272
|
+
|
|
273
|
+
### Response Validation
|
|
274
|
+
|
|
275
|
+
Helpers for validating API responses in MCP tool implementations:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from mcp_resource_framework.validation import (
|
|
279
|
+
json_error,
|
|
280
|
+
validate_list_response,
|
|
281
|
+
validate_dict_response,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Returns (list, None) on success or ([], "error message") on failure
|
|
285
|
+
items, error = validate_list_response(api_response, context="tasks")
|
|
286
|
+
if error:
|
|
287
|
+
return json_error(error)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Middleware
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
from mcp_resource_framework.middleware import NormalizePathMiddleware, create_logging_middleware
|
|
294
|
+
|
|
295
|
+
# Normalize trailing slashes: /mcp/ -> /mcp
|
|
296
|
+
app.add_middleware(NormalizePathMiddleware)
|
|
297
|
+
|
|
298
|
+
# Debug logging with auth header masking
|
|
299
|
+
app = create_logging_middleware(app, mask_auth=True)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Full Example: Auth Server + Resource Server
|
|
303
|
+
|
|
304
|
+
A complete working example using both packages together:
|
|
305
|
+
|
|
306
|
+
**auth_server.py** (authorization server):
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
import secrets
|
|
310
|
+
import time
|
|
311
|
+
from contextlib import asynccontextmanager
|
|
312
|
+
|
|
313
|
+
from starlette.applications import Starlette
|
|
314
|
+
from starlette.requests import Request
|
|
315
|
+
from starlette.responses import JSONResponse
|
|
316
|
+
from starlette.routing import Route
|
|
317
|
+
|
|
318
|
+
from mcp_auth_framework.responses import invalid_request
|
|
319
|
+
from mcp_auth_framework.storage import MemoryTokenStorage
|
|
320
|
+
from mcp_auth_framework.validation import parse_scope_field
|
|
321
|
+
|
|
322
|
+
storage = MemoryTokenStorage()
|
|
323
|
+
|
|
324
|
+
async def token(request: Request) -> JSONResponse:
|
|
325
|
+
form = await request.form()
|
|
326
|
+
client_id = str(form.get("client_id", ""))
|
|
327
|
+
if not client_id:
|
|
328
|
+
return invalid_request("client_id is required")
|
|
329
|
+
|
|
330
|
+
access_token = secrets.token_urlsafe(32)
|
|
331
|
+
scopes = parse_scope_field(form.get("scope"))
|
|
332
|
+
|
|
333
|
+
await storage.store_token(
|
|
334
|
+
token=access_token,
|
|
335
|
+
client_id=client_id,
|
|
336
|
+
scopes=scopes.split(),
|
|
337
|
+
expires_at=int(time.time()) + 3600,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return JSONResponse({
|
|
341
|
+
"access_token": access_token,
|
|
342
|
+
"token_type": "bearer",
|
|
343
|
+
"expires_in": 3600,
|
|
344
|
+
"scope": scopes,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
async def introspect(request: Request) -> JSONResponse:
|
|
348
|
+
form = await request.form()
|
|
349
|
+
token_str = str(form.get("token", ""))
|
|
350
|
+
data = await storage.load_token(token_str)
|
|
351
|
+
|
|
352
|
+
if not data or data["expires_at"] < time.time():
|
|
353
|
+
return JSONResponse({"active": False})
|
|
354
|
+
|
|
355
|
+
return JSONResponse({
|
|
356
|
+
"active": True,
|
|
357
|
+
"client_id": data["client_id"],
|
|
358
|
+
"scope": " ".join(data["scopes"]),
|
|
359
|
+
"exp": data["expires_at"],
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
@asynccontextmanager
|
|
363
|
+
async def lifespan(app):
|
|
364
|
+
await storage.initialize()
|
|
365
|
+
yield
|
|
366
|
+
await storage.close()
|
|
367
|
+
|
|
368
|
+
app = Starlette(
|
|
369
|
+
routes=[
|
|
370
|
+
Route("/token", token, methods=["POST"]),
|
|
371
|
+
Route("/introspect", introspect, methods=["POST"]),
|
|
372
|
+
],
|
|
373
|
+
lifespan=lifespan,
|
|
374
|
+
)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
**resource_server.py** (MCP resource server):
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
from mcp.server.fastmcp.server import FastMCP
|
|
381
|
+
|
|
382
|
+
from mcp_resource_framework import (
|
|
383
|
+
IntrospectionTokenVerifier,
|
|
384
|
+
register_oauth_discovery_endpoints,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
verifier = IntrospectionTokenVerifier(
|
|
388
|
+
introspection_endpoint="http://localhost:8000/introspect",
|
|
389
|
+
server_url="http://localhost:8001",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
app = FastMCP(name="Example MCP", token_verifier=verifier)
|
|
393
|
+
|
|
394
|
+
register_oauth_discovery_endpoints(
|
|
395
|
+
app,
|
|
396
|
+
server_url="http://localhost:8001",
|
|
397
|
+
auth_server_public_url="http://localhost:8000",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
@app.tool()
|
|
401
|
+
async def greet(name: str) -> str:
|
|
402
|
+
"""Say hello."""
|
|
403
|
+
return f"Hello, {name}!"
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
**Run both:**
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
# Terminal 1: Auth server
|
|
410
|
+
uvicorn auth_server:app --port 8000
|
|
411
|
+
|
|
412
|
+
# Terminal 2: Resource server
|
|
413
|
+
python resource_server.py # MCP SDK handles transport
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
**Test the flow:**
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
# Get a token
|
|
420
|
+
TOKEN=$(curl -s -X POST http://localhost:8000/token \
|
|
421
|
+
-d "client_id=test&scope=read" | jq -r .access_token)
|
|
422
|
+
|
|
423
|
+
# Call an MCP tool (via the MCP protocol, token in Authorization header)
|
|
424
|
+
curl http://localhost:8001/.well-known/oauth-protected-resource
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## License
|
|
428
|
+
|
|
429
|
+
MIT
|