Skip to content

Commit def60e3

Browse files
Merge branch 'main' into feat/keycloak-auth-provider
2 parents 3c8ed2b + c8ddbff commit def60e3

File tree

14 files changed

+547
-40
lines changed

14 files changed

+547
-40
lines changed

.github/workflows/marvin.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,6 @@ jobs:
7474
"model": "claude-sonnet-4-5-20250929",
7575
"env": {
7676
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
77-
}
77+
},
78+
"customInstructions": "When you complete work on an issue: (1) You MUST create a pull request using the mcp__github__create_pull_request tool instead of posting a link, and (2) You MUST add the 'marvin-pr' label to the original issue using mcp__github__update_issue. Even if PR creation fails and you post a link instead, you MUST still add the 'marvin-pr' label. Follow the PR message guidelines in CLAUDE.md."
7879
}

docs/servers/auth/token-verification.mdx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,67 @@ Static token verification stores tokens as plain text and should never be used i
210210
</Warning>
211211

212212

213+
### Debug/Custom Token Verification
214+
215+
The `DebugTokenVerifier` provides maximum flexibility for testing and special cases where standard token verification isn't applicable. It delegates validation to a user-provided callable, making it useful for prototyping, testing scenarios, or handling opaque tokens without introspection endpoints.
216+
217+
```python
218+
from fastmcp import FastMCP
219+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
220+
221+
# Accept all tokens (useful for rapid development)
222+
verifier = DebugTokenVerifier()
223+
224+
mcp = FastMCP(name="Development Server", auth=verifier)
225+
```
226+
227+
By default, `DebugTokenVerifier` accepts any non-empty token as valid. This eliminates authentication barriers during early development, allowing you to focus on core functionality before adding security.
228+
229+
For more controlled testing, provide custom validation logic:
230+
231+
```python
232+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
233+
234+
# Synchronous validation - check token prefix
235+
verifier = DebugTokenVerifier(
236+
validate=lambda token: token.startswith("dev-"),
237+
client_id="development-client",
238+
scopes=["read", "write"]
239+
)
240+
241+
mcp = FastMCP(name="Development Server", auth=verifier)
242+
```
243+
244+
The validation callable can also be async, enabling database lookups or external service calls:
245+
246+
```python
247+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
248+
249+
# Asynchronous validation - check against cache
250+
async def validate_token(token: str) -> bool:
251+
# Check if token exists in Redis, database, etc.
252+
return await redis.exists(f"valid_tokens:{token}")
253+
254+
verifier = DebugTokenVerifier(
255+
validate=validate_token,
256+
client_id="api-client",
257+
scopes=["api:access"]
258+
)
259+
260+
mcp = FastMCP(name="Custom API", auth=verifier)
261+
```
262+
263+
**Use Cases:**
264+
265+
- **Testing**: Accept any token during integration tests without setting up token infrastructure
266+
- **Prototyping**: Quickly validate concepts without authentication complexity
267+
- **Opaque tokens without introspection**: When you have tokens from an IDP that provides no introspection endpoint, and you're willing to accept tokens without validation (validation happens later at the upstream service)
268+
- **Custom token formats**: Implement validation for non-standard token formats or legacy systems
269+
270+
<Warning>
271+
`DebugTokenVerifier` bypasses standard security checks. Only use in controlled environments (development, testing) or when you fully understand the security implications. For production, use proper JWT or introspection-based verification.
272+
</Warning>
273+
213274
### Test Token Generation
214275

215276
Test token generation helps when you need to test JWT verification without setting up complete identity infrastructure. FastMCP includes utilities for generating test key pairs and signed tokens.

docs/servers/icons.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ For small icons or when you want to embed the icon directly, use data URIs:
115115

