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.
Files changed (36) hide show
  1. mcp_authflow_resource-0.1.0/.github/workflows/ci.yml +43 -0
  2. mcp_authflow_resource-0.1.0/.github/workflows/publish.yml +21 -0
  3. mcp_authflow_resource-0.1.0/.gitignore +15 -0
  4. mcp_authflow_resource-0.1.0/.python-version +1 -0
  5. mcp_authflow_resource-0.1.0/LICENSE +21 -0
  6. mcp_authflow_resource-0.1.0/PKG-INFO +429 -0
  7. mcp_authflow_resource-0.1.0/README.md +400 -0
  8. mcp_authflow_resource-0.1.0/mcp_resource_framework/__init__.py +44 -0
  9. mcp_authflow_resource-0.1.0/mcp_resource_framework/auth/__init__.py +9 -0
  10. mcp_authflow_resource-0.1.0/mcp_resource_framework/auth/ssrf_protection.py +40 -0
  11. mcp_authflow_resource-0.1.0/mcp_resource_framework/auth/token_verifier.py +117 -0
  12. mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/__init__.py +31 -0
  13. mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/controller.py +304 -0
  14. mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/decorator.py +137 -0
  15. mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/logging.py +101 -0
  16. mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/models.py +108 -0
  17. mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/registry.py +150 -0
  18. mcp_authflow_resource-0.1.0/mcp_resource_framework/friction/window.py +69 -0
  19. mcp_authflow_resource-0.1.0/mcp_resource_framework/middleware.py +137 -0
  20. mcp_authflow_resource-0.1.0/mcp_resource_framework/oauth_discovery.py +196 -0
  21. mcp_authflow_resource-0.1.0/mcp_resource_framework/py.typed +0 -0
  22. mcp_authflow_resource-0.1.0/mcp_resource_framework/validation.py +167 -0
  23. mcp_authflow_resource-0.1.0/pyproject.toml +67 -0
  24. mcp_authflow_resource-0.1.0/tests/conftest.py +33 -0
  25. mcp_authflow_resource-0.1.0/tests/friction/__init__.py +0 -0
  26. mcp_authflow_resource-0.1.0/tests/friction/test_controller.py +224 -0
  27. mcp_authflow_resource-0.1.0/tests/friction/test_decorator.py +153 -0
  28. mcp_authflow_resource-0.1.0/tests/friction/test_load.py +447 -0
  29. mcp_authflow_resource-0.1.0/tests/friction/test_registry.py +128 -0
  30. mcp_authflow_resource-0.1.0/tests/friction/test_window.py +69 -0
  31. mcp_authflow_resource-0.1.0/tests/test_middleware.py +362 -0
  32. mcp_authflow_resource-0.1.0/tests/test_oauth_discovery.py +221 -0
  33. mcp_authflow_resource-0.1.0/tests/test_ssrf_protection.py +60 -0
  34. mcp_authflow_resource-0.1.0/tests/test_token_verifier.py +442 -0
  35. mcp_authflow_resource-0.1.0/tests/test_validation.py +250 -0
  36. 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,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ .ruff_cache/
12
+ .pyright/
13
+ .pytest_cache/
14
+ .coverage
15
+ htmlcov/
@@ -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