fastmcp 2.13.0rc1__py3-none-any.whl → 2.13.0rc3__py3-none-any.whl

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.
fastmcp/cli/cli.py CHANGED
@@ -772,6 +772,7 @@ async def inspect(
772
772
  "server_spec": server_spec,
773
773
  "error": str(e),
774
774
  },
775
+ exc_info=True,
775
776
  )
776
777
  console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}")
777
778
  sys.exit(1)
@@ -125,15 +125,15 @@ def install_claude_code(
125
125
  full_command = env_config.build_command(["fastmcp", "run", server_spec])
126
126
 
127
127
  # Build claude mcp add command
128
- cmd_parts = [claude_cmd, "mcp", "add"]
128
+ cmd_parts = [claude_cmd, "mcp", "add", name]
129
129
 
130
- # Add environment variables if specified (before the name and command)
130
+ # Add environment variables if specified
131
131
  if env_vars:
132
132
  for key, value in env_vars.items():
133
133
  cmd_parts.extend(["-e", f"{key}={value}"])
134
134
 
135
135
  # Add server name and command
136
- cmd_parts.extend([name, "--"])
136
+ cmd_parts.append("--")
137
137
  cmd_parts.extend(full_command)
138
138
 
139
139
  try:
@@ -46,9 +46,13 @@ def create_callback_html(
46
46
  # Add detail info box for both success and error cases
47
47
  detail_info = ""
48
48
  if is_success and server_url:
49
- detail_info = create_info_box(f"Connected to: {server_url}", centered=True)
49
+ detail_info = create_info_box(
50
+ f"Connected to: {server_url}", centered=True, monospace=True
51
+ )
50
52
  elif not is_success:
51
- detail_info = create_info_box(message, is_error=True, centered=True)
53
+ detail_info = create_info_box(
54
+ message, is_error=True, centered=True, monospace=True
55
+ )
52
56
 
53
57
  # Build the page content
54
58
  content = f"""
@@ -748,6 +748,7 @@ class UvxStdioTransport(StdioTransport):
748
748
  env: dict[str, str] | None = None
749
749
  if env_vars:
750
750
  env = os.environ.copy()
751
+ env.update(env_vars)
751
752
 
752
753
  super().__init__(
753
754
  command="uvx",
@@ -5,11 +5,11 @@ from __future__ import annotations
5
5
  import json
6
6
  from pathlib import Path
7
7
 
8
- import anyio
9
- import anyio.to_thread
10
8
  import httpx
11
9
  import pydantic.json
10
+ from anyio import Path as AsyncPath
12
11
  from pydantic import Field, ValidationInfo
12
+ from typing_extensions import override
13
13
 
14
14
  from fastmcp.exceptions import ResourceError
15
15
  from fastmcp.resources.resource import Resource
@@ -54,6 +54,10 @@ class FileResource(Resource):
54
54
  description="MIME type of the resource content",
55
55
  )
56
56
 
57
+ @property
58
+ def _async_path(self) -> AsyncPath:
59
+ return AsyncPath(self.path)
60
+
57
61
  @pydantic.field_validator("path")
58
62
  @classmethod
59
63
  def validate_absolute_path(cls, path: Path) -> Path:
@@ -71,12 +75,13 @@ class FileResource(Resource):
71
75
  mime_type = info.data.get("mime_type", "text/plain")
72
76
  return not mime_type.startswith("text/")
73
77
 
78
+ @override
74
79
  async def read(self) -> str | bytes:
75
80
  """Read the file content."""
76
81
  try:
77
82
  if self.is_binary:
78
- return await anyio.to_thread.run_sync(self.path.read_bytes)
79
- return await anyio.to_thread.run_sync(self.path.read_text)
83
+ return await self._async_path.read_bytes()
84
+ return await self._async_path.read_text()
80
85
  except Exception as e:
81
86
  raise ResourceError(f"Error reading file {self.path}") from e
82
87
 
@@ -89,11 +94,12 @@ class HttpResource(Resource):
89
94
  default="application/json", description="MIME type of the resource content"
90
95
  )
91
96
 
97
+ @override
92
98
  async def read(self) -> str | bytes:
93
99
  """Read the HTTP content."""
94
100
  async with httpx.AsyncClient() as client:
95
101
  response = await client.get(self.url)
96
- response.raise_for_status()
102
+ _ = response.raise_for_status()
97
103
  return response.text
98
104
 
99
105
 
@@ -111,6 +117,10 @@ class DirectoryResource(Resource):
111
117
  default="application/json", description="MIME type of the resource content"
112
118
  )
113
119
 
120
+ @property
121
+ def _async_path(self) -> AsyncPath:
122
+ return AsyncPath(self.path)
123
+
114
124
  @pydantic.field_validator("path")
115
125
  @classmethod
116
126
  def validate_absolute_path(cls, path: Path) -> Path:
@@ -119,33 +129,29 @@ class DirectoryResource(Resource):
119
129
  raise ValueError("Path must be absolute")
120
130
  return path
121
131
 
122
- def list_files(self) -> list[Path]:
132
+ async def list_files(self) -> list[Path]:
123
133
  """List files in the directory."""
124
- if not self.path.exists():
134
+ if not await self._async_path.exists():
125
135
  raise FileNotFoundError(f"Directory not found: {self.path}")
126
- if not self.path.is_dir():
136
+ if not await self._async_path.is_dir():
127
137
  raise NotADirectoryError(f"Not a directory: {self.path}")
128
138
 
139
+ pattern = self.pattern or "*"
140
+
141
+ glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob
129
142
  try:
130
- if self.pattern:
131
- return (
132
- list(self.path.glob(self.pattern))
133
- if not self.recursive
134
- else list(self.path.rglob(self.pattern))
135
- )
136
- return (
137
- list(self.path.glob("*"))
138
- if not self.recursive
139
- else list(self.path.rglob("*"))
140
- )
143
+ return [Path(p) async for p in glob_fn(pattern) if await p.is_file()]
141
144
  except Exception as e:
142
- raise ResourceError(f"Error listing directory {self.path}: {e}")
145
+ raise ResourceError(f"Error listing directory {self.path}") from e
143
146
 
147
+ @override
144
148
  async def read(self) -> str: # Always returns JSON string
145
149
  """Read the directory listing."""
146
150
  try:
147
- files = await anyio.to_thread.run_sync(self.list_files)
148
- file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
151
+ files: list[Path] = await self.list_files()
152
+
153
+ file_list = [str(f.relative_to(self.path)) for f in files]
154
+
149
155
  return json.dumps({"files": file_list}, indent=2)
150
- except Exception:
151
- raise ResourceError(f"Error reading directory {self.path}")
156
+ except Exception as e:
157
+ raise ResourceError(f"Error reading directory {self.path}") from e
@@ -0,0 +1,324 @@
1
+ """Enhanced authorization handler with improved error responses.
2
+
3
+ This module provides an enhanced authorization handler that wraps the MCP SDK's
4
+ AuthorizationHandler to provide better error messages when clients attempt to
5
+ authorize with unregistered client IDs.
6
+
7
+ The enhancement adds:
8
+ - Content negotiation: HTML for browsers, JSON for API clients
9
+ - Enhanced JSON responses with registration endpoint hints
10
+ - Styled HTML error pages with registration links/forms
11
+ - Link headers pointing to registration endpoints
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from mcp.server.auth.handlers.authorize import (
19
+ AuthorizationHandler as SDKAuthorizationHandler,
20
+ )
21
+ from pydantic import AnyHttpUrl
22
+ from starlette.requests import Request
23
+ from starlette.responses import Response
24
+
25
+ from fastmcp.utilities.logging import get_logger
26
+ from fastmcp.utilities.ui import (
27
+ INFO_BOX_STYLES,
28
+ TOOLTIP_STYLES,
29
+ create_logo,
30
+ create_page,
31
+ create_secure_html_response,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ from mcp.server.auth.provider import OAuthAuthorizationServerProvider
36
+
37
+ logger = get_logger(__name__)
38
+
39
+
40
+ def create_unregistered_client_html(
41
+ client_id: str,
42
+ registration_endpoint: str,
43
+ discovery_endpoint: str,
44
+ server_name: str | None = None,
45
+ server_icon_url: str | None = None,
46
+ title: str = "Client Not Registered",
47
+ ) -> str:
48
+ """Create styled HTML error page for unregistered client attempts.
49
+
50
+ Args:
51
+ client_id: The unregistered client ID that was provided
52
+ registration_endpoint: URL of the registration endpoint
53
+ discovery_endpoint: URL of the OAuth metadata discovery endpoint
54
+ server_name: Optional server name for branding
55
+ server_icon_url: Optional server icon URL
56
+ title: Page title
57
+
58
+ Returns:
59
+ HTML string for the error page
60
+ """
61
+ import html as html_module
62
+
63
+ client_id_escaped = html_module.escape(client_id)
64
+
65
+ # Main error message
66
+ error_box = f"""
67
+ <div class="info-box error">
68
+ <p>The client ID <code>{client_id_escaped}</code> was not found in the server's client registry.</p>
69
+ </div>
70
+ """
71
+
72
+ # What to do - yellow warning box
73
+ warning_box = """
74
+ <div class="info-box warning">
75
+ <p>Your MCP client opened this page to complete OAuth authorization,
76
+ but the server did not recognize its client ID. To fix this:</p>
77
+ <ul>
78
+ <li>Close this browser window</li>
79
+ <li>Clear authentication tokens in your MCP client (or restart it)</li>
80
+ <li>Try connecting again - your client should automatically re-register</li>
81
+ </ul>
82
+ </div>
83
+ """
84
+
85
+ # Help link with tooltip (similar to consent screen)
86
+ help_link = """
87
+ <div class="help-link-container">
88
+ <span class="help-link">
89
+ Why am I seeing this?
90
+ <span class="tooltip">
91
+ OAuth 2.0 requires clients to register before authorization.
92
+ This server returned a 400 error because the provided client
93
+ ID was not found.
94
+ <br><br>
95
+ In browser-delegated OAuth flows, your application cannot
96
+ detect this error automatically; it's waiting for a
97
+ callback that will never arrive. You must manually clear
98
+ auth tokens and reconnect.
99
+ </span>
100
+ </span>
101
+ </div>
102
+ """
103
+
104
+ # Build page content
105
+ content = f"""
106
+ <div class="container">
107
+ {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}
108
+ <h1>{title}</h1>
109
+ {error_box}
110
+ {warning_box}
111
+ </div>
112
+ {help_link}
113
+ """
114
+
115
+ # Use same styles as consent page
116
+ additional_styles = (
117
+ INFO_BOX_STYLES
118
+ + TOOLTIP_STYLES
119
+ + """
120
+ /* Error variant for info-box */
121
+ .info-box.error {
122
+ background: #fef2f2;
123
+ border-color: #f87171;
124
+ }
125
+ .info-box.error strong {
126
+ color: #991b1b;
127
+ }
128
+ /* Warning variant for info-box (yellow) */
129
+ .info-box.warning {
130
+ background: #fffbeb;
131
+ border-color: #fbbf24;
132
+ }
133
+ .info-box.warning strong {
134
+ color: #92400e;
135
+ }
136
+ .info-box code {
137
+ background: rgba(0, 0, 0, 0.05);
138
+ padding: 2px 6px;
139
+ border-radius: 3px;
140
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
141
+ font-size: 0.9em;
142
+ }
143
+ .info-box ul {
144
+ margin: 10px 0;
145
+ padding-left: 20px;
146
+ }
147
+ .info-box li {
148
+ margin: 6px 0;
149
+ }
150
+ """
151
+ )
152
+
153
+ return create_page(
154
+ content=content,
155
+ title=title,
156
+ additional_styles=additional_styles,
157
+ )
158
+
159
+
160
+ class AuthorizationHandler(SDKAuthorizationHandler):
161
+ """Authorization handler with enhanced error responses for unregistered clients.
162
+
163
+ This handler extends the MCP SDK's AuthorizationHandler to provide better UX
164
+ when clients attempt to authorize without being registered. It implements
165
+ content negotiation to return:
166
+
167
+ - HTML error pages for browser requests
168
+ - Enhanced JSON with registration hints for API clients
169
+ - Link headers pointing to registration endpoints
170
+
171
+ This maintains OAuth 2.1 compliance (returns 400 for invalid client_id)
172
+ while providing actionable guidance to fix the error.
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ provider: OAuthAuthorizationServerProvider,
178
+ base_url: AnyHttpUrl | str,
179
+ server_name: str | None = None,
180
+ server_icon_url: str | None = None,
181
+ ):
182
+ """Initialize the enhanced authorization handler.
183
+
184
+ Args:
185
+ provider: OAuth authorization server provider
186
+ base_url: Base URL of the server for constructing endpoint URLs
187
+ server_name: Optional server name for branding
188
+ server_icon_url: Optional server icon URL for branding
189
+ """
190
+ super().__init__(provider)
191
+ self._base_url = str(base_url).rstrip("/")
192
+ self._server_name = server_name
193
+ self._server_icon_url = server_icon_url
194
+
195
+ async def handle(self, request: Request) -> Response:
196
+ """Handle authorization request with enhanced error responses.
197
+
198
+ This method extends the SDK's authorization handler and intercepts
199
+ errors for unregistered clients to provide better error responses
200
+ based on the client's Accept header.
201
+
202
+ Args:
203
+ request: The authorization request
204
+
205
+ Returns:
206
+ Response (redirect on success, error response on failure)
207
+ """
208
+ # Call the SDK handler
209
+ response = await super().handle(request)
210
+
211
+ # Check if this is a client not found error
212
+ if response.status_code == 400:
213
+ # Try to extract client_id from request for enhanced error
214
+ client_id = None
215
+ if request.method == "GET":
216
+ client_id = request.query_params.get("client_id")
217
+ else:
218
+ form = await request.form()
219
+ client_id = form.get("client_id")
220
+
221
+ # If we have a client_id and the error is about it not being found,
222
+ # enhance the response
223
+ if client_id:
224
+ try:
225
+ # Check if response body contains "not found" error
226
+ if hasattr(response, "body"):
227
+ import json
228
+
229
+ body = json.loads(response.body)
230
+ if (
231
+ body.get("error") == "invalid_request"
232
+ and "not found" in body.get("error_description", "").lower()
233
+ ):
234
+ return await self._create_enhanced_error_response(
235
+ request, client_id, body.get("state")
236
+ )
237
+ except Exception:
238
+ # If we can't parse the response, just return the original
239
+ pass
240
+
241
+ return response
242
+
243
+ async def _create_enhanced_error_response(
244
+ self, request: Request, client_id: str, state: str | None
245
+ ) -> Response:
246
+ """Create enhanced error response with content negotiation.
247
+
248
+ Args:
249
+ request: The original request
250
+ client_id: The unregistered client ID
251
+ state: The state parameter from the request
252
+
253
+ Returns:
254
+ HTML or JSON error response based on Accept header
255
+ """
256
+ registration_endpoint = f"{self._base_url}/register"
257
+ discovery_endpoint = f"{self._base_url}/.well-known/oauth-authorization-server"
258
+
259
+ # Extract server metadata from app state (same pattern as consent screen)
260
+ from fastmcp.server.server import FastMCP
261
+
262
+ fastmcp = getattr(request.app.state, "fastmcp_server", None)
263
+
264
+ if isinstance(fastmcp, FastMCP):
265
+ server_name = fastmcp.name
266
+ icons = fastmcp.icons
267
+ server_icon_url = icons[0].src if icons else None
268
+ else:
269
+ server_name = self._server_name
270
+ server_icon_url = self._server_icon_url
271
+
272
+ # Check Accept header for content negotiation
273
+ accept = request.headers.get("accept", "")
274
+
275
+ # Prefer HTML for browsers
276
+ if "text/html" in accept:
277
+ html = create_unregistered_client_html(
278
+ client_id=client_id,
279
+ registration_endpoint=registration_endpoint,
280
+ discovery_endpoint=discovery_endpoint,
281
+ server_name=server_name,
282
+ server_icon_url=server_icon_url,
283
+ )
284
+ response = create_secure_html_response(html, status_code=400)
285
+ else:
286
+ # Return enhanced JSON for API clients
287
+ from mcp.server.auth.handlers.authorize import AuthorizationErrorResponse
288
+
289
+ error_data = AuthorizationErrorResponse(
290
+ error="invalid_request",
291
+ error_description=(
292
+ f"Client ID '{client_id}' is not registered with this server. "
293
+ f"MCP clients should automatically re-register by sending a POST request to "
294
+ f"the registration_endpoint and retry authorization. "
295
+ f"If this persists, clear cached authentication tokens and reconnect."
296
+ ),
297
+ state=state,
298
+ )
299
+
300
+ # Add extra fields to help clients discover registration
301
+ error_dict = error_data.model_dump(exclude_none=True)
302
+ error_dict["registration_endpoint"] = registration_endpoint
303
+ error_dict["authorization_server_metadata"] = discovery_endpoint
304
+
305
+ from starlette.responses import JSONResponse
306
+
307
+ response = JSONResponse(
308
+ status_code=400,
309
+ content=error_dict,
310
+ headers={"Cache-Control": "no-store"},
311
+ )
312
+
313
+ # Add Link header for registration endpoint discovery
314
+ response.headers["Link"] = (
315
+ f'<{registration_endpoint}>; rel="http://oauth.net/core/2.1/#registration"'
316
+ )
317
+
318
+ logger.info(
319
+ "Unregistered client_id=%s, returned %s error response",
320
+ client_id,
321
+ "HTML" if "text/html" in accept else "JSON",
322
+ )
323
+
324
+ return response
@@ -9,82 +9,66 @@ from __future__ import annotations
9
9
 
10
10
  import base64
11
11
  import time
12
- from typing import Any
12
+ from typing import Any, overload
13
13
 
14
14
  from authlib.jose import JsonWebToken
15
15
  from authlib.jose.errors import JoseError
16
- from cryptography.fernet import Fernet
17
16
  from cryptography.hazmat.primitives import hashes
18
17
  from cryptography.hazmat.primitives.kdf.hkdf import HKDF
18
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
19
 
20
20
  from fastmcp.utilities.logging import get_logger
21
21
 
22
22
  logger = get_logger(__name__)
23
23
 
24
+ KDF_ITERATIONS = 1000000
24
25
 
25
- def derive_jwt_key(upstream_secret: str, server_salt: str) -> bytes:
26
- """Derive JWT signing key from upstream client secret and server salt.
27
26
 
28
- Uses HKDF (RFC 5869) to derive a cryptographically secure signing key from
29
- the upstream OAuth client secret combined with a server-specific salt.
27
+ @overload
28
+ def derive_jwt_key(*, high_entropy_material: str, salt: str) -> bytes:
29
+ """Derive JWT signing key from a high-entropy key material and server salt."""
30
30
 
31
- Args:
32
- upstream_secret: The OAuth client secret from upstream provider
33
- server_salt: Random salt unique to this server instance
34
31
 
35
- Returns:
36
- 32-byte key suitable for HS256 JWT signing
37
- """
38
- return HKDF(
39
- algorithm=hashes.SHA256(),
40
- length=32,
41
- salt=f"fastmcp-jwt-signing-v1-{server_salt}".encode(),
42
- info=b"HS256",
43
- ).derive(upstream_secret.encode())
44
-
45
-
46
- def derive_encryption_key(upstream_secret: str) -> bytes:
47
- """Derive Fernet encryption key from upstream client secret.
48
-
49
- Uses HKDF to derive a cryptographically secure encryption key for
50
- encrypting upstream tokens at rest.
32
+ @overload
33
+ def derive_jwt_key(*, low_entropy_material: str, salt: str) -> bytes:
34
+ """Derive JWT signing key from a low-entropy key material and server salt."""
51
35
 
52
- Args:
53
- upstream_secret: The OAuth client secret from upstream provider
54
36
 
55
- Returns:
56
- 32-byte Fernet key (base64url-encoded)
57
- """
58
- key_material = HKDF(
59
- algorithm=hashes.SHA256(),
60
- length=32,
61
- salt=b"fastmcp-token-encryption-v1",
62
- info=b"Fernet",
63
- ).derive(upstream_secret.encode())
64
- return base64.urlsafe_b64encode(key_material)
37
+ def derive_jwt_key(
38
+ *,
39
+ high_entropy_material: str | None = None,
40
+ low_entropy_material: str | None = None,
41
+ salt: str,
42
+ ) -> bytes:
43
+ """Derive JWT signing key from a high-entropy or low-entropy key material and server salt."""
44
+ if high_entropy_material is not None and low_entropy_material is not None:
45
+ raise ValueError(
46
+ "Either high_entropy_material or low_entropy_material must be provided, but not both"
47
+ )
65
48
 
49
+ if high_entropy_material is not None:
50
+ derived_key = HKDF(
51
+ algorithm=hashes.SHA256(),
52
+ length=32,
53
+ salt=salt.encode(),
54
+ info=b"Fernet",
55
+ ).derive(key_material=high_entropy_material.encode())
66
56
 
67
- def derive_key_from_secret(secret: str | bytes, salt: str, info: bytes) -> bytes:
68
- """Derive 32-byte key from user-provided secret (string or bytes).
57
+ return base64.urlsafe_b64encode(derived_key)
69
58
 
70
- Accepts any length input and derives a proper cryptographic key.
71
- Uses HKDF to stretch weak inputs into strong keys.
59
+ if low_entropy_material is not None:
60
+ pbkdf2 = PBKDF2HMAC(
61
+ algorithm=hashes.SHA256(),
62
+ length=32,
63
+ salt=salt.encode(),
64
+ iterations=KDF_ITERATIONS,
65
+ ).derive(key_material=low_entropy_material.encode())
72
66
 
73
- Args:
74
- secret: User-provided secret (any string or bytes)
75
- salt: Application-specific salt string
76
- info: Key purpose identifier
67
+ return base64.urlsafe_b64encode(pbkdf2)
77
68
 
78
- Returns:
79
- 32-byte key suitable for HS256 JWT signing or Fernet encryption
80
- """
81
- secret_bytes = secret.encode() if isinstance(secret, str) else secret
82
- return HKDF(
83
- algorithm=hashes.SHA256(),
84
- length=32,
85
- salt=salt.encode(),
86
- info=info,
87
- ).derive(secret_bytes)
69
+ raise ValueError(
70
+ "Either high_entropy_material or low_entropy_material must be provided"
71
+ )
88
72
 
89
73
 
90
74
  class JWTIssuer:
@@ -250,40 +234,3 @@ class JWTIssuer:
250
234
  except JoseError as e:
251
235
  logger.debug("Token validation failed: %s", e)
252
236
  raise
253
-
254
-
255
- class TokenEncryption:
256
- """Handles encryption/decryption of upstream OAuth tokens at rest."""
257
-
258
- def __init__(self, encryption_key: bytes):
259
- """Initialize token encryption.
260
-
261
- Args:
262
- encryption_key: Fernet encryption key (32 bytes, base64url-encoded)
263
- """
264
- self._fernet = Fernet(encryption_key)
265
-
266
- def encrypt(self, token: str) -> bytes:
267
- """Encrypt a token for storage.
268
-
269
- Args:
270
- token: Plain text token
271
-
272
- Returns:
273
- Encrypted token bytes
274
- """
275
- return self._fernet.encrypt(token.encode())
276
-
277
- def decrypt(self, encrypted_token: bytes) -> str:
278
- """Decrypt a token from storage.
279
-
280
- Args:
281
- encrypted_token: Encrypted token bytes
282
-
283
- Returns:
284
- Plain text token
285
-
286
- Raises:
287
- cryptography.fernet.InvalidToken: If token is corrupted or key is wrong
288
- """
289
- return self._fernet.decrypt(encrypted_token).decode()