116116
```python
117117
from mcp.types import Icon
118+
from fastmcp.utilities.types import Image
118119

119120
# SVG icon as data URI
120121
svg_icon = Icon(
@@ -126,4 +127,13 @@ svg_icon = Icon(
126127
def my_tool() -> str:
127128
"""A tool with an embedded SVG icon."""
128129
return "result"
130+
131+
# Generating a data URI from a local image file.
132+
img = Image(path="./assets/brand/favicon.png")
133+
icon = Icon(src=img.to_data_uri())
134+
135+
@mcp.tool(icons=[icon])
136+
def file_icon_tool() -> str:
137+
"""A tool with an icon generated from a local file."""
138+
return "result"
129139
```

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ dependencies = [
1212
"platformdirs>=4.0.0",
1313
"rich>=13.9.4",
1414
"cyclopts>=3.0.0",
15-
"authlib>=1.5.2",
15+
"authlib>=1.6.5",
1616
"pydantic[email]>=2.11.7",
1717
"pyperclip>=1.9.0",
1818
"py-key-value-aio[disk,keyring,memory]>=0.2.8,<0.3.0",
@@ -103,6 +103,11 @@ fallback-version = "0.0.0"
103103
[tool.pytest.ini_options]
104104
asyncio_mode = "auto"
105105
# filterwarnings = ["error::DeprecationWarning"]
106+
filterwarnings = [
107+
# Suppress OAuth in-memory token storage warnings in tests
108+
# Tests intentionally use ephemeral storage; this warning is for end users
109+
"ignore:Using in-memory token storage:UserWarning",
110+
]
106111
timeout = 5
107112
env = [
108113
"FASTMCP_TEST_MODE=1",

src/fastmcp/resources/resource_manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ async def has_resource(self, uri: AnyUrl | str) -> bool:
236236
# Then check templates (local and mounted) only if not found in concrete resources
237237
templates = await self.get_resource_templates()
238238
for template_key in templates:
239-
if match_uri_template(uri_str, template_key):
239+
if match_uri_template(uri_str, template_key) is not None:
240240
return True
241241

242242
return False
@@ -262,7 +262,7 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource:
262262
templates = await self.get_resource_templates()
263263
for storage_key, template in templates.items():
264264
# Try to match against the storage key (which might be a custom key)
265-
if params := match_uri_template(uri_str, storage_key):
265+
if (params := match_uri_template(uri_str, storage_key)) is not None:
266266
try:
267267
return await template.create_resource(
268268
uri_str,
@@ -318,7 +318,7 @@ async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
318318

319319
# 1b. Check local templates if not found in concrete resources
320320
for key, template in self._templates.items():
321-
if params := match_uri_template(uri_str, key):
321+
if (params := match_uri_template(uri_str, key)) is not None:
322322
try:
323323
resource = await template.create_resource(uri_str, params=params)
324324
return await resource.read()

src/fastmcp/server/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
AccessToken,
66
AuthProvider,
77
)
8+
from .providers.debug import DebugTokenVerifier
89
from .providers.jwt import JWTVerifier, StaticTokenVerifier
910
from .oauth_proxy import OAuthProxy
1011
from .oidc_proxy import OIDCProxy
@@ -13,6 +14,7 @@
1314
__all__ = [
1415
"AccessToken",
1516
"AuthProvider",
17+
"DebugTokenVerifier",
1618
"JWTVerifier",
1719
"OAuthProvider",
1820
"OAuthProxy",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Debug token verifier for testing and special cases.
2+
3+
This module provides a flexible token verifier that delegates validation
4+
to a custom callable. Useful for testing, development, or scenarios where
5+
standard verification isn't possible (like opaque tokens without introspection).
6+
7+
Example:
8+
```python
9+
from fastmcp import FastMCP
10+
from fastmcp.server.auth.providers.debug import DebugTokenVerifier
11+
12+
# Accept all tokens (default - useful for testing)
13+
auth = DebugTokenVerifier()
14+
15+
# Custom sync validation logic
16+
auth = DebugTokenVerifier(validate=lambda token: token.startswith("valid-"))
17+
18+
# Custom async validation logic
19+
async def check_cache(token: str) -> bool:
20+
return await redis.exists(f"token:{token}")
21+
22+
auth = DebugTokenVerifier(validate=check_cache)
23+
24+
mcp = FastMCP("My Server", auth=auth)
25+
```
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import inspect
31+
from collections.abc import Awaitable, Callable
32+
33+
from fastmcp.server.auth import TokenVerifier
34+
from fastmcp.server.auth.auth import AccessToken
35+
from fastmcp.utilities.logging import get_logger
36+
37+
logger = get_logger(__name__)
38+
39+
40+
class DebugTokenVerifier(TokenVerifier):
41+
"""Token verifier with custom validation logic.
42+
43+
This verifier delegates token validation to a user-provided callable.
44+
By default, it accepts all non-empty tokens (useful for testing).
45+
46+
Use cases:
47+
- Testing: Accept any token without real verification
48+
- Development: Custom validation logic for prototyping
49+
- Opaque tokens: When you have tokens with no introspection endpoint
50+
51+
WARNING: This bypasses standard security checks. Only use in controlled
52+
environments or when you understand the security implications.
53+
"""
54+
55+
def __init__(
56+
self,
57+
validate: Callable[[str], bool]
58+
| Callable[[str], Awaitable[bool]] = lambda token: True,
59+
client_id: str = "debug-client",
60+
scopes: list[str] | None = None,
61+
required_scopes: list[str] | None = None,
62+
):
63+
"""Initialize the debug token verifier.
64+
65+
Args:
66+
validate: Callable that takes a token string and returns True if valid.
67+
Can be sync or async. Default accepts all tokens.
68+
client_id: Client ID to assign to validated tokens
69+
scopes: Scopes to assign to validated tokens
70+
required_scopes: Required scopes (inherited from TokenVerifier base class)
71+
"""
72+
super().__init__(required_scopes=required_scopes)
73+
self.validate = validate
74+
self.client_id = client_id
75+
self.scopes = scopes or []
76+
77+
async def verify_token(self, token: str) -> AccessToken | None:
78+
"""Verify token using custom validation logic.
79+
80+
Args:
81+
token: The token string to validate
82+
83+
Returns:
84+
AccessToken if validation succeeds, None otherwise
85+
"""
86+
# Reject empty tokens
87+
if not token or not token.strip():
88+
logger.debug("Rejecting empty token")
89+
return None
90+
91+
try:
92+
# Call validation function and await if result is awaitable
93+
result = self.validate(token)
94+
if inspect.isawaitable(result):
95+
is_valid = await result
96+
else:
97+
is_valid = result
98+
99+
if not is_valid:
100+
logger.debug("Token validation failed: callable returned False")
101+
return None
102+
103+
# Return valid AccessToken
104+
return AccessToken(
105+
token=token,
106+
client_id=self.client_id,
107+
scopes=self.scopes,
108+
expires_at=None, # No expiration
109+
claims={"token": token}, # Store original token in claims
110+
)
111+
112+
except Exception as e:
113+
logger.debug("Token validation error: %s", e, exc_info=True)
114+
return None

src/fastmcp/utilities/types.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -190,41 +190,49 @@ def __init__(
190190
if path is not None and data is not None:
191191
raise ValueError("Only one of path or data can be provided")
192192

193-
self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None
193+
self.path = self._get_expanded_path(path)
194194
self.data = data
195195
self._format = format
196196
self._mime_type = self._get_mime_type()
197197
self.annotations = annotations
198198

199+
@staticmethod
200+
def _get_expanded_path(path: str | Path | None) -> Path | None:
201+
"""Expand environment variables and user home in path."""
202+
return Path(os.path.expandvars(str(path))).expanduser() if path else None
203+
199204
def _get_mime_type(self) -> str:
200205
"""Get MIME type from format or guess from file extension."""
201206
if self._format:
202207
return f"image/{self._format.lower()}"
203208

204209
if self.path:
205-
suffix = self.path.suffix.lower()
206-
return {
207-
".png": "image/png",
208-
".jpg": "image/jpeg",
209-
".jpeg": "image/jpeg",
210-
".gif": "image/gif",
211-
".webp": "image/webp",
212-
}.get(suffix, "application/octet-stream")
210+
# Workaround for WEBP in Py3.10
211+
mimetypes.add_type("image/webp", ".webp")
212+
resp = mimetypes.guess_type(self.path, strict=False)
213+
if resp and resp[0] is not None:
214+
return resp[0]
215+
return "application/octet-stream"
213216
return "image/png" # default for raw binary data
214217

215-
def to_image_content(
216-
self,
217-
mime_type: str | None = None,
218-
annotations: Annotations | None = None,
219-
) -> mcp.types.ImageContent:
220-
"""Convert to MCP ImageContent."""
218+
def _get_data(self) -> str:
219+
"""Get raw image data as base64-encoded string."""
221220
if self.path:
222221
with open(self.path, "rb") as f:
223222
data = base64.b64encode(f.read()).decode()
224223
elif self.data is not None:
225224
data = base64.b64encode(self.data).decode()
226225
else:
227226
raise ValueError("No image data available")
227+
return data
228+
229+
def to_image_content(
230+
self,
231+
mime_type: str | None = None,
232+
annotations: Annotations | None = None,
233+
) -> mcp.types.ImageContent:
234+
"""Convert to MCP ImageContent."""
235+
data = self._get_data()
228236

229237
return mcp.types.ImageContent(
230238
type="image",
@@ -233,6 +241,11 @@ def to_image_content(
233241
annotations=annotations or self.annotations,
234242
)
235243

244+
def to_data_uri(self, mime_type: str | None = None) -> str:
245+
"""Get image as a data URI."""
246+
data = self._get_data()
247+
return f"data:{mime_type or self._mime_type};base64,{data}"
248+
236249

237250
class Audio:
238251
"""Helper class for returning audio from tools."""

tests/client/test_sse.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,13 @@ async def nested_sse_server():
9494
from starlette.applications import Starlette
9595
from starlette.routing import Mount
9696

97+
from fastmcp.server.http import create_sse_app
9798
from fastmcp.utilities.http import find_available_port
9899

99100
server = create_test_server()
100-
sse_app = server.sse_app(path="/mcp/sse/", message_path="/mcp/messages")
101+
sse_app = create_sse_app(
102+
server=server, message_path="/mcp/messages", sse_path="/mcp/sse/"
103+
)
101104

102105
# Nest the app under multiple mounts to test URL resolution
103106
inner = Starlette(routes=[Mount("/nest-inner", app=sse_app)])

0 commit comments

Comments
 (0)