Jeremiah Lowin commited on
Commit
e58ace8
·
unverified ·
2 Parent(s): 457a7dee29e8a6

Merge branch 'main' into feature/exclude-args-support

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. docs/servers/fastmcp.mdx +1 -0
  2. pyproject.toml +4 -1
  3. src/fastmcp/client/__init__.py +2 -0
  4. src/fastmcp/client/auth.py +403 -0
  5. src/fastmcp/client/client.py +74 -26
  6. src/fastmcp/client/oauth_callback.py +310 -0
  7. src/fastmcp/client/transports.py +76 -14
  8. src/fastmcp/low_level/README.md +0 -1
  9. src/fastmcp/{low_level → server/auth}/__init__.py +0 -0
  10. src/fastmcp/server/auth/auth.py +45 -0
  11. src/fastmcp/{client/base.py → server/auth/providers/__init__.py} +0 -0
  12. src/fastmcp/server/auth/providers/bearer.py +377 -0
  13. src/fastmcp/server/auth/providers/bearer_env.py +62 -0
  14. src/fastmcp/server/auth/providers/in_memory.py +330 -0
  15. src/fastmcp/server/dependencies.py +10 -0
  16. src/fastmcp/server/http.py +38 -66
  17. src/fastmcp/server/server.py +17 -16
  18. src/fastmcp/settings.py +27 -8
  19. src/fastmcp/utilities/http.py +8 -0
  20. src/fastmcp/utilities/tests.py +22 -10
  21. src/fastmcp/py.typed → tests/auth/__init__.py +0 -0
  22. tests/auth/providers/test_bearer.py +635 -0
  23. tests/auth/providers/test_bearer_env.py +82 -0
  24. tests/auth/test_oauth_client.py +265 -0
  25. tests/cli/__init__.py +0 -0
  26. tests/client/__init__.py +0 -1
  27. tests/client/test_client.py +54 -14
  28. tests/client/test_openapi.py +26 -79
  29. tests/client/test_roots.py +1 -3
  30. tests/client/test_sse.py +12 -28
  31. tests/client/test_stdio.py +9 -19
  32. tests/client/test_streamable_http.py +23 -45
  33. tests/prompts/test_prompt_manager.py +2 -4
  34. tests/resources/test_file_resources.py +0 -1
  35. tests/server/http/__init__.py +0 -0
  36. tests/server/http/test_http_dependencies.py +10 -53
  37. tests/server/openapi/__init__.py +0 -0
  38. tests/server/openapi/test_openapi.py +16 -38
  39. tests/server/test_import_server.py +6 -14
  40. tests/server/test_lifespan.py +0 -396
  41. tests/server/test_logging.py +4 -3
  42. tests/server/test_mount.py +12 -26
  43. tests/server/test_proxy.py +31 -17
  44. tests/server/test_server.py +56 -78
  45. tests/server/test_server_interactions.py +59 -111
  46. tests/server/test_tool_annotations.py +3 -4
  47. tests/test_examples.py +8 -26
  48. tests/tools/test_tool.py +14 -32
  49. tests/tools/test_tool_manager.py +21 -90
  50. tests/utilities/test_mcp_config.py +2 -6
docs/servers/fastmcp.mdx CHANGED
@@ -35,6 +35,7 @@ The `FastMCP` constructor accepts several arguments:
35
  * `instructions`: (Optional) Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality.
36
  * `lifespan`: (Optional) An async context manager function for server startup and shutdown logic.
37
  * `tags`: (Optional) A set of strings to tag the server itself.
 
38
  * `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration
39
 
40
  ## Components
 
35
  * `instructions`: (Optional) Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality.
36
  * `lifespan`: (Optional) An async context manager function for server startup and shutdown logic.
37
  * `tags`: (Optional) A set of strings to tag the server itself.
38
+ * `tools`: (Optional) A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator.
39
  * `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration
40
 
41
  ## Components
pyproject.toml CHANGED
@@ -7,11 +7,12 @@ dependencies = [
7
  "python-dotenv>=1.1.0",
8
  "exceptiongroup>=1.2.2",
9
  "httpx>=0.28.1",
10
- "mcp>=1.9.0,<2.0.0",
11
  "openapi-pydantic>=0.5.1",
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
14
  "websockets>=14.0",
 
15
  ]
16
  requires-python = ">=3.10"
17
  readme = "README.md"
@@ -44,12 +45,14 @@ dev = [
44
  "ipython>=8.12.3",
45
  "pdbpp>=0.10.3",
46
  "pre-commit",
 
47
  "pyright>=1.1.389",
48
  "pytest>=8.3.3",
49
  "pytest-asyncio>=0.23.5",
50
  "pytest-cov>=6.1.1",
51
  "pytest-env>=1.1.5",
52
  "pytest-flakefinder",
 
53
  "pytest-report>=0.2.1",
54
  "pytest-timeout>=2.4.0",
55
  "pytest-xdist>=3.6.1",
 
7
  "python-dotenv>=1.1.0",
8
  "exceptiongroup>=1.2.2",
9
  "httpx>=0.28.1",
10
+ "mcp>=1.9.2,<2.0.0",
11
  "openapi-pydantic>=0.5.1",
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
14
  "websockets>=14.0",
15
+ "authlib>=1.5.2",
16
  ]
17
  requires-python = ">=3.10"
18
  readme = "README.md"
 
45
  "ipython>=8.12.3",
46
  "pdbpp>=0.10.3",
47
  "pre-commit",
48
+ "pyinstrument>=5.0.2",
49
  "pyright>=1.1.389",
50
  "pytest>=8.3.3",
51
  "pytest-asyncio>=0.23.5",
52
  "pytest-cov>=6.1.1",
53
  "pytest-env>=1.1.5",
54
  "pytest-flakefinder",
55
+ "pytest-httpx>=0.35.0",
56
  "pytest-report>=0.2.1",
57
  "pytest-timeout>=2.4.0",
58
  "pytest-xdist>=3.6.1",
src/fastmcp/client/__init__.py CHANGED
@@ -11,6 +11,7 @@ from .transports import (
11
  FastMCPTransport,
12
  StreamableHttpTransport,
13
  )
 
14
 
15
  __all__ = [
16
  "Client",
@@ -24,4 +25,5 @@ __all__ = [
24
  "NpxStdioTransport",
25
  "FastMCPTransport",
26
  "StreamableHttpTransport",
 
27
  ]
 
11
  FastMCPTransport,
12
  StreamableHttpTransport,
13
  )
14
+ from .auth import OAuth
15
 
16
  __all__ = [
17
  "Client",
 
25
  "NpxStdioTransport",
26
  "FastMCPTransport",
27
  "StreamableHttpTransport",
28
+ "OAuth",
29
  ]
src/fastmcp/client/auth.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import webbrowser
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+ from urllib.parse import urljoin, urlparse
9
+
10
+ import anyio
11
+ import httpx
12
+ from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider
13
+ from mcp.client.auth import TokenStorage
14
+ from mcp.shared.auth import (
15
+ OAuthClientInformationFull,
16
+ OAuthClientMetadata,
17
+ )
18
+ from mcp.shared.auth import (
19
+ OAuthMetadata as _MCPServerOAuthMetadata,
20
+ )
21
+ from mcp.shared.auth import (
22
+ OAuthToken as OAuthToken,
23
+ )
24
+ from pydantic import AnyHttpUrl, ValidationError
25
+
26
+ from fastmcp.client.oauth_callback import (
27
+ create_oauth_callback_server,
28
+ )
29
+ from fastmcp.settings import settings as fastmcp_global_settings
30
+ from fastmcp.utilities.http import find_available_port
31
+ from fastmcp.utilities.logging import get_logger
32
+
33
+ __all__ = ["OAuth"]
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ def default_cache_dir() -> Path:
39
+ return fastmcp_global_settings.home / "oauth-mcp-client-cache"
40
+
41
+
42
+ # Flexible OAuth models for real-world compatibility
43
+ class ServerOAuthMetadata(_MCPServerOAuthMetadata):
44
+ """
45
+ More flexible OAuth metadata model that accepts broader ranges of values
46
+ than the restrictive MCP standard model.
47
+
48
+ This handles real-world OAuth servers like PayPal that may support
49
+ additional methods not in the MCP specification.
50
+ """
51
+
52
+ # Allow any code challenge methods, not just S256
53
+ code_challenge_methods_supported: list[str] | None = None
54
+
55
+ # Allow any token endpoint auth methods
56
+ token_endpoint_auth_methods_supported: list[str] | None = None
57
+
58
+ # Allow any grant types
59
+ grant_types_supported: list[str] | None = None
60
+
61
+ # Allow any response types
62
+ response_types_supported: list[str] = ["code"]
63
+
64
+ # Allow any response modes
65
+ response_modes_supported: list[str] | None = None
66
+
67
+
68
+ class OAuthClientProvider(_MCPOAuthClientProvider):
69
+ """
70
+ OAuth client provider with more flexible OAuth metadata discovery.
71
+
72
+ This subclass handles real-world OAuth servers that may not conform
73
+ strictly to the MCP OAuth specification but are still valid OAuth 2.0 servers.
74
+ """
75
+
76
+ async def _discover_oauth_metadata(
77
+ self, server_url: str
78
+ ) -> ServerOAuthMetadata | None:
79
+ """
80
+ Discover OAuth metadata with flexible validation.
81
+
82
+ This is nearly identical to the parent implementation but uses
83
+ ServerOAuthMetadata instead of the restrictive MCP OAuthMetadata.
84
+ """
85
+ # Extract base URL per MCP spec
86
+ auth_base_url = self._get_authorization_base_url(server_url)
87
+ url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server")
88
+
89
+ from mcp.types import LATEST_PROTOCOL_VERSION
90
+
91
+ headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION}
92
+
93
+ async with httpx.AsyncClient() as client:
94
+ try:
95
+ response = await client.get(url, headers=headers)
96
+ if response.status_code == 404:
97
+ return None
98
+ response.raise_for_status()
99
+ metadata_json = response.json()
100
+ logger.debug(f"OAuth metadata discovered: {metadata_json}")
101
+ return ServerOAuthMetadata.model_validate(metadata_json)
102
+ except Exception:
103
+ # Retry without MCP header for CORS compatibility
104
+ try:
105
+ response = await client.get(url)
106
+ if response.status_code == 404:
107
+ return None
108
+ response.raise_for_status()
109
+ metadata_json = response.json()
110
+ logger.debug(
111
+ f"OAuth metadata discovered (no MCP header): {metadata_json}"
112
+ )
113
+ return ServerOAuthMetadata.model_validate(metadata_json)
114
+ except Exception:
115
+ logger.exception("Failed to discover OAuth metadata")
116
+ return None
117
+
118
+
119
+ class FileTokenStorage(TokenStorage):
120
+ """
121
+ File-based token storage implementation for OAuth credentials and tokens.
122
+ Implements the mcp.client.auth.TokenStorage protocol.
123
+
124
+ Each instance is tied to a specific server URL for proper token isolation.
125
+ """
126
+
127
+ def __init__(self, server_url: str, cache_dir: Path | None = None):
128
+ """Initialize storage for a specific server URL."""
129
+ self.server_url = server_url
130
+ self.cache_dir = cache_dir or default_cache_dir()
131
+ self.cache_dir.mkdir(exist_ok=True, parents=True)
132
+
133
+ @staticmethod
134
+ def get_base_url(url: str) -> str:
135
+ """Extract the base URL (scheme + host) from a URL."""
136
+ parsed = urlparse(url)
137
+ return f"{parsed.scheme}://{parsed.netloc}"
138
+
139
+ def get_cache_key(self) -> str:
140
+ """Generate a safe filesystem key from the server's base URL."""
141
+ base_url = self.get_base_url(self.server_url)
142
+ return (
143
+ base_url.replace("://", "_")
144
+ .replace(".", "_")
145
+ .replace("/", "_")
146
+ .replace(":", "_")
147
+ )
148
+
149
+ def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path:
150
+ """Get the file path for the specified cache file type."""
151
+ key = self.get_cache_key()
152
+ return self.cache_dir / f"{key}_{file_type}.json"
153
+
154
+ async def get_tokens(self) -> OAuthToken | None:
155
+ """Load tokens from file storage."""
156
+ path = self._get_file_path("tokens")
157
+
158
+ try:
159
+ tokens = OAuthToken.model_validate_json(path.read_text())
160
+ # now = datetime.datetime.now(datetime.timezone.utc)
161
+ # if tokens.expires_at is not None and tokens.expires_at <= now:
162
+ # logger.debug(f"Token expired for {self.get_base_url(self.server_url)}")
163
+ # return None
164
+ return tokens
165
+ except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
166
+ logger.debug(
167
+ f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
168
+ )
169
+ return None
170
+
171
+ async def set_tokens(self, tokens: OAuthToken) -> None:
172
+ """Save tokens to file storage."""
173
+ path = self._get_file_path("tokens")
174
+ path.write_text(tokens.model_dump_json(indent=2))
175
+ logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
176
+
177
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
178
+ """Load client information from file storage."""
179
+ path = self._get_file_path("client_info")
180
+ try:
181
+ client_info = OAuthClientInformationFull.model_validate_json(
182
+ path.read_text()
183
+ )
184
+ # Check if we have corresponding valid tokens
185
+ # If no tokens exist, the OAuth flow was incomplete and we should
186
+ # force a fresh client registration
187
+ tokens = await self.get_tokens()
188
+ if tokens is None:
189
+ logger.debug(
190
+ f"No tokens found for client info at {self.get_base_url(self.server_url)}. "
191
+ "OAuth flow may have been incomplete. Clearing client info to force fresh registration."
192
+ )
193
+ # Clear the incomplete client info
194
+ client_info_path = self._get_file_path("client_info")
195
+ client_info_path.unlink(missing_ok=True)
196
+ return None
197
+
198
+ return client_info
199
+ except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
200
+ logger.debug(
201
+ f"Could not load client info for {self.get_base_url(self.server_url)}: {e}"
202
+ )
203
+ return None
204
+
205
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
206
+ """Save client information to file storage."""
207
+ path = self._get_file_path("client_info")
208
+ path.write_text(client_info.model_dump_json(indent=2))
209
+ logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}")
210
+
211
+ def clear(self) -> None:
212
+ """Clear all cached data for this server."""
213
+ file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
214
+ for file_type in file_types:
215
+ path = self._get_file_path(file_type)
216
+ path.unlink(missing_ok=True)
217
+ logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
218
+
219
+ @classmethod
220
+ def clear_all(cls, cache_dir: Path | None = None) -> None:
221
+ """Clear all cached data for all servers."""
222
+ cache_dir = cache_dir or default_cache_dir()
223
+ if not cache_dir.exists():
224
+ return
225
+
226
+ file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
227
+ for file_type in file_types:
228
+ for file in cache_dir.glob(f"*_{file_type}.json"):
229
+ file.unlink(missing_ok=True)
230
+ logger.info("Cleared all OAuth client cache data.")
231
+
232
+
233
+ async def discover_oauth_metadata(
234
+ server_base_url: str, httpx_kwargs: dict[str, Any] | None = None
235
+ ) -> _MCPServerOAuthMetadata | None:
236
+ """
237
+ Discover OAuth metadata from the server using RFC 8414 well-known endpoint.
238
+
239
+ Args:
240
+ server_base_url: Base URL of the OAuth server (e.g., "https://example.com")
241
+ httpx_kwargs: Additional kwargs for httpx client
242
+
243
+ Returns:
244
+ OAuth metadata if found, None otherwise
245
+ """
246
+ well_known_url = urljoin(server_base_url, "/.well-known/oauth-authorization-server")
247
+ logger.debug(f"Discovering OAuth metadata from: {well_known_url}")
248
+
249
+ async with httpx.AsyncClient(**(httpx_kwargs or {})) as client:
250
+ try:
251
+ response = await client.get(well_known_url, timeout=10.0)
252
+ if response.status_code == 200:
253
+ logger.debug("Successfully discovered OAuth metadata")
254
+ return _MCPServerOAuthMetadata.model_validate(response.json())
255
+ elif response.status_code == 404:
256
+ logger.debug(
257
+ "OAuth metadata not found (404) - server may not require auth"
258
+ )
259
+ return None
260
+ else:
261
+ logger.warning(f"OAuth metadata request failed: {response.status_code}")
262
+ return None
263
+ except (httpx.RequestError, json.JSONDecodeError, ValidationError) as e:
264
+ logger.debug(f"OAuth metadata discovery failed: {e}")
265
+ return None
266
+
267
+
268
+ async def check_if_auth_required(
269
+ mcp_url: str, httpx_kwargs: dict[str, Any] | None = None
270
+ ) -> bool:
271
+ """
272
+ Check if the MCP endpoint requires authentication by making a test request.
273
+
274
+ Returns:
275
+ True if auth appears to be required, False otherwise
276
+ """
277
+ async with httpx.AsyncClient(**(httpx_kwargs or {})) as client:
278
+ try:
279
+ # Try a simple request to the endpoint
280
+ response = await client.get(mcp_url, timeout=5.0)
281
+
282
+ # If we get 401/403, auth is likely required
283
+ if response.status_code in (401, 403):
284
+ return True
285
+
286
+ # Check for WWW-Authenticate header
287
+ if "WWW-Authenticate" in response.headers:
288
+ return True
289
+
290
+ # If we get a successful response, auth may not be required
291
+ return False
292
+
293
+ except httpx.RequestError:
294
+ # If we can't connect, assume auth might be required
295
+ return True
296
+
297
+
298
+ def OAuth(
299
+ mcp_url: str,
300
+ scopes: str | list[str] | None = None,
301
+ client_name: str = "FastMCP Client",
302
+ token_storage_cache_dir: Path | None = None,
303
+ additional_client_metadata: dict[str, Any] | None = None,
304
+ ) -> _MCPOAuthClientProvider:
305
+ """
306
+ Create an OAuthClientProvider for an MCP server.
307
+
308
+ This is intended to be provided to the `auth` parameter of an
309
+ httpx.AsyncClient (or appropriate FastMCP client/transport instance)
310
+
311
+ Args:
312
+ mcp_url: Full URL to the MCP endpoint (e.g.,
313
+ "http://host/mcp/sse")
314
+ scopes: OAuth scopes to request. Can be a
315
+ space-separated string or a list of strings.
316
+ client_name: Name for this client during registration
317
+ token_storage_cache_dir: Directory for FileTokenStorage
318
+ additional_client_metadata: Extra fields for OAuthClientMetadata
319
+
320
+ Returns:
321
+ OAuthClientProvider
322
+ """
323
+ parsed_url = urlparse(mcp_url)
324
+ server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
325
+
326
+ # Setup OAuth client
327
+ redirect_port = find_available_port()
328
+ redirect_uri = f"http://127.0.0.1:{redirect_port}/callback"
329
+
330
+ if isinstance(scopes, list):
331
+ scopes = " ".join(scopes)
332
+
333
+ client_metadata = OAuthClientMetadata(
334
+ client_name=client_name,
335
+ redirect_uris=[AnyHttpUrl(redirect_uri)],
336
+ grant_types=["authorization_code", "refresh_token"],
337
+ response_types=["code"],
338
+ token_endpoint_auth_method="client_secret_post",
339
+ scope=scopes,
340
+ **(additional_client_metadata or {}),
341
+ )
342
+
343
+ # Create server-specific token storage
344
+ storage = FileTokenStorage(
345
+ server_url=server_base_url, cache_dir=token_storage_cache_dir
346
+ )
347
+
348
+ # Define OAuth handlers
349
+ async def redirect_handler(authorization_url: str) -> None:
350
+ """Open browser for authorization."""
351
+ logger.info(f"OAuth authorization URL: {authorization_url}")
352
+ webbrowser.open(authorization_url)
353
+
354
+ async def callback_handler() -> tuple[str, str | None]:
355
+ """Handle OAuth callback and return (auth_code, state)."""
356
+ # Create a future to capture the OAuth response
357
+ response_future = asyncio.get_running_loop().create_future()
358
+
359
+ # Create server with the future
360
+ server = create_oauth_callback_server(
361
+ port=redirect_port,
362
+ server_url=server_base_url,
363
+ response_future=response_future,
364
+ )
365
+
366
+ # Run server until response is received with timeout logic
367
+ async with anyio.create_task_group() as tg:
368
+ tg.start_soon(server.serve)
369
+ logger.info(
370
+ f"🎧 OAuth callback server started on http://127.0.0.1:{redirect_port}"
371
+ )
372
+
373
+ TIMEOUT = 300.0 # 5 minute timeout
374
+ try:
375
+ with anyio.fail_after(TIMEOUT):
376
+ auth_code, state = await response_future
377
+ return auth_code, state
378
+ except TimeoutError:
379
+ raise TimeoutError(f"OAuth callback timed out after {TIMEOUT} seconds")
380
+ finally:
381
+ server.should_exit = True
382
+ await asyncio.sleep(0.1) # Allow server to shutdown gracefully
383
+ tg.cancel_scope.cancel()
384
+
385
+ # Create OAuth provider
386
+ oauth_provider = OAuthClientProvider(
387
+ server_url=server_base_url,
388
+ client_metadata=client_metadata,
389
+ storage=storage,
390
+ redirect_handler=redirect_handler,
391
+ callback_handler=callback_handler,
392
+ )
393
+
394
+ return oauth_provider
395
+
396
+
397
+ class BearerAuth(httpx.Auth):
398
+ def __init__(self, token: str):
399
+ self.token = token
400
+
401
+ def auth_flow(self, request):
402
+ request.headers["Authorization"] = f"Bearer {self.token}"
403
+ yield request
src/fastmcp/client/client.py CHANGED
@@ -1,9 +1,11 @@
 
1
  import datetime
2
  from contextlib import AsyncExitStack, asynccontextmanager
3
  from pathlib import Path
4
- from typing import Any, Generic, cast, overload
5
 
6
  import anyio
 
7
  import mcp.types
8
  from exceptiongroup import catch
9
  from mcp import ClientSession
@@ -43,6 +45,7 @@ from .transports import (
43
 
44
  __all__ = [
45
  "Client",
 
46
  "RootsHandler",
47
  "RootsList",
48
  "LogHandler",
@@ -142,11 +145,11 @@ class Client(Generic[ClientTransportT]):
142
  progress_handler: ProgressHandler | None = None,
143
  timeout: datetime.timedelta | float | int | None = None,
144
  init_timeout: datetime.timedelta | float | int | None = None,
 
145
  ):
146
  self.transport = cast(ClientTransportT, infer_transport(transport))
147
- self._session: ClientSession | None = None
148
- self._exit_stack: AsyncExitStack | None = None
149
- self._nesting_counter: int = 0
150
  self._initialize_result: mcp.types.InitializeResult | None = None
151
 
152
  if log_handler is None:
@@ -187,6 +190,15 @@ class Client(Generic[ClientTransportT]):
187
  sampling_handler
188
  )
189
 
 
 
 
 
 
 
 
 
 
190
  @property
191
  def session(self) -> ClientSession:
192
  """Get the current active session. Raises RuntimeError if not connected."""
@@ -237,39 +249,75 @@ class Client(Generic[ClientTransportT]):
237
  except TimeoutError:
238
  raise RuntimeError("Failed to initialize server session")
239
  finally:
240
- self._exit_stack = None
241
  self._session = None
242
  self._initialize_result = None
243
 
244
  async def __aenter__(self):
245
- if self._nesting_counter == 0:
246
- # Create exit stack to manage both context managers
247
- stack = AsyncExitStack()
248
- await stack.__aenter__()
249
-
250
- await stack.enter_async_context(self._context_manager())
251
-
252
- self._exit_stack = stack
253
-
254
- self._nesting_counter += 1
255
-
256
  return self
257
 
258
  async def __aexit__(self, exc_type, exc_val, exc_tb):
259
- self._nesting_counter -= 1
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
- if self._nesting_counter == 0:
262
- # Exit the stack which will handle cleaning up the session
263
- if self._exit_stack is not None:
264
- try:
265
- await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
266
- finally:
267
- self._exit_stack = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  async def close(self):
 
270
  await self.transport.close()
271
- self._session = None
272
- self._initialize_result = None
273
 
274
  # --- MCP Client Methods ---
275
 
 
1
+ import asyncio
2
  import datetime
3
  from contextlib import AsyncExitStack, asynccontextmanager
4
  from pathlib import Path
5
+ from typing import Any, Generic, Literal, cast, overload
6
 
7
  import anyio
8
+ import httpx
9
  import mcp.types
10
  from exceptiongroup import catch
11
  from mcp import ClientSession
 
45
 
46
  __all__ = [
47
  "Client",
48
+ "SessionKwargs",
49
  "RootsHandler",
50
  "RootsList",
51
  "LogHandler",
 
145
  progress_handler: ProgressHandler | None = None,
146
  timeout: datetime.timedelta | float | int | None = None,
147
  init_timeout: datetime.timedelta | float | int | None = None,
148
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
149
  ):
150
  self.transport = cast(ClientTransportT, infer_transport(transport))
151
+ if auth is not None:
152
+ self.transport._set_auth(auth)
 
153
  self._initialize_result: mcp.types.InitializeResult | None = None
154
 
155
  if log_handler is None:
 
190
  sampling_handler
191
  )
192
 
193
+ # session context management
194
+ self._session: ClientSession | None = None
195
+ self._exit_stack: AsyncExitStack | None = None
196
+ self._nesting_counter: int = 0
197
+ self._context_lock = anyio.Lock()
198
+ self._session_task: asyncio.Task | None = None
199
+ self._ready_event = anyio.Event()
200
+ self._stop_event = anyio.Event()
201
+
202
  @property
203
  def session(self) -> ClientSession:
204
  """Get the current active session. Raises RuntimeError if not connected."""
 
249
  except TimeoutError:
250
  raise RuntimeError("Failed to initialize server session")
251
  finally:
 
252
  self._session = None
253
  self._initialize_result = None
254
 
255
  async def __aenter__(self):
256
+ await self._connect()
 
 
 
 
 
 
 
 
 
 
257
  return self
258
 
259
  async def __aexit__(self, exc_type, exc_val, exc_tb):
260
+ await self._disconnect()
261
+
262
+ async def _connect(self):
263
+ # ensure only one session is running at a time to avoid race conditions
264
+ async with self._context_lock:
265
+ need_to_start = self._session_task is None or self._session_task.done()
266
+ if need_to_start:
267
+ self._stop_event = anyio.Event()
268
+ self._ready_event = anyio.Event()
269
+ self._session_task = asyncio.create_task(self._session_runner())
270
+ await self._ready_event.wait()
271
+ self._nesting_counter += 1
272
+ return self
273
 
274
+ async def _disconnect(self, force: bool = False):
275
+ # ensure only one session is running at a time to avoid race conditions
276
+ async with self._context_lock:
277
+ # if we are forcing a disconnect, reset the nesting counter
278
+ if force:
279
+ self._nesting_counter = 0
280
+
281
+ # otherwise decrement to check if we are done nesting
282
+ else:
283
+ self._nesting_counter = max(0, self._nesting_counter - 1)
284
+
285
+ # if we are still nested, return
286
+ if self._nesting_counter > 0:
287
+ return
288
+
289
+ # stop the active seesion
290
+ if self._session_task is None:
291
+ return
292
+ self._stop_event.set()
293
+ runner_task = self._session_task
294
+ self._session_task = None
295
+
296
+ # wait for the session to finish
297
+ if runner_task:
298
+ await runner_task
299
+
300
+ # Reset for future reconnects
301
+ self._stop_event = anyio.Event()
302
+ self._ready_event = anyio.Event()
303
+ self._session = None
304
+ self._initialize_result = None
305
+
306
+ async def _session_runner(self):
307
+ async with AsyncExitStack() as stack:
308
+ try:
309
+ await stack.enter_async_context(self._context_manager())
310
+ # Session/context is now ready
311
+ self._ready_event.set()
312
+ # Wait until disconnect/stop is requested
313
+ await self._stop_event.wait()
314
+ finally:
315
+ # On exit, ensure ready event is set (idempotent)
316
+ self._ready_event.set()
317
 
318
  async def close(self):
319
+ await self._disconnect(force=True)
320
  await self.transport.close()
 
 
321
 
322
  # --- MCP Client Methods ---
323
 
src/fastmcp/client/oauth_callback.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OAuth callback server for handling authorization code flows.
3
+
4
+ This module provides a reusable callback server that can handle OAuth redirects
5
+ and display styled responses to users.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from dataclasses import dataclass
12
+
13
+ from starlette.applications import Starlette
14
+ from starlette.requests import Request
15
+ from starlette.responses import HTMLResponse
16
+ from starlette.routing import Route
17
+ from uvicorn import Config, Server
18
+
19
+ from fastmcp.utilities.http import find_available_port
20
+ from fastmcp.utilities.logging import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ def create_callback_html(
26
+ message: str,
27
+ is_success: bool = True,
28
+ title: str = "FastMCP OAuth",
29
+ server_url: str | None = None,
30
+ ) -> str:
31
+ """Create a styled HTML response for OAuth callbacks."""
32
+ status_emoji = "✅" if is_success else "❌"
33
+ status_color = "#10b981" if is_success else "#ef4444" # emerald-500 / red-500
34
+
35
+ # Add server info for success cases
36
+ server_info = ""
37
+ if is_success and server_url:
38
+ server_info = f"""
39
+ <div class="server-info">
40
+ Connected to: <strong>{server_url}</strong>
41
+ </div>
42
+ """
43
+
44
+ return f"""
45
+ <!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="UTF-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
50
+ <title>{title}</title>
51
+ <style>
52
+ body {{
53
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
54
+ margin: 0;
55
+ padding: 0;
56
+ min-height: 100vh;
57
+ display: flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 25%, #16213e 50%, #0f0f23 100%);
61
+ color: #e2e8f0;
62
+ overflow: hidden;
63
+ }}
64
+
65
+ body::before {{
66
+ content: '';
67
+ position: fixed;
68
+ top: 0;
69
+ left: 0;
70
+ width: 100%;
71
+ height: 100%;
72
+ background:
73
+ radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%),
74
+ radial-gradient(circle at 80% 20%, rgba(16, 185, 129, 0.1) 0%, transparent 50%),
75
+ radial-gradient(circle at 40% 40%, rgba(14, 165, 233, 0.1) 0%, transparent 50%);
76
+ pointer-events: none;
77
+ z-index: -1;
78
+ }}
79
+
80
+ .container {{
81
+ background: rgba(30, 41, 59, 0.9);
82
+ backdrop-filter: blur(10px);
83
+ border: 1px solid rgba(71, 85, 105, 0.3);
84
+ padding: 3rem 2rem;
85
+ border-radius: 1rem;
86
+ box-shadow:
87
+ 0 25px 50px -12px rgba(0, 0, 0, 0.7),
88
+ 0 0 0 1px rgba(255, 255, 255, 0.05),
89
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
90
+ text-align: center;
91
+ max-width: 500px;
92
+ margin: 1rem;
93
+ position: relative;
94
+ }}
95
+
96
+ .container::before {{
97
+ content: '';
98
+ position: absolute;
99
+ top: 0;
100
+ left: 0;
101
+ right: 0;
102
+ height: 1px;
103
+ background: linear-gradient(90deg, transparent, rgba(16, 185, 129, 0.5), transparent);
104
+ }}
105
+
106
+ .status-icon {{
107
+ font-size: 4rem;
108
+ margin-bottom: 1rem;
109
+ display: block;
110
+ filter: drop-shadow(0 0 20px currentColor);
111
+ }}
112
+
113
+ .message {{
114
+ font-size: 1.25rem;
115
+ line-height: 1.6;
116
+ color: {status_color};
117
+ margin-bottom: 1.5rem;
118
+ font-weight: 600;
119
+ text-shadow: 0 0 10px rgba({
120
+ "16, 185, 129" if is_success else "239, 68, 68"
121
+ }, 0.3);
122
+ }}
123
+
124
+ .server-info {{
125
+ background: rgba(6, 182, 212, 0.1);
126
+ border: 1px solid rgba(6, 182, 212, 0.3);
127
+ border-radius: 0.75rem;
128
+ padding: 1rem;
129
+ margin: 1rem 0;
130
+ font-size: 0.9rem;
131
+ color: #67e8f9;
132
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
133
+ text-shadow: 0 0 10px rgba(103, 232, 249, 0.3);
134
+ }}
135
+
136
+ .server-info strong {{
137
+ color: #22d3ee;
138
+ font-weight: 700;
139
+ }}
140
+
141
+ .subtitle {{
142
+ font-size: 1rem;
143
+ color: #94a3b8;
144
+ margin-top: 1rem;
145
+ }}
146
+
147
+ .close-instruction {{
148
+ background: rgba(51, 65, 85, 0.8);
149
+ border: 1px solid rgba(71, 85, 105, 0.4);
150
+ border-radius: 0.75rem;
151
+ padding: 1rem;
152
+ margin-top: 1.5rem;
153
+ font-size: 0.9rem;
154
+ color: #cbd5e1;
155
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
156
+ }}
157
+
158
+ @keyframes glow {{
159
+ 0%, 100% {{ opacity: 1; }}
160
+ 50% {{ opacity: 0.7; }}
161
+ }}
162
+
163
+ .status-icon {{
164
+ animation: glow 2s ease-in-out infinite;
165
+ }}
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <div class="container">
170
+ <span class="status-icon">{status_emoji}</span>
171
+ <div class="message">{message}</div>
172
+ {server_info}
173
+ <div class="close-instruction">
174
+ You can safely close this tab now.
175
+ </div>
176
+ </div>
177
+ </body>
178
+ </html>
179
+ """
180
+
181
+
182
+ @dataclass
183
+ class CallbackResponse:
184
+ code: str | None = None
185
+ state: str | None = None
186
+ error: str | None = None
187
+ error_description: str | None = None
188
+
189
+ @classmethod
190
+ def from_dict(cls, data: dict[str, str]) -> CallbackResponse:
191
+ return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
192
+
193
+ def to_dict(self) -> dict[str, str]:
194
+ return {k: v for k, v in self.__dict__.items() if v is not None}
195
+
196
+
197
+ def create_oauth_callback_server(
198
+ port: int,
199
+ callback_path: str = "/callback",
200
+ server_url: str | None = None,
201
+ response_future: asyncio.Future | None = None,
202
+ ) -> Server:
203
+ """
204
+ Create an OAuth callback server.
205
+
206
+ Args:
207
+ port: The port to run the server on
208
+ callback_path: The path to listen for OAuth redirects on
209
+ server_url: Optional server URL to display in success messages
210
+ response_future: Optional future to resolve when OAuth callback is received
211
+
212
+ Returns:
213
+ Configured uvicorn Server instance (not yet running)
214
+ """
215
+
216
+ async def callback_handler(request: Request):
217
+ """Handle OAuth callback requests with proper HTML responses."""
218
+ query_params = dict(request.query_params)
219
+ callback_response = CallbackResponse.from_dict(query_params)
220
+
221
+ if callback_response.error:
222
+ error_desc = callback_response.error_description or "Unknown error"
223
+
224
+ # Resolve future with exception if provided
225
+ if response_future and not response_future.done():
226
+ response_future.set_exception(
227
+ RuntimeError(
228
+ f"OAuth error: {callback_response.error} - {error_desc}"
229
+ )
230
+ )
231
+
232
+ return HTMLResponse(
233
+ create_callback_html(
234
+ f"FastMCP OAuth Error: {callback_response.error}<br>{error_desc}",
235
+ is_success=False,
236
+ ),
237
+ status_code=400,
238
+ )
239
+
240
+ if not callback_response.code:
241
+ # Resolve future with exception if provided
242
+ if response_future and not response_future.done():
243
+ response_future.set_exception(
244
+ RuntimeError("OAuth callback missing authorization code")
245
+ )
246
+
247
+ return HTMLResponse(
248
+ create_callback_html(
249
+ "FastMCP OAuth Error: No authorization code received",
250
+ is_success=False,
251
+ ),
252
+ status_code=400,
253
+ )
254
+
255
+ # Success case
256
+ if response_future and not response_future.done():
257
+ response_future.set_result(
258
+ (callback_response.code, callback_response.state)
259
+ )
260
+
261
+ return HTMLResponse(
262
+ create_callback_html("FastMCP OAuth login complete!", server_url=server_url)
263
+ )
264
+
265
+ app = Starlette(routes=[Route(callback_path, callback_handler)])
266
+
267
+ return Server(
268
+ Config(
269
+ app=app,
270
+ host="127.0.0.1",
271
+ port=port,
272
+ lifespan="off",
273
+ log_level="warning",
274
+ )
275
+ )
276
+
277
+
278
+ if __name__ == "__main__":
279
+ """Run a test server when executed directly."""
280
+ import webbrowser
281
+
282
+ import uvicorn
283
+
284
+ port = find_available_port()
285
+ print("🎭 OAuth Callback Test Server")
286
+ print("📍 Test URLs:")
287
+ print(f" Success: http://localhost:{port}/callback?code=test123&state=xyz")
288
+ print(
289
+ f" Error: http://localhost:{port}/callback?error=access_denied&error_description=User%20denied"
290
+ )
291
+ print(f" Missing: http://localhost:{port}/callback")
292
+ print("🛑 Press Ctrl+C to stop")
293
+ print()
294
+
295
+ # Create test server without future (just for testing HTML responses)
296
+ server = create_oauth_callback_server(
297
+ port=port, server_url="https://fastmcp-test-server.example.com"
298
+ )
299
+
300
+ # Open browser to success example
301
+ webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz")
302
+
303
+ # Run with uvicorn directly
304
+ uvicorn.run(
305
+ server.config.app,
306
+ host="127.0.0.1",
307
+ port=port,
308
+ log_level="warning",
309
+ access_log=False,
310
+ )
src/fastmcp/client/transports.py CHANGED
@@ -6,10 +6,20 @@ import os
6
  import shutil
7
  import sys
8
  import warnings
9
- from collections.abc import AsyncIterator
10
  from pathlib import Path
11
- from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload
 
 
 
 
 
 
 
 
12
 
 
 
13
  from mcp import ClientSession, StdioServerParameters
14
  from mcp.client.session import (
15
  ListRootsFnT,
@@ -26,7 +36,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
26
  from pydantic import AnyUrl
27
  from typing_extensions import Unpack
28
 
29
- from fastmcp.server import FastMCP as FastMCPServer
30
  from fastmcp.server.dependencies import get_http_headers
31
  from fastmcp.server.server import FastMCP
32
  from fastmcp.utilities.logging import get_logger
@@ -40,6 +50,20 @@ logger = get_logger(__name__)
40
  # TypeVar for preserving specific ClientTransport subclass types
41
  ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  class SessionKwargs(TypedDict, total=False):
45
  """Keyword arguments for the MCP ClientSession constructor."""
@@ -92,6 +116,10 @@ class ClientTransport(abc.ABC):
92
  """Close the transport."""
93
  pass
94
 
 
 
 
 
95
 
96
  class WSTransport(ClientTransport):
97
  """Transport implementation that connects to an MCP server via WebSockets."""
@@ -131,7 +159,9 @@ class SSETransport(ClientTransport):
131
  self,
132
  url: str | AnyUrl,
133
  headers: dict[str, str] | None = None,
 
134
  sse_read_timeout: datetime.timedelta | float | int | None = None,
 
135
  ):
136
  if isinstance(url, AnyUrl):
137
  url = str(url)
@@ -139,11 +169,21 @@ class SSETransport(ClientTransport):
139
  raise ValueError("Invalid HTTP/S URL provided for SSE.")
140
  self.url = url
141
  self.headers = headers or {}
 
 
142
 
143
  if isinstance(sse_read_timeout, int | float):
144
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
145
  self.sse_read_timeout = sse_read_timeout
146
 
 
 
 
 
 
 
 
 
147
  @contextlib.asynccontextmanager
148
  async def connect_session(
149
  self, **session_kwargs: Unpack[SessionKwargs]
@@ -165,7 +205,10 @@ class SSETransport(ClientTransport):
165
  )
166
  client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
167
 
168
- async with sse_client(self.url, **client_kwargs) as transport:
 
 
 
169
  read_stream, write_stream = transport
170
  async with ClientSession(
171
  read_stream, write_stream, **session_kwargs
@@ -183,7 +226,9 @@ class StreamableHttpTransport(ClientTransport):
183
  self,
184
  url: str | AnyUrl,
185
  headers: dict[str, str] | None = None,
 
186
  sse_read_timeout: datetime.timedelta | float | int | None = None,
 
187
  ):
188
  if isinstance(url, AnyUrl):
189
  url = str(url)
@@ -191,11 +236,21 @@ class StreamableHttpTransport(ClientTransport):
191
  raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
192
  self.url = url
193
  self.headers = headers or {}
 
 
194
 
195
  if isinstance(sse_read_timeout, int | float):
196
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
197
  self.sse_read_timeout = sse_read_timeout
198
 
 
 
 
 
 
 
 
 
199
  @contextlib.asynccontextmanager
200
  async def connect_session(
201
  self, **session_kwargs: Unpack[SessionKwargs]
@@ -214,7 +269,14 @@ class StreamableHttpTransport(ClientTransport):
214
  if session_kwargs.get("read_timeout_seconds", None) is not None:
215
  client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
216
 
217
- async with streamablehttp_client(self.url, **client_kwargs) as transport:
 
 
 
 
 
 
 
218
  read_stream, write_stream, _ = transport
219
  async with ClientSession(
220
  read_stream, write_stream, **session_kwargs
@@ -264,8 +326,8 @@ class StdioTransport(ClientTransport):
264
 
265
  self._session: ClientSession | None = None
266
  self._connect_task: asyncio.Task | None = None
267
- self._ready_event = asyncio.Event()
268
- self._stop_event = asyncio.Event()
269
 
270
  @contextlib.asynccontextmanager
271
  async def connect_session(
@@ -328,8 +390,8 @@ class StdioTransport(ClientTransport):
328
 
329
  # reset variables and events for potential future reconnects
330
  self._connect_task = None
331
- self._stop_event = asyncio.Event()
332
- self._ready_event = asyncio.Event()
333
 
334
  async def close(self):
335
  await self.disconnect()
@@ -592,7 +654,7 @@ class FastMCPTransport(ClientTransport):
592
  tests or scenarios where client and server run in the same runtime.
593
  """
594
 
595
- def __init__(self, mcp: FastMCPServer | FastMCP1Server):
596
  """Initialize a FastMCPTransport from a FastMCP server instance."""
597
 
598
  # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
@@ -706,7 +768,7 @@ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ...
706
 
707
 
708
  @overload
709
- def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ...
710
 
711
 
712
  @overload
@@ -741,7 +803,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor
741
 
742
  def infer_transport(
743
  transport: ClientTransport
744
- | FastMCPServer
745
  | FastMCP1Server
746
  | AnyUrl
747
  | Path
@@ -758,7 +820,7 @@ def infer_transport(
758
 
759
  The function supports these input types:
760
  - ClientTransport: Used directly without modification
761
- - FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
762
  - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
763
  - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
764
  - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
@@ -796,7 +858,7 @@ def infer_transport(
796
  return transport
797
 
798
  # the transport is a FastMCP server (2.x or 1.0)
799
- elif isinstance(transport, FastMCPServer | FastMCP1Server):
800
  inferred_transport = FastMCPTransport(mcp=transport)
801
 
802
  # the transport is a path to a script
 
6
  import shutil
7
  import sys
8
  import warnings
9
+ from collections.abc import AsyncIterator, Callable
10
  from pathlib import Path
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Literal,
15
+ TypedDict,
16
+ TypeVar,
17
+ cast,
18
+ overload,
19
+ )
20
 
21
+ import anyio
22
+ import httpx
23
  from mcp import ClientSession, StdioServerParameters
24
  from mcp.client.session import (
25
  ListRootsFnT,
 
36
  from pydantic import AnyUrl
37
  from typing_extensions import Unpack
38
 
39
+ from fastmcp.client.auth import OAuth
40
  from fastmcp.server.dependencies import get_http_headers
41
  from fastmcp.server.server import FastMCP
42
  from fastmcp.utilities.logging import get_logger
 
50
  # TypeVar for preserving specific ClientTransport subclass types
51
  ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
52
 
53
+ __all__ = [
54
+ "ClientTransport",
55
+ "SSETransport",
56
+ "StreamableHttpTransport",
57
+ "StdioTransport",
58
+ "PythonStdioTransport",
59
+ "FastMCPStdioTransport",
60
+ "NodeStdioTransport",
61
+ "UvxStdioTransport",
62
+ "NpxStdioTransport",
63
+ "FastMCPTransport",
64
+ "infer_transport",
65
+ ]
66
+
67
 
68
  class SessionKwargs(TypedDict, total=False):
69
  """Keyword arguments for the MCP ClientSession constructor."""
 
116
  """Close the transport."""
117
  pass
118
 
119
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
120
+ if auth is not None:
121
+ raise ValueError("This transport does not support auth")
122
+
123
 
124
  class WSTransport(ClientTransport):
125
  """Transport implementation that connects to an MCP server via WebSockets."""
 
159
  self,
160
  url: str | AnyUrl,
161
  headers: dict[str, str] | None = None,
162
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
163
  sse_read_timeout: datetime.timedelta | float | int | None = None,
164
+ httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
165
  ):
166
  if isinstance(url, AnyUrl):
167
  url = str(url)
 
169
  raise ValueError("Invalid HTTP/S URL provided for SSE.")
170
  self.url = url
171
  self.headers = headers or {}
172
+ self._set_auth(auth)
173
+ self.httpx_client_factory = httpx_client_factory
174
 
175
  if isinstance(sse_read_timeout, int | float):
176
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
177
  self.sse_read_timeout = sse_read_timeout
178
 
179
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
180
+ if auth == "oauth":
181
+ auth = OAuth(self.url)
182
+ elif isinstance(auth, str):
183
+ self.headers["Authorization"] = auth
184
+ auth = None
185
+ self.auth = auth
186
+
187
  @contextlib.asynccontextmanager
188
  async def connect_session(
189
  self, **session_kwargs: Unpack[SessionKwargs]
 
205
  )
206
  client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
207
 
208
+ if self.httpx_client_factory is not None:
209
+ client_kwargs["httpx_client_factory"] = self.httpx_client_factory
210
+
211
+ async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport:
212
  read_stream, write_stream = transport
213
  async with ClientSession(
214
  read_stream, write_stream, **session_kwargs
 
226
  self,
227
  url: str | AnyUrl,
228
  headers: dict[str, str] | None = None,
229
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
230
  sse_read_timeout: datetime.timedelta | float | int | None = None,
231
+ httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
232
  ):
233
  if isinstance(url, AnyUrl):
234
  url = str(url)
 
236
  raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
237
  self.url = url
238
  self.headers = headers or {}
239
+ self._set_auth(auth)
240
+ self.httpx_client_factory = httpx_client_factory
241
 
242
  if isinstance(sse_read_timeout, int | float):
243
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
244
  self.sse_read_timeout = sse_read_timeout
245
 
246
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
247
+ if auth == "oauth":
248
+ auth = OAuth(self.url)
249
+ elif isinstance(auth, str):
250
+ self.headers["Authorization"] = auth
251
+ auth = None
252
+ self.auth = auth
253
+
254
  @contextlib.asynccontextmanager
255
  async def connect_session(
256
  self, **session_kwargs: Unpack[SessionKwargs]
 
269
  if session_kwargs.get("read_timeout_seconds", None) is not None:
270
  client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
271
 
272
+ if self.httpx_client_factory is not None:
273
+ client_kwargs["httpx_client_factory"] = self.httpx_client_factory
274
+
275
+ async with streamablehttp_client(
276
+ self.url,
277
+ auth=self.auth,
278
+ **client_kwargs,
279
+ ) as transport:
280
  read_stream, write_stream, _ = transport
281
  async with ClientSession(
282
  read_stream, write_stream, **session_kwargs
 
326
 
327
  self._session: ClientSession | None = None
328
  self._connect_task: asyncio.Task | None = None
329
+ self._ready_event = anyio.Event()
330
+ self._stop_event = anyio.Event()
331
 
332
  @contextlib.asynccontextmanager
333
  async def connect_session(
 
390
 
391
  # reset variables and events for potential future reconnects
392
  self._connect_task = None
393
+ self._stop_event = anyio.Event()
394
+ self._ready_event = anyio.Event()
395
 
396
  async def close(self):
397
  await self.disconnect()
 
654
  tests or scenarios where client and server run in the same runtime.
655
  """
656
 
657
+ def __init__(self, mcp: FastMCP | FastMCP1Server):
658
  """Initialize a FastMCPTransport from a FastMCP server instance."""
659
 
660
  # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
 
768
 
769
 
770
  @overload
771
+ def infer_transport(transport: FastMCP) -> FastMCPTransport: ...
772
 
773
 
774
  @overload
 
803
 
804
  def infer_transport(
805
  transport: ClientTransport
806
+ | FastMCP
807
  | FastMCP1Server
808
  | AnyUrl
809
  | Path
 
820
 
821
  The function supports these input types:
822
  - ClientTransport: Used directly without modification
823
+ - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
824
  - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
825
  - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
826
  - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
 
858
  return transport
859
 
860
  # the transport is a FastMCP server (2.x or 1.0)
861
+ elif isinstance(transport, FastMCP | FastMCP1Server):
862
  inferred_transport = FastMCPTransport(mcp=transport)
863
 
864
  # the transport is a path to a script
src/fastmcp/low_level/README.md DELETED
@@ -1 +0,0 @@
1
- Patched low-level objects. When possible, we prefer the official SDK, but we patch bugs here if necessary.
 
 
src/fastmcp/{low_level → server/auth}/__init__.py RENAMED
File without changes
src/fastmcp/server/auth/auth.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mcp.server.auth.provider import (
2
+ AccessToken,
3
+ AuthorizationCode,
4
+ OAuthAuthorizationServerProvider,
5
+ RefreshToken,
6
+ )
7
+ from mcp.server.auth.settings import (
8
+ ClientRegistrationOptions,
9
+ RevocationOptions,
10
+ )
11
+ from pydantic import AnyHttpUrl
12
+
13
+
14
+ class OAuthProvider(
15
+ OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
16
+ ):
17
+ def __init__(
18
+ self,
19
+ issuer_url: AnyHttpUrl | str,
20
+ service_documentation_url: AnyHttpUrl | str | None = None,
21
+ client_registration_options: ClientRegistrationOptions | None = None,
22
+ revocation_options: RevocationOptions | None = None,
23
+ required_scopes: list[str] | None = None,
24
+ ):
25
+ """
26
+ Initialize the OAuth provider.
27
+
28
+ Args:
29
+ issuer_url: The URL of the OAuth issuer.
30
+ service_documentation_url: The URL of the service documentation.
31
+ client_registration_options: The client registration options.
32
+ revocation_options: The revocation options.
33
+ required_scopes: Scopes that are required for all requests.
34
+ """
35
+ super().__init__()
36
+ if isinstance(issuer_url, str):
37
+ issuer_url = AnyHttpUrl(issuer_url)
38
+ if isinstance(service_documentation_url, str):
39
+ service_documentation_url = AnyHttpUrl(service_documentation_url)
40
+
41
+ self.issuer_url = issuer_url
42
+ self.service_documentation_url = service_documentation_url
43
+ self.client_registration_options = client_registration_options
44
+ self.revocation_options = revocation_options
45
+ self.required_scopes = required_scopes
src/fastmcp/{client/base.py → server/auth/providers/__init__.py} RENAMED
File without changes
src/fastmcp/server/auth/providers/bearer.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from dataclasses import dataclass
3
+ from typing import Any, TypedDict
4
+
5
+ import httpx
6
+ from authlib.jose import JsonWebKey, JsonWebToken
7
+ from authlib.jose.errors import JoseError
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric import rsa
10
+ from mcp.server.auth.provider import (
11
+ AccessToken,
12
+ AuthorizationCode,
13
+ AuthorizationParams,
14
+ RefreshToken,
15
+ )
16
+ from mcp.shared.auth import (
17
+ OAuthClientInformationFull,
18
+ OAuthToken,
19
+ )
20
+ from pydantic import SecretStr
21
+
22
+ from fastmcp.server.auth.auth import (
23
+ ClientRegistrationOptions,
24
+ OAuthProvider,
25
+ RevocationOptions,
26
+ )
27
+
28
+
29
+ class JWKData(TypedDict, total=False):
30
+ """JSON Web Key data structure."""
31
+
32
+ kty: str # Key type (e.g., "RSA") - required
33
+ kid: str # Key ID (optional but recommended)
34
+ use: str # Usage (e.g., "sig")
35
+ alg: str # Algorithm (e.g., "RS256")
36
+ n: str # Modulus (for RSA keys)
37
+ e: str # Exponent (for RSA keys)
38
+ x5c: list[str] # X.509 certificate chain (for JWKs)
39
+ x5t: str # X.509 certificate thumbprint (for JWKs)
40
+
41
+
42
+ class JWKSData(TypedDict):
43
+ """JSON Web Key Set data structure."""
44
+
45
+ keys: list[JWKData]
46
+
47
+
48
+ @dataclass(frozen=True, kw_only=True, repr=False)
49
+ class RSAKeyPair:
50
+ private_key: SecretStr
51
+ public_key: str
52
+
53
+ @classmethod
54
+ def generate(cls) -> "RSAKeyPair":
55
+ """
56
+ Generate an RSA key pair for testing.
57
+
58
+ Returns:
59
+ tuple: (private_key_pem, public_key_pem)
60
+ """
61
+ # Generate private key
62
+ private_key = rsa.generate_private_key(
63
+ public_exponent=65537,
64
+ key_size=2048,
65
+ )
66
+
67
+ # Get public key
68
+ public_key = private_key.public_key()
69
+
70
+ # Serialize private key to PEM format
71
+ private_pem = private_key.private_bytes(
72
+ encoding=serialization.Encoding.PEM,
73
+ format=serialization.PrivateFormat.PKCS8,
74
+ encryption_algorithm=serialization.NoEncryption(),
75
+ ).decode("utf-8")
76
+
77
+ # Serialize public key to PEM format
78
+ public_pem = public_key.public_bytes(
79
+ encoding=serialization.Encoding.PEM,
80
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
81
+ ).decode("utf-8")
82
+
83
+ return cls(
84
+ private_key=SecretStr(private_pem),
85
+ public_key=public_pem,
86
+ )
87
+
88
+ def create_token(
89
+ self,
90
+ subject: str = "fastmcp-user",
91
+ issuer: str = "https://fastmcp.example.com",
92
+ audience: str | None = None,
93
+ scopes: list[str] | None = None,
94
+ expires_in_seconds: int = 3600,
95
+ additional_claims: dict[str, Any] | None = None,
96
+ kid: str | None = None,
97
+ ) -> str:
98
+ """
99
+ Generate a test JWT token for testing purposes.
100
+
101
+ Args:
102
+ private_key_pem: RSA private key in PEM format
103
+ subject: Subject claim (usually user ID)
104
+ issuer: Issuer claim
105
+ audience: Audience claim (optional)
106
+ scopes: List of scopes to include
107
+ expires_in_seconds: Token expiration time in seconds
108
+ additional_claims: Any additional claims to include
109
+ kid: Key ID for JWKS lookup (optional)
110
+
111
+ Returns:
112
+ Signed JWT token string
113
+ """
114
+ jwt = JsonWebToken(["RS256"])
115
+
116
+ now = int(time.time())
117
+
118
+ # Build payload
119
+ payload = {
120
+ "iss": issuer,
121
+ "sub": subject,
122
+ "iat": now,
123
+ "exp": now + expires_in_seconds,
124
+ }
125
+
126
+ if audience:
127
+ payload["aud"] = audience
128
+
129
+ if scopes:
130
+ payload["scope"] = " ".join(scopes)
131
+
132
+ if additional_claims:
133
+ payload.update(additional_claims)
134
+
135
+ # Create header
136
+ header = {"alg": "RS256"}
137
+ if kid:
138
+ header["kid"] = kid
139
+
140
+ # Sign and return token
141
+ token_bytes = jwt.encode(
142
+ header,
143
+ payload,
144
+ key=self.private_key.get_secret_value(),
145
+ )
146
+ return token_bytes.decode("utf-8")
147
+
148
+
149
+ class BearerAuthProvider(OAuthProvider):
150
+ """
151
+ Simple JWT Bearer Token validator for hosted MCP servers.
152
+ Uses RS256 asymmetric encryption. Supports either static public key
153
+ or JWKS URI for key rotation.
154
+
155
+ Note that this provider DOES NOT permit client registration or revocation, or any OAuth flows.
156
+ It is intended to be used with a control plane that manages clients and tokens.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ public_key: str | None = None,
162
+ jwks_uri: str | None = None,
163
+ issuer: str | None = None,
164
+ audience: str | None = None,
165
+ required_scopes: list[str] | None = None,
166
+ ):
167
+ """
168
+ Initialize the provider. Either public_key or jwks_uri must be provided.
169
+
170
+ Args:
171
+ public_key: RSA public key in PEM format (for static key)
172
+ jwks_uri: URI to fetch keys from (for key rotation)
173
+ issuer: Expected issuer claim (optional)
174
+ audience: Expected audience claim (optional)
175
+ required_scopes: List of required scopes for access (optional)
176
+ """
177
+ if not (public_key or jwks_uri):
178
+ raise ValueError("Either public_key or jwks_uri must be provided")
179
+ if public_key and jwks_uri:
180
+ raise ValueError("Provide either public_key or jwks_uri, not both")
181
+
182
+ super().__init__(
183
+ issuer_url=issuer or "https://fastmcp.example.com",
184
+ client_registration_options=ClientRegistrationOptions(enabled=False),
185
+ revocation_options=RevocationOptions(enabled=False),
186
+ required_scopes=required_scopes,
187
+ )
188
+
189
+ self.issuer = issuer
190
+ self.audience = audience
191
+ self.public_key = public_key
192
+ self.jwks_uri = jwks_uri
193
+ self.jwt = JsonWebToken(["RS256"])
194
+
195
+ # Simple JWKS cache
196
+ self._jwks_cache: dict[str, str] = {}
197
+ self._jwks_cache_time: float = 0
198
+ self._cache_ttl = 3600 # 1 hour
199
+
200
+ async def _get_verification_key(self, token: str) -> str:
201
+ """Get the verification key for the token."""
202
+ if self.public_key:
203
+ return self.public_key
204
+
205
+ # Extract kid from token header for JWKS lookup
206
+ try:
207
+ import base64
208
+ import json
209
+
210
+ header_b64 = token.split(".")[0]
211
+ header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
212
+ header = json.loads(base64.urlsafe_b64decode(header_b64))
213
+ kid = header.get("kid")
214
+
215
+ return await self._get_jwks_key(kid)
216
+
217
+ except Exception as e:
218
+ raise ValueError(f"Failed to extract key ID from token: {e}")
219
+
220
+ async def _get_jwks_key(self, kid: str | None) -> str:
221
+ """Fetch key from JWKS with simple caching."""
222
+ if not self.jwks_uri:
223
+ raise ValueError("JWKS URI not configured")
224
+
225
+ current_time = time.time()
226
+
227
+ # Check cache first
228
+ if current_time - self._jwks_cache_time < self._cache_ttl:
229
+ if kid and kid in self._jwks_cache:
230
+ return self._jwks_cache[kid]
231
+ elif not kid and len(self._jwks_cache) == 1:
232
+ # If no kid but only one key cached, use it
233
+ return next(iter(self._jwks_cache.values()))
234
+
235
+ # Fetch JWKS
236
+ try:
237
+ async with httpx.AsyncClient() as client:
238
+ response = await client.get(self.jwks_uri)
239
+ response.raise_for_status()
240
+ jwks_data = response.json()
241
+
242
+ # Cache all keys
243
+ self._jwks_cache = {}
244
+ for key_data in jwks_data.get("keys", []):
245
+ key_kid = key_data.get("kid")
246
+ jwk = JsonWebKey.import_key(key_data)
247
+ public_key = jwk.get_public_key() # type: ignore
248
+
249
+ if key_kid:
250
+ self._jwks_cache[key_kid] = public_key
251
+ else:
252
+ # Key without kid - use a default identifier
253
+ self._jwks_cache["_default"] = public_key
254
+
255
+ self._jwks_cache_time = current_time
256
+
257
+ # Select the appropriate key
258
+ if kid:
259
+ if kid not in self._jwks_cache:
260
+ raise ValueError(f"Key ID '{kid}' not found in JWKS")
261
+ return self._jwks_cache[kid]
262
+ else:
263
+ # No kid in token - only allow if there's exactly one key
264
+ if len(self._jwks_cache) == 1:
265
+ return next(iter(self._jwks_cache.values()))
266
+ elif len(self._jwks_cache) > 1:
267
+ raise ValueError(
268
+ "Multiple keys in JWKS but no key ID (kid) in token"
269
+ )
270
+ else:
271
+ raise ValueError("No keys found in JWKS")
272
+
273
+ except Exception as e:
274
+ raise ValueError(f"Failed to fetch JWKS: {e}")
275
+
276
+ async def load_access_token(self, token: str) -> AccessToken | None:
277
+ """
278
+ Validates the provided JWT bearer token.
279
+
280
+ Args:
281
+ token: The JWT token string to validate
282
+
283
+ Returns:
284
+ AccessToken object if valid, None if invalid or expired
285
+ """
286
+ try:
287
+ # Get verification key (static or from JWKS)
288
+ verification_key = await self._get_verification_key(token)
289
+
290
+ # Decode and verify the JWT token
291
+ claims = self.jwt.decode(token, verification_key)
292
+
293
+ # Validate expiration
294
+ exp = claims.get("exp")
295
+ if exp and exp < time.time():
296
+ return None
297
+
298
+ # Validate issuer - note we use issuer instead of issuer_url here because
299
+ # issuer is optional, allowing users to make this check optional
300
+ if self.issuer:
301
+ if claims.get("iss") != self.issuer:
302
+ return None
303
+
304
+ # Validate audience if configured
305
+ if self.audience:
306
+ aud = claims.get("aud")
307
+ if isinstance(aud, list):
308
+ if self.audience not in aud:
309
+ return None
310
+ elif aud != self.audience:
311
+ return None
312
+
313
+ # Extract claims - prefer client_id over sub for OAuth application identification
314
+ client_id = claims.get("client_id") or claims.get("sub") or "unknown"
315
+ scopes = self._extract_scopes(claims)
316
+
317
+ return AccessToken(
318
+ token=token,
319
+ client_id=str(client_id),
320
+ scopes=scopes,
321
+ expires_at=int(exp) if exp else None,
322
+ )
323
+
324
+ except JoseError:
325
+ return None
326
+ except Exception:
327
+ return None
328
+
329
+ def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
330
+ """Extract scopes from JWT claims."""
331
+ scope_claim = claims.get("scope", "")
332
+ if isinstance(scope_claim, str):
333
+ return scope_claim.split()
334
+ elif isinstance(scope_claim, list):
335
+ return scope_claim
336
+ return []
337
+
338
+ # --- Unused OAuth server methods ---
339
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
340
+ raise NotImplementedError("Client management not supported")
341
+
342
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
343
+ raise NotImplementedError("Client registration not supported")
344
+
345
+ async def authorize(
346
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
347
+ ) -> str:
348
+ raise NotImplementedError("Authorization flow not supported")
349
+
350
+ async def load_authorization_code(
351
+ self, client: OAuthClientInformationFull, authorization_code: str
352
+ ) -> AuthorizationCode | None:
353
+ raise NotImplementedError("Authorization code flow not supported")
354
+
355
+ async def exchange_authorization_code(
356
+ self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
357
+ ) -> OAuthToken:
358
+ raise NotImplementedError("Authorization code exchange not supported")
359
+
360
+ async def load_refresh_token(
361
+ self, client: OAuthClientInformationFull, refresh_token: str
362
+ ) -> RefreshToken | None:
363
+ raise NotImplementedError("Refresh token flow not supported")
364
+
365
+ async def exchange_refresh_token(
366
+ self,
367
+ client: OAuthClientInformationFull,
368
+ refresh_token: RefreshToken,
369
+ scopes: list[str],
370
+ ) -> OAuthToken:
371
+ raise NotImplementedError("Refresh token exchange not supported")
372
+
373
+ async def revoke_token(
374
+ self,
375
+ token: AccessToken | RefreshToken,
376
+ ) -> None:
377
+ raise NotImplementedError("Token revocation not supported")
src/fastmcp/server/auth/providers/bearer_env.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+ from fastmcp.server.auth.providers.bearer import BearerAuthProvider
4
+
5
+
6
+ # Sentinel object to indicate that a setting is not set
7
+ class _NotSet:
8
+ pass
9
+
10
+
11
+ class EnvBearerAuthProviderSettings(BaseSettings):
12
+ """Settings for the BearerAuthProvider."""
13
+
14
+ model_config = SettingsConfigDict(
15
+ env_prefix="FASTMCP_AUTH_BEARER_",
16
+ env_file=".env",
17
+ extra="ignore",
18
+ )
19
+
20
+ public_key: str | None = None
21
+ jwks_uri: str | None = None
22
+ issuer: str | None = None
23
+ audience: str | None = None
24
+ required_scopes: list[str] | None = None
25
+
26
+
27
+ class EnvBearerAuthProvider(BearerAuthProvider):
28
+ """
29
+ A BearerAuthProvider that loads settings from environment variables. Any
30
+ providing setting will always take precedence over the environment
31
+ variables.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ public_key: str | None | type[_NotSet] = _NotSet,
37
+ jwks_uri: str | None | type[_NotSet] = _NotSet,
38
+ issuer: str | None | type[_NotSet] = _NotSet,
39
+ audience: str | None | type[_NotSet] = _NotSet,
40
+ required_scopes: list[str] | None | type[_NotSet] = _NotSet,
41
+ ):
42
+ """
43
+ Initialize the provider.
44
+
45
+ Args:
46
+ public_key: RSA public key in PEM format (for static key)
47
+ jwks_uri: URI to fetch keys from (for key rotation)
48
+ issuer: Expected issuer claim (optional)
49
+ audience: Expected audience claim (optional)
50
+ required_scopes: List of required scopes for access (optional)
51
+ """
52
+ kwargs = {
53
+ "public_key": public_key,
54
+ "jwks_uri": jwks_uri,
55
+ "issuer": issuer,
56
+ "audience": audience,
57
+ "required_scopes": required_scopes,
58
+ }
59
+ settings = EnvBearerAuthProviderSettings(
60
+ **{k: v for k, v in kwargs.items() if v is not _NotSet}
61
+ )
62
+ super().__init__(**settings.model_dump())
src/fastmcp/server/auth/providers/in_memory.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This is a simple in-memory OAuth provider for testing purposes.
3
+ It simulates the OAuth 2.0 flow locally without external calls.
4
+ """
5
+
6
+ import secrets
7
+ import time
8
+
9
+ from mcp.server.auth.provider import (
10
+ AccessToken,
11
+ AuthorizationCode,
12
+ AuthorizationParams,
13
+ AuthorizeError,
14
+ RefreshToken,
15
+ TokenError,
16
+ construct_redirect_uri,
17
+ )
18
+ from mcp.shared.auth import (
19
+ OAuthClientInformationFull,
20
+ OAuthToken,
21
+ )
22
+ from pydantic import AnyHttpUrl
23
+
24
+ from fastmcp.server.auth.auth import (
25
+ ClientRegistrationOptions,
26
+ OAuthProvider,
27
+ RevocationOptions,
28
+ )
29
+
30
+ # Default expiration times (in seconds)
31
+ DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes
32
+ DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour
33
+ DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None # No expiry
34
+
35
+
36
+ class InMemoryOAuthProvider(OAuthProvider):
37
+ """
38
+ An in-memory OAuth provider for testing purposes.
39
+ It simulates the OAuth 2.0 flow locally without external calls.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ issuer_url: AnyHttpUrl | str | None = None,
45
+ service_documentation_url: AnyHttpUrl | str | None = None,
46
+ client_registration_options: ClientRegistrationOptions | None = None,
47
+ revocation_options: RevocationOptions | None = None,
48
+ required_scopes: list[str] | None = None,
49
+ ):
50
+ super().__init__(
51
+ issuer_url=issuer_url or "http://fastmcp.example.com",
52
+ service_documentation_url=service_documentation_url,
53
+ client_registration_options=client_registration_options,
54
+ revocation_options=revocation_options,
55
+ required_scopes=required_scopes,
56
+ )
57
+ self.clients: dict[str, OAuthClientInformationFull] = {}
58
+ self.auth_codes: dict[str, AuthorizationCode] = {}
59
+ self.access_tokens: dict[str, AccessToken] = {}
60
+ self.refresh_tokens: dict[str, RefreshToken] = {}
61
+
62
+ # For revoking associated tokens
63
+ self._access_to_refresh_map: dict[
64
+ str, str
65
+ ] = {} # access_token_str -> refresh_token_str
66
+ self._refresh_to_access_map: dict[
67
+ str, str
68
+ ] = {} # refresh_token_str -> access_token_str
69
+
70
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
71
+ return self.clients.get(client_id)
72
+
73
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
74
+ if client_info.client_id in self.clients:
75
+ # As per RFC 7591, if client_id is already known, it's an update.
76
+ # For this simple provider, we'll treat it as re-registration.
77
+ # A real provider might handle updates or raise errors for conflicts.
78
+ pass
79
+ self.clients[client_info.client_id] = client_info
80
+
81
+ async def authorize(
82
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
83
+ ) -> str:
84
+ """
85
+ Simulates user authorization and generates an authorization code.
86
+ Returns a redirect URI with the code and state.
87
+ """
88
+ if client.client_id not in self.clients:
89
+ raise AuthorizeError(
90
+ error="unauthorized_client",
91
+ error_description=f"Client '{client.client_id}' not registered.",
92
+ )
93
+
94
+ # Validate redirect_uri (already validated by AuthorizationHandler, but good practice)
95
+ try:
96
+ # OAuthClientInformationFull should have a method like validate_redirect_uri
97
+ # For this test provider, we assume it's valid if it matches one in client_info
98
+ # The AuthorizationHandler already does robust validation using client.validate_redirect_uri
99
+ if params.redirect_uri not in client.redirect_uris:
100
+ # This check might be too simplistic if redirect_uris can be patterns
101
+ # or if params.redirect_uri is None and client has a default.
102
+ # However, the AuthorizationHandler handles the primary validation.
103
+ pass # Let's assume AuthorizationHandler did its job.
104
+ except Exception: # Replace with specific validation error if client.validate_redirect_uri existed
105
+ raise AuthorizeError(
106
+ error="invalid_request", error_description="Invalid redirect_uri."
107
+ )
108
+
109
+ auth_code_value = f"test_auth_code_{secrets.token_hex(16)}"
110
+ expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS
111
+
112
+ # Ensure scopes are a list
113
+ scopes_list = params.scopes if params.scopes is not None else []
114
+ if client.scope: # Filter params.scopes against client's registered scopes
115
+ client_allowed_scopes = set(client.scope.split())
116
+ scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
117
+
118
+ auth_code = AuthorizationCode(
119
+ code=auth_code_value,
120
+ client_id=client.client_id,
121
+ redirect_uri=params.redirect_uri,
122
+ redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
123
+ scopes=scopes_list,
124
+ expires_at=expires_at,
125
+ code_challenge=params.code_challenge,
126
+ # code_challenge_method is assumed S256 by the framework
127
+ )
128
+ self.auth_codes[auth_code_value] = auth_code
129
+
130
+ return construct_redirect_uri(
131
+ str(params.redirect_uri), code=auth_code_value, state=params.state
132
+ )
133
+
134
+ async def load_authorization_code(
135
+ self, client: OAuthClientInformationFull, authorization_code: str
136
+ ) -> AuthorizationCode | None:
137
+ auth_code_obj = self.auth_codes.get(authorization_code)
138
+ if auth_code_obj:
139
+ if auth_code_obj.client_id != client.client_id:
140
+ return None # Belongs to a different client
141
+ if auth_code_obj.expires_at < time.time():
142
+ del self.auth_codes[authorization_code] # Expired
143
+ return None
144
+ return auth_code_obj
145
+ return None
146
+
147
+ async def exchange_authorization_code(
148
+ self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
149
+ ) -> OAuthToken:
150
+ # Authorization code should have been validated (existence, expiry, client_id match)
151
+ # by the TokenHandler calling load_authorization_code before this.
152
+ # We might want to re-verify or simply trust it's valid.
153
+
154
+ if authorization_code.code not in self.auth_codes:
155
+ raise TokenError(
156
+ "invalid_grant", "Authorization code not found or already used."
157
+ )
158
+
159
+ # Consume the auth code
160
+ del self.auth_codes[authorization_code.code]
161
+
162
+ access_token_value = f"test_access_token_{secrets.token_hex(32)}"
163
+ refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
164
+
165
+ access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
166
+
167
+ # Refresh token expiry
168
+ refresh_token_expires_at = None
169
+ if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
170
+ refresh_token_expires_at = int(
171
+ time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
172
+ )
173
+
174
+ self.access_tokens[access_token_value] = AccessToken(
175
+ token=access_token_value,
176
+ client_id=client.client_id,
177
+ scopes=authorization_code.scopes,
178
+ expires_at=access_token_expires_at,
179
+ )
180
+ self.refresh_tokens[refresh_token_value] = RefreshToken(
181
+ token=refresh_token_value,
182
+ client_id=client.client_id,
183
+ scopes=authorization_code.scopes, # Refresh token inherits scopes
184
+ expires_at=refresh_token_expires_at,
185
+ )
186
+
187
+ self._access_to_refresh_map[access_token_value] = refresh_token_value
188
+ self._refresh_to_access_map[refresh_token_value] = access_token_value
189
+
190
+ return OAuthToken(
191
+ access_token=access_token_value,
192
+ token_type="bearer",
193
+ expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
194
+ refresh_token=refresh_token_value,
195
+ scope=" ".join(authorization_code.scopes),
196
+ )
197
+
198
+ async def load_refresh_token(
199
+ self, client: OAuthClientInformationFull, refresh_token: str
200
+ ) -> RefreshToken | None:
201
+ token_obj = self.refresh_tokens.get(refresh_token)
202
+ if token_obj:
203
+ if token_obj.client_id != client.client_id:
204
+ return None # Belongs to different client
205
+ if token_obj.expires_at is not None and token_obj.expires_at < time.time():
206
+ self._revoke_internal(
207
+ refresh_token_str=token_obj.token
208
+ ) # Clean up expired
209
+ return None
210
+ return token_obj
211
+ return None
212
+
213
+ async def exchange_refresh_token(
214
+ self,
215
+ client: OAuthClientInformationFull,
216
+ refresh_token: RefreshToken, # This is the RefreshToken object, already loaded
217
+ scopes: list[str], # Requested scopes for the new access token
218
+ ) -> OAuthToken:
219
+ # Validate scopes: requested scopes must be a subset of original scopes
220
+ original_scopes = set(refresh_token.scopes)
221
+ requested_scopes = set(scopes)
222
+ if not requested_scopes.issubset(original_scopes):
223
+ raise TokenError(
224
+ "invalid_scope",
225
+ "Requested scopes exceed those authorized by the refresh token.",
226
+ )
227
+
228
+ # Invalidate old refresh token and its associated access token (rotation)
229
+ self._revoke_internal(refresh_token_str=refresh_token.token)
230
+
231
+ # Issue new tokens
232
+ new_access_token_value = f"test_access_token_{secrets.token_hex(32)}"
233
+ new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
234
+
235
+ access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
236
+
237
+ # Refresh token expiry
238
+ refresh_token_expires_at = None
239
+ if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
240
+ refresh_token_expires_at = int(
241
+ time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
242
+ )
243
+
244
+ self.access_tokens[new_access_token_value] = AccessToken(
245
+ token=new_access_token_value,
246
+ client_id=client.client_id,
247
+ scopes=scopes, # Use newly requested (and validated) scopes
248
+ expires_at=access_token_expires_at,
249
+ )
250
+ self.refresh_tokens[new_refresh_token_value] = RefreshToken(
251
+ token=new_refresh_token_value,
252
+ client_id=client.client_id,
253
+ scopes=scopes, # New refresh token also gets these scopes
254
+ expires_at=refresh_token_expires_at,
255
+ )
256
+
257
+ self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
258
+ self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
259
+
260
+ return OAuthToken(
261
+ access_token=new_access_token_value,
262
+ token_type="bearer",
263
+ expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
264
+ refresh_token=new_refresh_token_value,
265
+ scope=" ".join(scopes),
266
+ )
267
+
268
+ async def load_access_token(self, token: str) -> AccessToken | None:
269
+ token_obj = self.access_tokens.get(token)
270
+ if token_obj:
271
+ if token_obj.expires_at is not None and token_obj.expires_at < time.time():
272
+ self._revoke_internal(
273
+ access_token_str=token_obj.token
274
+ ) # Clean up expired
275
+ return None
276
+ return token_obj
277
+ return None
278
+
279
+ def _revoke_internal(
280
+ self, access_token_str: str | None = None, refresh_token_str: str | None = None
281
+ ):
282
+ """Internal helper to remove tokens and their associations."""
283
+ removed_access_token = None
284
+ removed_refresh_token = None
285
+
286
+ if access_token_str:
287
+ if access_token_str in self.access_tokens:
288
+ del self.access_tokens[access_token_str]
289
+ removed_access_token = access_token_str
290
+
291
+ # Get associated refresh token
292
+ associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
293
+ if associated_refresh:
294
+ if associated_refresh in self.refresh_tokens:
295
+ del self.refresh_tokens[associated_refresh]
296
+ removed_refresh_token = associated_refresh
297
+ self._refresh_to_access_map.pop(associated_refresh, None)
298
+
299
+ if refresh_token_str:
300
+ if refresh_token_str in self.refresh_tokens:
301
+ del self.refresh_tokens[refresh_token_str]
302
+ removed_refresh_token = refresh_token_str
303
+
304
+ # Get associated access token
305
+ associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
306
+ if associated_access:
307
+ if associated_access in self.access_tokens:
308
+ del self.access_tokens[associated_access]
309
+ removed_access_token = associated_access
310
+ self._access_to_refresh_map.pop(associated_access, None)
311
+
312
+ # Clean up any dangling references if one part of the pair was already gone
313
+ if removed_access_token and removed_access_token in self._access_to_refresh_map:
314
+ del self._access_to_refresh_map[removed_access_token]
315
+ if (
316
+ removed_refresh_token
317
+ and removed_refresh_token in self._refresh_to_access_map
318
+ ):
319
+ del self._refresh_to_access_map[removed_refresh_token]
320
+
321
+ async def revoke_token(
322
+ self,
323
+ token: AccessToken | RefreshToken,
324
+ ) -> None:
325
+ """Revokes an access or refresh token and its counterpart."""
326
+ if isinstance(token, AccessToken):
327
+ self._revoke_internal(access_token_str=token.token)
328
+ elif isinstance(token, RefreshToken):
329
+ self._revoke_internal(refresh_token_str=token.token)
330
+ # If token is not found or already revoked, _revoke_internal does nothing, which is correct.
src/fastmcp/server/dependencies.py CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
2
 
3
  from typing import TYPE_CHECKING, ParamSpec, TypeVar
4
 
 
 
5
  from starlette.requests import Request
6
 
7
  if TYPE_CHECKING:
@@ -10,6 +12,14 @@ if TYPE_CHECKING:
10
  P = ParamSpec("P")
11
  R = TypeVar("R")
12
 
 
 
 
 
 
 
 
 
13
 
14
  # --- Context ---
15
 
 
2
 
3
  from typing import TYPE_CHECKING, ParamSpec, TypeVar
4
 
5
+ from mcp.server.auth.middleware.auth_context import get_access_token
6
+ from mcp.server.auth.provider import AccessToken
7
  from starlette.requests import Request
8
 
9
  if TYPE_CHECKING:
 
12
  P = ParamSpec("P")
13
  R = TypeVar("R")
14
 
15
+ __all__ = [
16
+ "get_context",
17
+ "get_http_request",
18
+ "get_http_headers",
19
+ "get_access_token",
20
+ "AccessToken",
21
+ ]
22
+
23
 
24
  # --- Context ---
25
 
src/fastmcp/server/http.py CHANGED
@@ -10,14 +10,7 @@ from mcp.server.auth.middleware.bearer_auth import (
10
  BearerAuthBackend,
11
  RequireAuthMiddleware,
12
  )
13
- from mcp.server.auth.provider import (
14
- AccessTokenT,
15
- AuthorizationCodeT,
16
- OAuthAuthorizationServerProvider,
17
- RefreshTokenT,
18
- )
19
  from mcp.server.auth.routes import create_auth_routes
20
- from mcp.server.auth.settings import AuthSettings
21
  from mcp.server.lowlevel.server import LifespanResultT
22
  from mcp.server.sse import SseServerTransport
23
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
@@ -29,6 +22,7 @@ from starlette.responses import Response
29
  from starlette.routing import BaseRoute, Mount, Route
30
  from starlette.types import Lifespan, Receive, Scope, Send
31
 
 
32
  from fastmcp.utilities.logging import get_logger
33
 
34
  if TYPE_CHECKING:
@@ -75,17 +69,12 @@ class RequestContextMiddleware:
75
 
76
 
77
  def setup_auth_middleware_and_routes(
78
- auth_server_provider: OAuthAuthorizationServerProvider[
79
- AuthorizationCodeT, RefreshTokenT, AccessTokenT
80
- ]
81
- | None,
82
- auth_settings: AuthSettings | None,
83
  ) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
84
  """Set up authentication middleware and routes if auth is enabled.
85
 
86
  Args:
87
- auth_server_provider: The OAuth authorization server provider
88
- auth_settings: The auth settings
89
 
90
  Returns:
91
  Tuple of (middleware, auth_routes, required_scopes)
@@ -94,31 +83,25 @@ def setup_auth_middleware_and_routes(
94
  auth_routes: list[BaseRoute] = []
95
  required_scopes: list[str] = []
96
 
97
- if auth_server_provider:
98
- if not auth_settings:
99
- raise ValueError(
100
- "auth_settings must be provided when auth_server_provider is specified"
101
- )
 
 
102
 
103
- middleware = [
104
- Middleware(
105
- AuthenticationMiddleware,
106
- backend=BearerAuthBackend(provider=auth_server_provider),
107
- ),
108
- Middleware(AuthContextMiddleware),
109
- ]
110
-
111
- required_scopes = auth_settings.required_scopes or []
112
-
113
- auth_routes.extend(
114
- create_auth_routes(
115
- provider=auth_server_provider,
116
- issuer_url=auth_settings.issuer_url,
117
- service_documentation_url=auth_settings.service_documentation_url,
118
- client_registration_options=auth_settings.client_registration_options,
119
- revocation_options=auth_settings.revocation_options,
120
- )
121
  )
 
122
 
123
  return middleware, auth_routes, required_scopes
124
 
@@ -155,11 +138,7 @@ def create_sse_app(
155
  server: FastMCP[LifespanResultT],
156
  message_path: str,
157
  sse_path: str,
158
- auth_server_provider: OAuthAuthorizationServerProvider[
159
- AuthorizationCodeT, RefreshTokenT, AccessTokenT
160
- ]
161
- | None = None,
162
- auth_settings: AuthSettings | None = None,
163
  debug: bool = False,
164
  routes: list[BaseRoute] | None = None,
165
  middleware: list[Middleware] | None = None,
@@ -170,8 +149,7 @@ def create_sse_app(
170
  server: The FastMCP server instance
171
  message_path: Path for SSE messages
172
  sse_path: Path for SSE connections
173
- auth_server_provider: Optional auth provider
174
- auth_settings: Optional auth settings
175
  debug: Whether to enable debug mode
176
  routes: Optional list of custom routes
177
  middleware: Optional list of middleware
@@ -196,15 +174,15 @@ def create_sse_app(
196
  return Response()
197
 
198
  # Get auth middleware and routes
199
- auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
200
- auth_server_provider, auth_settings
201
- )
202
-
203
- server_routes.extend(auth_routes)
204
- server_middleware.extend(auth_middleware)
205
 
206
  # Add SSE routes with or without auth
207
- if auth_server_provider:
 
 
 
 
 
 
208
  # Auth is enabled, wrap endpoints with RequireAuthMiddleware
209
  server_routes.append(
210
  Route(
@@ -264,11 +242,7 @@ def create_streamable_http_app(
264
  server: FastMCP[LifespanResultT],
265
  streamable_http_path: str,
266
  event_store: None = None,
267
- auth_server_provider: OAuthAuthorizationServerProvider[
268
- AuthorizationCodeT, RefreshTokenT, AccessTokenT
269
- ]
270
- | None = None,
271
- auth_settings: AuthSettings | None = None,
272
  json_response: bool = False,
273
  stateless_http: bool = False,
274
  debug: bool = False,
@@ -281,8 +255,7 @@ def create_streamable_http_app(
281
  server: The FastMCP server instance
282
  streamable_http_path: Path for StreamableHTTP connections
283
  event_store: Optional event store for session management
284
- auth_server_provider: Optional auth provider
285
- auth_settings: Optional auth settings
286
  json_response: Whether to use JSON response format
287
  stateless_http: Whether to use stateless mode (new transport per request)
288
  debug: Whether to enable debug mode
@@ -331,16 +304,15 @@ def create_streamable_http_app(
331
  # Re-raise other RuntimeErrors if they don't match the specific message
332
  raise
333
 
334
- # Get auth middleware and routes
335
- auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
336
- auth_server_provider, auth_settings
337
- )
 
338
 
339
- server_routes.extend(auth_routes)
340
- server_middleware.extend(auth_middleware)
341
 
342
- # Add StreamableHTTP routes with or without auth
343
- if auth_server_provider:
344
  # Auth is enabled, wrap endpoint with RequireAuthMiddleware
345
  server_routes.append(
346
  Mount(
 
10
  BearerAuthBackend,
11
  RequireAuthMiddleware,
12
  )
 
 
 
 
 
 
13
  from mcp.server.auth.routes import create_auth_routes
 
14
  from mcp.server.lowlevel.server import LifespanResultT
15
  from mcp.server.sse import SseServerTransport
16
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
 
22
  from starlette.routing import BaseRoute, Mount, Route
23
  from starlette.types import Lifespan, Receive, Scope, Send
24
 
25
+ from fastmcp.server.auth.auth import OAuthProvider
26
  from fastmcp.utilities.logging import get_logger
27
 
28
  if TYPE_CHECKING:
 
69
 
70
 
71
  def setup_auth_middleware_and_routes(
72
+ auth: OAuthProvider,
 
 
 
 
73
  ) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
74
  """Set up authentication middleware and routes if auth is enabled.
75
 
76
  Args:
77
+ auth: The OAuthProvider authorization server provider
 
78
 
79
  Returns:
80
  Tuple of (middleware, auth_routes, required_scopes)
 
83
  auth_routes: list[BaseRoute] = []
84
  required_scopes: list[str] = []
85
 
86
+ middleware = [
87
+ Middleware(
88
+ AuthenticationMiddleware,
89
+ backend=BearerAuthBackend(provider=auth),
90
+ ),
91
+ Middleware(AuthContextMiddleware),
92
+ ]
93
 
94
+ required_scopes = auth.required_scopes or []
95
+
96
+ auth_routes.extend(
97
+ create_auth_routes(
98
+ provider=auth,
99
+ issuer_url=auth.issuer_url,
100
+ service_documentation_url=auth.service_documentation_url,
101
+ client_registration_options=auth.client_registration_options,
102
+ revocation_options=auth.revocation_options,
 
 
 
 
 
 
 
 
 
103
  )
104
+ )
105
 
106
  return middleware, auth_routes, required_scopes
107
 
 
138
  server: FastMCP[LifespanResultT],
139
  message_path: str,
140
  sse_path: str,
141
+ auth: OAuthProvider | None = None,
 
 
 
 
142
  debug: bool = False,
143
  routes: list[BaseRoute] | None = None,
144
  middleware: list[Middleware] | None = None,
 
149
  server: The FastMCP server instance
150
  message_path: Path for SSE messages
151
  sse_path: Path for SSE connections
152
+ auth: Optional auth provider
 
153
  debug: Whether to enable debug mode
154
  routes: Optional list of custom routes
155
  middleware: Optional list of middleware
 
174
  return Response()
175
 
176
  # Get auth middleware and routes
 
 
 
 
 
 
177
 
178
  # Add SSE routes with or without auth
179
+ if auth:
180
+ auth_middleware, auth_routes, required_scopes = (
181
+ setup_auth_middleware_and_routes(auth)
182
+ )
183
+
184
+ server_routes.extend(auth_routes)
185
+ server_middleware.extend(auth_middleware)
186
  # Auth is enabled, wrap endpoints with RequireAuthMiddleware
187
  server_routes.append(
188
  Route(
 
242
  server: FastMCP[LifespanResultT],
243
  streamable_http_path: str,
244
  event_store: None = None,
245
+ auth: OAuthProvider | None = None,
 
 
 
 
246
  json_response: bool = False,
247
  stateless_http: bool = False,
248
  debug: bool = False,
 
255
  server: The FastMCP server instance
256
  streamable_http_path: Path for StreamableHTTP connections
257
  event_store: Optional event store for session management
258
+ auth: Optional auth provider
 
259
  json_response: Whether to use JSON response format
260
  stateless_http: Whether to use stateless mode (new transport per request)
261
  debug: Whether to enable debug mode
 
304
  # Re-raise other RuntimeErrors if they don't match the specific message
305
  raise
306
 
307
+ # Add StreamableHTTP routes with or without auth
308
+ if auth:
309
+ auth_middleware, auth_routes, required_scopes = (
310
+ setup_auth_middleware_and_routes(auth)
311
+ )
312
 
313
+ server_routes.extend(auth_routes)
314
+ server_middleware.extend(auth_middleware)
315
 
 
 
316
  # Auth is enabled, wrap endpoint with RequireAuthMiddleware
317
  server_routes.append(
318
  Mount(
src/fastmcp/server/server.py CHANGED
@@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
18
  import anyio
19
  import httpx
20
  import uvicorn
21
- from mcp.server.auth.provider import OAuthAuthorizationServerProvider
22
  from mcp.server.lowlevel.helper_types import ReadResourceContents
23
  from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
24
  from mcp.server.lowlevel.server import Server as MCPServer
@@ -48,6 +47,8 @@ from fastmcp.prompts import Prompt, PromptManager
48
  from fastmcp.prompts.prompt import PromptResult
49
  from fastmcp.resources import Resource, ResourceManager
50
  from fastmcp.resources.template import ResourceTemplate
 
 
51
  from fastmcp.server.http import (
52
  StarletteWithLifespan,
53
  create_sse_app,
@@ -110,8 +111,7 @@ class FastMCP(Generic[LifespanResultT]):
110
  self,
111
  name: str | None = None,
112
  instructions: str | None = None,
113
- auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
114
- | None = None,
115
  lifespan: (
116
  Callable[
117
  [FastMCP[LifespanResultT]],
@@ -128,6 +128,7 @@ class FastMCP(Generic[LifespanResultT]):
128
  on_duplicate_prompts: DuplicateBehavior | None = None,
129
  resource_prefix_format: Literal["protocol", "path"] | None = None,
130
  mask_error_details: bool | None = None,
 
131
  **settings: Any,
132
  ):
133
  if settings:
@@ -186,13 +187,16 @@ class FastMCP(Generic[LifespanResultT]):
186
  lifespan=_lifespan_wrapper(self, lifespan),
187
  )
188
 
189
- if (self.settings.auth is not None) != (auth_server_provider is not None):
190
- # TODO: after we support separate authorization servers (see
191
- raise ValueError(
192
- "settings.auth must be specified if and only if auth_server_provider "
193
- "is specified"
194
- )
195
- self._auth_server_provider = auth_server_provider
 
 
 
196
 
197
  # Set up MCP protocol handlers
198
  self._setup_handlers()
@@ -907,8 +911,7 @@ class FastMCP(Generic[LifespanResultT]):
907
  server=self,
908
  message_path=message_path or self.settings.message_path,
909
  sse_path=path or self.settings.sse_path,
910
- auth_server_provider=self._auth_server_provider,
911
- auth_settings=self.settings.auth,
912
  debug=self.settings.debug,
913
  middleware=middleware,
914
  )
@@ -955,8 +958,7 @@ class FastMCP(Generic[LifespanResultT]):
955
  server=self,
956
  streamable_http_path=path or self.settings.streamable_http_path,
957
  event_store=None,
958
- auth_server_provider=self._auth_server_provider,
959
- auth_settings=self.settings.auth,
960
  json_response=self.settings.json_response,
961
  stateless_http=self.settings.stateless_http,
962
  debug=self.settings.debug,
@@ -967,8 +969,7 @@ class FastMCP(Generic[LifespanResultT]):
967
  server=self,
968
  message_path=self.settings.message_path,
969
  sse_path=path or self.settings.sse_path,
970
- auth_server_provider=self._auth_server_provider,
971
- auth_settings=self.settings.auth,
972
  debug=self.settings.debug,
973
  middleware=middleware,
974
  )
 
18
  import anyio
19
  import httpx
20
  import uvicorn
 
21
  from mcp.server.lowlevel.helper_types import ReadResourceContents
22
  from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
23
  from mcp.server.lowlevel.server import Server as MCPServer
 
47
  from fastmcp.prompts.prompt import PromptResult
48
  from fastmcp.resources import Resource, ResourceManager
49
  from fastmcp.resources.template import ResourceTemplate
50
+ from fastmcp.server.auth.auth import OAuthProvider
51
+ from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
52
  from fastmcp.server.http import (
53
  StarletteWithLifespan,
54
  create_sse_app,
 
111
  self,
112
  name: str | None = None,
113
  instructions: str | None = None,
114
+ auth: OAuthProvider | None = None,
 
115
  lifespan: (
116
  Callable[
117
  [FastMCP[LifespanResultT]],
 
128
  on_duplicate_prompts: DuplicateBehavior | None = None,
129
  resource_prefix_format: Literal["protocol", "path"] | None = None,
130
  mask_error_details: bool | None = None,
131
+ tools: list[Tool | Callable[..., Any]] | None = None,
132
  **settings: Any,
133
  ):
134
  if settings:
 
187
  lifespan=_lifespan_wrapper(self, lifespan),
188
  )
189
 
190
+ if auth is None and self.settings.default_auth_provider == "bearer_env":
191
+ auth = EnvBearerAuthProvider()
192
+ self.auth = auth
193
+
194
+ if tools:
195
+ for tool in tools:
196
+ if isinstance(tool, Tool):
197
+ self._tool_manager.add_tool(tool)
198
+ else:
199
+ self.add_tool(tool)
200
 
201
  # Set up MCP protocol handlers
202
  self._setup_handlers()
 
911
  server=self,
912
  message_path=message_path or self.settings.message_path,
913
  sse_path=path or self.settings.sse_path,
914
+ auth=self.auth,
 
915
  debug=self.settings.debug,
916
  middleware=middleware,
917
  )
 
958
  server=self,
959
  streamable_http_path=path or self.settings.streamable_http_path,
960
  event_store=None,
961
+ auth=self.auth,
 
962
  json_response=self.settings.json_response,
963
  stateless_http=self.settings.stateless_http,
964
  debug=self.settings.debug,
 
969
  server=self,
970
  message_path=self.settings.message_path,
971
  sse_path=path or self.settings.sse_path,
972
+ auth=self.auth,
 
973
  debug=self.settings.debug,
974
  middleware=middleware,
975
  )
src/fastmcp/settings.py CHANGED
@@ -1,16 +1,16 @@
1
  from __future__ import annotations as _annotations
2
 
3
  import inspect
4
- from typing import TYPE_CHECKING, Annotated, Literal
 
5
 
6
- from mcp.server.auth.settings import AuthSettings
7
  from pydantic import Field, model_validator
8
- from pydantic_settings import BaseSettings, SettingsConfigDict
 
 
 
9
  from typing_extensions import Self
10
 
11
- if TYPE_CHECKING:
12
- pass
13
-
14
  LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
15
 
16
  DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
@@ -27,6 +27,8 @@ class Settings(BaseSettings):
27
  nested_model_default_partial_update=True,
28
  )
29
 
 
 
30
  test_mode: bool = False
31
  log_level: LOG_LEVEL = "INFO"
32
  enable_rich_tracebacks: Annotated[
@@ -171,13 +173,30 @@ class ServerSettings(BaseSettings):
171
  # cache settings (for checking mounted servers)
172
  cache_expiration_seconds: float = 0
173
 
174
- auth: AuthSettings | None = None
175
-
176
  # StreamableHTTP settings
177
  json_response: bool = False
178
  stateless_http: bool = (
179
  False # If True, uses true stateless mode (new transport per request)
180
  )
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
  settings = Settings()
 
1
  from __future__ import annotations as _annotations
2
 
3
  import inspect
4
+ from pathlib import Path
5
+ from typing import Annotated, Literal
6
 
 
7
  from pydantic import Field, model_validator
8
+ from pydantic_settings import (
9
+ BaseSettings,
10
+ SettingsConfigDict,
11
+ )
12
  from typing_extensions import Self
13
 
 
 
 
14
  LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
15
 
16
  DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
 
27
  nested_model_default_partial_update=True,
28
  )
29
 
30
+ home: Path = Path.home() / ".fastmcp"
31
+
32
  test_mode: bool = False
33
  log_level: LOG_LEVEL = "INFO"
34
  enable_rich_tracebacks: Annotated[
 
173
  # cache settings (for checking mounted servers)
174
  cache_expiration_seconds: float = 0
175
 
 
 
176
  # StreamableHTTP settings
177
  json_response: bool = False
178
  stateless_http: bool = (
179
  False # If True, uses true stateless mode (new transport per request)
180
  )
181
 
182
+ # Auth settings
183
+ default_auth_provider: Annotated[
184
+ Literal["bearer_env"] | None,
185
+ Field(
186
+ description=inspect.cleandoc(
187
+ """
188
+ Configure the authentication provider. This setting is intended only to
189
+ be used for remote confirugation of providers that fully support
190
+ environment variable configuration.
191
+
192
+ If None, no automatic configuration will take place.
193
+
194
+ This setting is *always* overriden by any auth provider passed to the
195
+ FastMCP constructor.
196
+ """
197
+ ),
198
+ ),
199
+ ] = None
200
+
201
 
202
  settings = Settings()
src/fastmcp/utilities/http.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import socket
2
+
3
+
4
+ def find_available_port() -> int:
5
+ """Find an available port by letting the OS assign one."""
6
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
7
+ s.bind(("127.0.0.1", 0))
8
+ return s.getsockname()[1]
src/fastmcp/utilities/tests.py CHANGED
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal
11
  import uvicorn
12
 
13
  from fastmcp.settings import settings
 
14
 
15
  if TYPE_CHECKING:
16
  from fastmcp.server.server import FastMCP
@@ -71,30 +72,38 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
71
 
72
  @contextmanager
73
  def run_server_in_process(
74
- server_fn: Callable[..., None], *args
 
 
 
75
  ) -> Generator[str, None, None]:
76
  """
77
- Context manager that runs a Starlette app in a separate process and returns the
78
- server URL. When the context manager is exited, the server process is killed.
79
 
80
  Args:
81
- app: The Starlette app to run.
 
 
 
 
82
 
83
  Returns:
84
  The server URL.
85
  """
86
  host = "127.0.0.1"
87
- with socket.socket() as s:
88
- s.bind((host, 0))
89
- port = s.getsockname()[1]
 
90
 
91
  proc = multiprocessing.Process(
92
- target=server_fn, args=(host, port, *args), daemon=True
93
  )
94
  proc.start()
95
 
96
  # Wait for server to be running
97
- max_attempts = 100
98
  attempt = 0
99
  while attempt < max_attempts and proc.is_alive():
100
  try:
@@ -102,7 +111,10 @@ def run_server_in_process(
102
  s.connect((host, port))
103
  break
104
  except ConnectionRefusedError:
105
- time.sleep(0.01)
 
 
 
106
  attempt += 1
107
  else:
108
  raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
 
11
  import uvicorn
12
 
13
  from fastmcp.settings import settings
14
+ from fastmcp.utilities.http import find_available_port
15
 
16
  if TYPE_CHECKING:
17
  from fastmcp.server.server import FastMCP
 
72
 
73
  @contextmanager
74
  def run_server_in_process(
75
+ server_fn: Callable[..., None],
76
+ *args,
77
+ provide_host_and_port: bool = True,
78
+ **kwargs,
79
  ) -> Generator[str, None, None]:
80
  """
81
+ Context manager that runs a FastMCP server in a separate process and
82
+ returns the server URL. When the context manager is exited, the server process is killed.
83
 
84
  Args:
85
+ server_fn: The function that runs a FastMCP server. FastMCP servers are
86
+ not pickleable, so we need a function that creates and runs one.
87
+ *args: Arguments to pass to the server function.
88
+ provide_host_and_port: Whether to provide the host and port to the server function as kwargs.
89
+ **kwargs: Keyword arguments to pass to the server function.
90
 
91
  Returns:
92
  The server URL.
93
  """
94
  host = "127.0.0.1"
95
+ port = find_available_port()
96
+
97
+ if provide_host_and_port:
98
+ kwargs |= {"host": host, "port": port}
99
 
100
  proc = multiprocessing.Process(
101
+ target=server_fn, args=args, kwargs=kwargs, daemon=True
102
  )
103
  proc.start()
104
 
105
  # Wait for server to be running
106
+ max_attempts = 10
107
  attempt = 0
108
  while attempt < max_attempts and proc.is_alive():
109
  try:
 
111
  s.connect((host, port))
112
  break
113
  except ConnectionRefusedError:
114
+ if attempt < 3:
115
+ time.sleep(0.01)
116
+ else:
117
+ time.sleep(0.1)
118
  attempt += 1
119
  else:
120
  raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
src/fastmcp/py.typed → tests/auth/__init__.py RENAMED
File without changes
tests/auth/providers/test_bearer.py ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Generator
2
+ from typing import Any
3
+
4
+ import httpx
5
+ import pytest
6
+ from pytest_httpx import HTTPXMock
7
+
8
+ from fastmcp import Client, FastMCP
9
+ from fastmcp.client.auth import BearerAuth
10
+ from fastmcp.server.auth.providers.bearer import (
11
+ BearerAuthProvider,
12
+ JWKData,
13
+ JWKSData,
14
+ RSAKeyPair,
15
+ )
16
+ from fastmcp.utilities.tests import run_server_in_process
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def rsa_key_pair() -> RSAKeyPair:
21
+ return RSAKeyPair.generate()
22
+
23
+
24
+ @pytest.fixture(scope="module")
25
+ def bearer_token(rsa_key_pair: RSAKeyPair) -> str:
26
+ return rsa_key_pair.create_token(
27
+ subject="test-user",
28
+ issuer="https://test.example.com",
29
+ audience="https://api.example.com",
30
+ )
31
+
32
+
33
+ @pytest.fixture
34
+ def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
35
+ return BearerAuthProvider(
36
+ public_key=rsa_key_pair.public_key,
37
+ issuer="https://test.example.com",
38
+ audience="https://api.example.com",
39
+ )
40
+
41
+
42
+ def run_mcp_server(
43
+ public_key: str,
44
+ host: str,
45
+ port: int,
46
+ auth_kwargs: dict[str, Any] | None = None,
47
+ run_kwargs: dict[str, Any] | None = None,
48
+ ) -> None:
49
+ mcp = FastMCP(
50
+ auth=BearerAuthProvider(
51
+ public_key=public_key,
52
+ **auth_kwargs or {},
53
+ )
54
+ )
55
+
56
+ @mcp.tool()
57
+ def add(a: int, b: int) -> int:
58
+ return a + b
59
+
60
+ mcp.run(host=host, port=port, **run_kwargs or {})
61
+
62
+
63
+ @pytest.fixture(scope="module")
64
+ def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
65
+ with run_server_in_process(
66
+ run_mcp_server,
67
+ public_key=rsa_key_pair.public_key,
68
+ run_kwargs=dict(transport="streamable-http"),
69
+ ) as url:
70
+ yield f"{url}/mcp"
71
+
72
+
73
+ class TestRSAKeyPair:
74
+ def test_generate_key_pair(self):
75
+ """Test RSA key pair generation."""
76
+ key_pair = RSAKeyPair.generate()
77
+
78
+ assert key_pair.private_key is not None
79
+ assert key_pair.public_key is not None
80
+
81
+ # Check that keys are in PEM format
82
+ private_pem = key_pair.private_key.get_secret_value()
83
+ public_pem = key_pair.public_key
84
+
85
+ assert "-----BEGIN PRIVATE KEY-----" in private_pem
86
+ assert "-----END PRIVATE KEY-----" in private_pem
87
+ assert "-----BEGIN PUBLIC KEY-----" in public_pem
88
+ assert "-----END PUBLIC KEY-----" in public_pem
89
+
90
+ def test_create_basic_token(self, rsa_key_pair: RSAKeyPair):
91
+ """Test basic token creation."""
92
+ token = rsa_key_pair.create_token(
93
+ subject="test-user",
94
+ issuer="https://test.example.com",
95
+ )
96
+
97
+ assert isinstance(token, str)
98
+ assert len(token.split(".")) == 3 # JWT has 3 parts
99
+
100
+ def test_create_token_with_scopes(self, rsa_key_pair: RSAKeyPair):
101
+ """Test token creation with scopes."""
102
+ token = rsa_key_pair.create_token(
103
+ subject="test-user",
104
+ issuer="https://test.example.com",
105
+ scopes=["read", "write"],
106
+ )
107
+
108
+ assert isinstance(token, str)
109
+ # We'll validate the scopes in the BearerToken tests
110
+
111
+
112
+ class TestBearerTokenJWKS:
113
+ """Tests for JWKS URI functionality."""
114
+
115
+ @pytest.fixture
116
+ def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
117
+ """Provider configured with JWKS URI."""
118
+ return BearerAuthProvider(
119
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
120
+ issuer="https://test.example.com",
121
+ audience="https://api.example.com",
122
+ )
123
+
124
+ @pytest.fixture
125
+ def mock_jwks_data(self, rsa_key_pair: RSAKeyPair) -> JWKSData:
126
+ """Create mock JWKS data from RSA key pair."""
127
+ from authlib.jose import JsonWebKey
128
+
129
+ # Create JWK from the RSA public key
130
+ jwk = JsonWebKey.import_key(rsa_key_pair.public_key) # type: ignore
131
+ jwk_data: JWKData = jwk.as_dict() # type: ignore
132
+ jwk_data["kid"] = "test-key-1"
133
+ jwk_data["alg"] = "RS256"
134
+
135
+ return {"keys": [jwk_data]}
136
+
137
+ async def test_jwks_token_validation(
138
+ self,
139
+ rsa_key_pair: RSAKeyPair,
140
+ jwks_provider: BearerAuthProvider,
141
+ mock_jwks_data: JWKSData,
142
+ httpx_mock: HTTPXMock,
143
+ ):
144
+ """Test token validation using JWKS URI."""
145
+ httpx_mock.add_response(
146
+ url="https://test.example.com/.well-known/jwks.json",
147
+ json=mock_jwks_data,
148
+ )
149
+ token = rsa_key_pair.create_token(
150
+ subject="test-user",
151
+ issuer="https://test.example.com",
152
+ audience="https://api.example.com",
153
+ )
154
+
155
+ access_token = await jwks_provider.load_access_token(token)
156
+ assert access_token is not None
157
+ assert access_token.client_id == "test-user"
158
+
159
+ async def test_jwks_token_validation_with_invalid_key(
160
+ self,
161
+ rsa_key_pair: RSAKeyPair,
162
+ jwks_provider: BearerAuthProvider,
163
+ mock_jwks_data: JWKSData,
164
+ httpx_mock: HTTPXMock,
165
+ ):
166
+ httpx_mock.add_response(
167
+ url="https://test.example.com/.well-known/jwks.json",
168
+ json=mock_jwks_data,
169
+ )
170
+ token = RSAKeyPair.generate().create_token(
171
+ subject="test-user",
172
+ issuer="https://test.example.com",
173
+ audience="https://api.example.com",
174
+ )
175
+
176
+ access_token = await jwks_provider.load_access_token(token)
177
+ assert access_token is None
178
+
179
+ async def test_jwks_token_validation_with_kid(
180
+ self,
181
+ rsa_key_pair: RSAKeyPair,
182
+ jwks_provider: BearerAuthProvider,
183
+ mock_jwks_data: JWKSData,
184
+ httpx_mock: HTTPXMock,
185
+ ):
186
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
187
+ httpx_mock.add_response(
188
+ url="https://test.example.com/.well-known/jwks.json",
189
+ json=mock_jwks_data,
190
+ )
191
+ token = rsa_key_pair.create_token(
192
+ subject="test-user",
193
+ issuer="https://test.example.com",
194
+ audience="https://api.example.com",
195
+ kid="test-key-1",
196
+ )
197
+
198
+ access_token = await jwks_provider.load_access_token(token)
199
+ assert access_token is not None
200
+ assert access_token.client_id == "test-user"
201
+
202
+ async def test_jwks_token_validation_with_kid_and_no_kid_in_token(
203
+ self,
204
+ rsa_key_pair: RSAKeyPair,
205
+ jwks_provider: BearerAuthProvider,
206
+ mock_jwks_data: JWKSData,
207
+ httpx_mock: HTTPXMock,
208
+ ):
209
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
210
+ httpx_mock.add_response(
211
+ url="https://test.example.com/.well-known/jwks.json",
212
+ json=mock_jwks_data,
213
+ )
214
+ token = rsa_key_pair.create_token(
215
+ subject="test-user",
216
+ issuer="https://test.example.com",
217
+ audience="https://api.example.com",
218
+ )
219
+
220
+ access_token = await jwks_provider.load_access_token(token)
221
+ assert access_token is not None
222
+ assert access_token.client_id == "test-user"
223
+
224
+ async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks(
225
+ self,
226
+ rsa_key_pair: RSAKeyPair,
227
+ jwks_provider: BearerAuthProvider,
228
+ mock_jwks_data: JWKSData,
229
+ httpx_mock: HTTPXMock,
230
+ ):
231
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
232
+ httpx_mock.add_response(
233
+ url="https://test.example.com/.well-known/jwks.json",
234
+ json=mock_jwks_data,
235
+ )
236
+ token = rsa_key_pair.create_token(
237
+ subject="test-user",
238
+ issuer="https://test.example.com",
239
+ audience="https://api.example.com",
240
+ )
241
+
242
+ access_token = await jwks_provider.load_access_token(token)
243
+ assert access_token is not None
244
+ assert access_token.client_id == "test-user"
245
+
246
+ async def test_jwks_token_validation_with_kid_mismatch(
247
+ self,
248
+ rsa_key_pair: RSAKeyPair,
249
+ jwks_provider: BearerAuthProvider,
250
+ mock_jwks_data: JWKSData,
251
+ httpx_mock: HTTPXMock,
252
+ ):
253
+ mock_jwks_data["keys"][0]["kid"] = "test-key-1"
254
+ httpx_mock.add_response(
255
+ url="https://test.example.com/.well-known/jwks.json",
256
+ json=mock_jwks_data,
257
+ )
258
+ token = rsa_key_pair.create_token(
259
+ subject="test-user",
260
+ issuer="https://test.example.com",
261
+ audience="https://api.example.com",
262
+ kid="test-key-2",
263
+ )
264
+
265
+ access_token = await jwks_provider.load_access_token(token)
266
+ assert access_token is None
267
+
268
+ async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token(
269
+ self,
270
+ rsa_key_pair: RSAKeyPair,
271
+ jwks_provider: BearerAuthProvider,
272
+ mock_jwks_data: JWKSData,
273
+ httpx_mock: HTTPXMock,
274
+ ):
275
+ mock_jwks_data["keys"] = [
276
+ {
277
+ "kid": "test-key-1",
278
+ "alg": "RS256",
279
+ },
280
+ {
281
+ "kid": "test-key-2",
282
+ "alg": "RS256",
283
+ },
284
+ ]
285
+
286
+ httpx_mock.add_response(
287
+ url="https://test.example.com/.well-known/jwks.json",
288
+ json=mock_jwks_data,
289
+ )
290
+ token = rsa_key_pair.create_token(
291
+ subject="test-user",
292
+ issuer="https://test.example.com",
293
+ audience="https://api.example.com",
294
+ )
295
+
296
+ access_token = await jwks_provider.load_access_token(token)
297
+ assert access_token is None
298
+
299
+
300
+ class TestBearerToken:
301
+ def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
302
+ """Test provider initialization with public key."""
303
+ provider = BearerAuthProvider(
304
+ public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
305
+ )
306
+
307
+ assert provider.issuer == "https://test.example.com"
308
+ assert provider.public_key is not None
309
+ assert provider.jwks_uri is None
310
+
311
+ def test_initialization_with_jwks_uri(self):
312
+ """Test provider initialization with JWKS URI."""
313
+ provider = BearerAuthProvider(
314
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
315
+ issuer="https://test.example.com",
316
+ )
317
+
318
+ assert provider.issuer == "https://test.example.com"
319
+ assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json"
320
+ assert provider.public_key is None
321
+
322
+ def test_initialization_requires_key_or_uri(self):
323
+ """Test that either public_key or jwks_uri is required."""
324
+ with pytest.raises(
325
+ ValueError, match="Either public_key or jwks_uri must be provided"
326
+ ):
327
+ BearerAuthProvider(issuer="https://test.example.com")
328
+
329
+ def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
330
+ """Test that both public_key and jwks_uri cannot be provided."""
331
+ with pytest.raises(
332
+ ValueError, match="Provide either public_key or jwks_uri, not both"
333
+ ):
334
+ BearerAuthProvider(
335
+ public_key=rsa_key_pair.public_key,
336
+ jwks_uri="https://test.example.com/.well-known/jwks.json",
337
+ issuer="https://test.example.com",
338
+ )
339
+
340
+ async def test_valid_token_validation(
341
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
342
+ ):
343
+ """Test validation of a valid token."""
344
+ token = rsa_key_pair.create_token(
345
+ subject="test-user",
346
+ issuer="https://test.example.com",
347
+ audience="https://api.example.com",
348
+ scopes=["read", "write"],
349
+ )
350
+
351
+ access_token = await bearer_provider.load_access_token(token)
352
+
353
+ assert access_token is not None
354
+ assert access_token.client_id == "test-user"
355
+ assert "read" in access_token.scopes
356
+ assert "write" in access_token.scopes
357
+ assert access_token.expires_at is not None
358
+
359
+ async def test_expired_token_rejection(
360
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
361
+ ):
362
+ """Test rejection of expired tokens."""
363
+ token = rsa_key_pair.create_token(
364
+ subject="test-user",
365
+ issuer="https://test.example.com",
366
+ audience="https://api.example.com",
367
+ expires_in_seconds=-3600, # Expired 1 hour ago
368
+ )
369
+
370
+ access_token = await bearer_provider.load_access_token(token)
371
+ assert access_token is None
372
+
373
+ async def test_invalid_issuer_rejection(
374
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
375
+ ):
376
+ """Test rejection of tokens with invalid issuer."""
377
+ token = rsa_key_pair.create_token(
378
+ subject="test-user",
379
+ issuer="https://evil.example.com", # Wrong issuer
380
+ audience="https://api.example.com",
381
+ )
382
+
383
+ access_token = await bearer_provider.load_access_token(token)
384
+ assert access_token is None
385
+
386
+ async def test_invalid_audience_rejection(
387
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
388
+ ):
389
+ """Test rejection of tokens with invalid audience."""
390
+ token = rsa_key_pair.create_token(
391
+ subject="test-user",
392
+ issuer="https://test.example.com",
393
+ audience="https://wrong-api.example.com", # Wrong audience
394
+ )
395
+
396
+ access_token = await bearer_provider.load_access_token(token)
397
+ assert access_token is None
398
+
399
+ async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
400
+ """Test that issuer validation is skipped when provider has no issuer configured."""
401
+ provider = BearerAuthProvider(
402
+ public_key=rsa_key_pair.public_key,
403
+ issuer=None, # No issuer validation
404
+ )
405
+
406
+ token = rsa_key_pair.create_token(
407
+ subject="test-user", issuer="https://any.example.com"
408
+ )
409
+
410
+ access_token = await provider.load_access_token(token)
411
+ assert access_token is not None
412
+
413
+ async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
414
+ """Test that audience validation is skipped when provider has no audience configured."""
415
+ provider = BearerAuthProvider(
416
+ public_key=rsa_key_pair.public_key,
417
+ issuer="https://test.example.com",
418
+ audience=None, # No audience validation
419
+ )
420
+
421
+ token = rsa_key_pair.create_token(
422
+ subject="test-user",
423
+ issuer="https://test.example.com",
424
+ audience="https://any-api.example.com",
425
+ )
426
+
427
+ access_token = await provider.load_access_token(token)
428
+ assert access_token is not None
429
+
430
+ async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
431
+ """Test validation with multiple audiences in token."""
432
+ provider = BearerAuthProvider(
433
+ public_key=rsa_key_pair.public_key,
434
+ issuer="https://test.example.com",
435
+ audience="https://api.example.com",
436
+ )
437
+
438
+ token = rsa_key_pair.create_token(
439
+ subject="test-user",
440
+ issuer="https://test.example.com",
441
+ additional_claims={
442
+ "aud": ["https://api.example.com", "https://other-api.example.com"]
443
+ },
444
+ )
445
+
446
+ access_token = await provider.load_access_token(token)
447
+ assert access_token is not None
448
+
449
+ async def test_scope_extraction_string(
450
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
451
+ ):
452
+ """Test scope extraction from space-separated string."""
453
+ token = rsa_key_pair.create_token(
454
+ subject="test-user",
455
+ issuer="https://test.example.com",
456
+ audience="https://api.example.com",
457
+ scopes=["read", "write", "admin"],
458
+ )
459
+
460
+ access_token = await bearer_provider.load_access_token(token)
461
+
462
+ assert access_token is not None
463
+ assert set(access_token.scopes) == {"read", "write", "admin"}
464
+
465
+ async def test_scope_extraction_list(
466
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
467
+ ):
468
+ """Test scope extraction from list format."""
469
+ token = rsa_key_pair.create_token(
470
+ subject="test-user",
471
+ issuer="https://test.example.com",
472
+ audience="https://api.example.com",
473
+ additional_claims={"scope": ["read", "write"]}, # List format
474
+ )
475
+
476
+ access_token = await bearer_provider.load_access_token(token)
477
+
478
+ assert access_token is not None
479
+ assert set(access_token.scopes) == {"read", "write"}
480
+
481
+ async def test_no_scopes(
482
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
483
+ ):
484
+ """Test token with no scopes."""
485
+ token = rsa_key_pair.create_token(
486
+ subject="test-user",
487
+ issuer="https://test.example.com",
488
+ audience="https://api.example.com",
489
+ # No scopes
490
+ )
491
+
492
+ access_token = await bearer_provider.load_access_token(token)
493
+
494
+ assert access_token is not None
495
+ assert access_token.scopes == []
496
+
497
+ async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider):
498
+ """Test rejection of malformed tokens."""
499
+ malformed_tokens = [
500
+ "not.a.jwt",
501
+ "too.many.parts.here.invalid",
502
+ "invalid-token",
503
+ "",
504
+ "header.body", # Missing signature
505
+ ]
506
+
507
+ for token in malformed_tokens:
508
+ access_token = await bearer_provider.load_access_token(token)
509
+ assert access_token is None
510
+
511
+ async def test_invalid_signature_rejection(
512
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
513
+ ):
514
+ """Test rejection of tokens with invalid signatures."""
515
+ # Create a token with a different key pair
516
+ other_key_pair = RSAKeyPair.generate()
517
+ token = other_key_pair.create_token(
518
+ subject="test-user",
519
+ issuer="https://test.example.com",
520
+ audience="https://api.example.com",
521
+ )
522
+
523
+ access_token = await bearer_provider.load_access_token(token)
524
+ assert access_token is None
525
+
526
+ async def test_client_id_fallback(
527
+ self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
528
+ ):
529
+ """Test client_id extraction with fallback logic."""
530
+ # Test with explicit client_id claim
531
+ token = rsa_key_pair.create_token(
532
+ subject="user123",
533
+ issuer="https://test.example.com",
534
+ audience="https://api.example.com",
535
+ additional_claims={"client_id": "app456"},
536
+ )
537
+
538
+ access_token = await bearer_provider.load_access_token(token)
539
+ assert access_token is not None
540
+ assert access_token.client_id == "app456" # Should prefer client_id over sub
541
+
542
+
543
+ class TestFastMCPBearerAuth:
544
+ def test_bearer_auth(self):
545
+ mcp = FastMCP(
546
+ auth=BearerAuthProvider(issuer="https://test.example.com", public_key="abc")
547
+ )
548
+ assert isinstance(mcp.auth, BearerAuthProvider)
549
+
550
+ async def test_unauthorized_access(self, mcp_server_url: str):
551
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
552
+ async with Client(mcp_server_url) as client:
553
+ tools = await client.list_tools() # noqa: F841
554
+ assert exc_info.value.response.status_code == 401
555
+ assert "tools" not in locals()
556
+
557
+ async def test_authorized_access(self, mcp_server_url: str, bearer_token):
558
+ async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client:
559
+ tools = await client.list_tools() # noqa: F841
560
+ assert tools
561
+
562
+ async def test_invalid_token_raises_401(self, mcp_server_url: str):
563
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
564
+ async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client:
565
+ tools = await client.list_tools() # noqa: F841
566
+ assert exc_info.value.response.status_code == 401
567
+ assert "tools" not in locals()
568
+
569
+ async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
570
+ token = rsa_key_pair.create_token(
571
+ subject="test-user",
572
+ issuer="https://test.example.com",
573
+ audience="https://api.example.com",
574
+ expires_in_seconds=-3600,
575
+ )
576
+
577
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
578
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
579
+ tools = await client.list_tools() # noqa: F841
580
+ assert exc_info.value.response.status_code == 401
581
+ assert "tools" not in locals()
582
+
583
+ async def test_token_with_bad_signature(self, mcp_server_url: str):
584
+ rsa_key_pair = RSAKeyPair.generate()
585
+ token = rsa_key_pair.create_token()
586
+
587
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
588
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
589
+ tools = await client.list_tools() # noqa: F841
590
+ assert exc_info.value.response.status_code == 401
591
+ assert "tools" not in locals()
592
+
593
+ async def test_token_with_insufficient_scopes(
594
+ self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
595
+ ):
596
+ token = rsa_key_pair.create_token(
597
+ subject="test-user",
598
+ issuer="https://test.example.com",
599
+ audience="https://api.example.com",
600
+ scopes=["read"],
601
+ )
602
+
603
+ with run_server_in_process(
604
+ run_mcp_server,
605
+ public_key=rsa_key_pair.public_key,
606
+ auth_kwargs=dict(required_scopes=["read", "write"]),
607
+ run_kwargs=dict(transport="streamable-http"),
608
+ ) as url:
609
+ mcp_server_url = f"{url}/mcp"
610
+ with pytest.raises(httpx.HTTPStatusError) as exc_info:
611
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
612
+ tools = await client.list_tools() # noqa: F841
613
+ assert exc_info.value.response.status_code == 403
614
+ assert "tools" not in locals()
615
+
616
+ async def test_token_with_sufficient_scopes(
617
+ self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
618
+ ):
619
+ token = rsa_key_pair.create_token(
620
+ subject="test-user",
621
+ issuer="https://test.example.com",
622
+ audience="https://api.example.com",
623
+ scopes=["read", "write"],
624
+ )
625
+
626
+ with run_server_in_process(
627
+ run_mcp_server,
628
+ public_key=rsa_key_pair.public_key,
629
+ auth_kwargs=dict(required_scopes=["read", "write"]),
630
+ run_kwargs=dict(transport="streamable-http"),
631
+ ) as url:
632
+ mcp_server_url = f"{url}/mcp"
633
+ async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
634
+ tools = await client.list_tools()
635
+ assert tools
tests/auth/providers/test_bearer_env.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from pydantic import AnyHttpUrl, ValidationError
3
+
4
+ from fastmcp import FastMCP
5
+ from fastmcp.server.auth.providers.bearer import BearerAuthProvider
6
+ from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
7
+
8
+
9
+ def test_load_bearer_env_from_env_var(monkeypatch):
10
+ mcp = FastMCP()
11
+ assert mcp.auth is None
12
+
13
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
14
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
15
+
16
+ mcp_with_auth = FastMCP()
17
+ assert isinstance(mcp_with_auth.auth, EnvBearerAuthProvider)
18
+
19
+
20
+ def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatch):
21
+ mcp = FastMCP()
22
+ assert mcp.auth is None
23
+
24
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
25
+
26
+ with pytest.raises(
27
+ ValueError, match="Either public_key or jwks_uri must be provided"
28
+ ):
29
+ FastMCP()
30
+
31
+
32
+ def test_configure_bearer_env_from_env_var(monkeypatch):
33
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
34
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
35
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_ISSUER", "http://test-issuer")
36
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_AUDIENCE", "test-audience")
37
+ monkeypatch.setenv(
38
+ "FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", '["test-scope1", "test-scope2"]'
39
+ )
40
+
41
+ mcp = FastMCP()
42
+ assert isinstance(mcp.auth, EnvBearerAuthProvider)
43
+ assert mcp.auth.public_key == "test-public-key"
44
+ assert mcp.auth.issuer_url == AnyHttpUrl("http://test-issuer")
45
+ assert mcp.auth.audience == "test-audience"
46
+ assert mcp.auth.required_scopes == ["test-scope1", "test-scope2"]
47
+
48
+
49
+ def test_list_of_scopes_must_be_a_list(monkeypatch):
50
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
51
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", "test-scope1")
52
+
53
+ with pytest.raises(ValidationError, match="Input should be a valid list"):
54
+ FastMCP()
55
+
56
+
57
+ def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch):
58
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
59
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
60
+
61
+ mcp = FastMCP()
62
+ assert isinstance(mcp.auth, EnvBearerAuthProvider)
63
+ assert mcp.auth.jwks_uri == "test-jwks-uri"
64
+
65
+
66
+ def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch):
67
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
68
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
69
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
70
+
71
+ with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"):
72
+ FastMCP()
73
+
74
+
75
+ def test_provided_auth_takes_precedence_over_env_vars(monkeypatch):
76
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
77
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
78
+
79
+ mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2"))
80
+ assert isinstance(mcp.auth, BearerAuthProvider)
81
+ assert not isinstance(mcp.auth, EnvBearerAuthProvider)
82
+ assert mcp.auth.public_key == "test-public-key-2"
tests/auth/test_oauth_client.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Generator
2
+ from unittest.mock import patch
3
+ from urllib.parse import parse_qs, urlparse
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ import fastmcp.client.auth # Import module, not the function directly
9
+ from fastmcp.client import Client
10
+ from fastmcp.client.transports import StreamableHttpTransport
11
+ from fastmcp.server.auth.auth import ClientRegistrationOptions
12
+ from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
13
+ from fastmcp.server.server import FastMCP
14
+ from fastmcp.utilities.tests import run_server_in_process
15
+
16
+
17
+ def fastmcp_server(issuer_url: str):
18
+ """Create a FastMCP server with OAuth authentication."""
19
+ server = FastMCP(
20
+ "TestServer",
21
+ auth=InMemoryOAuthProvider(
22
+ issuer_url=issuer_url,
23
+ client_registration_options=ClientRegistrationOptions(enabled=True),
24
+ ),
25
+ )
26
+
27
+ @server.tool()
28
+ def add(a: int, b: int) -> int:
29
+ """Add two numbers together."""
30
+ return a + b
31
+
32
+ @server.resource("resource://test")
33
+ def get_test_resource() -> str:
34
+ """Get a test resource."""
35
+ return "Hello from authenticated resource!"
36
+
37
+ return server
38
+
39
+
40
+ def run_server(host: str, port: int, **kwargs) -> None:
41
+ fastmcp_server(f"http://{host}:{port}").run(host=host, port=port, **kwargs)
42
+
43
+
44
+ @pytest.fixture(scope="module")
45
+ def streamable_http_server() -> Generator[str, None, None]:
46
+ with run_server_in_process(run_server, transport="streamable-http") as url:
47
+ yield f"{url}/mcp"
48
+
49
+
50
+ @pytest.fixture()
51
+ def client_unauthorized(streamable_http_server: str) -> Client:
52
+ return Client(transport=StreamableHttpTransport(streamable_http_server))
53
+
54
+
55
+ class HeadlessOAuthProvider(httpx.Auth):
56
+ """
57
+ OAuth provider that bypasses browser interaction for testing.
58
+
59
+ This simulates the complete OAuth flow programmatically by:
60
+ 1. Discovering OAuth metadata from the server
61
+ 2. Registering a client
62
+ 3. Getting an authorization code (simulates user approval)
63
+ 4. Exchanging it for an access token
64
+ 5. Adding Bearer token to all requests
65
+
66
+ This enables testing OAuth-protected FastMCP servers without
67
+ requiring browser interaction or external OAuth providers.
68
+ """
69
+
70
+ def __init__(self, mcp_url: str):
71
+ self.mcp_url = mcp_url
72
+ parsed_url = urlparse(mcp_url)
73
+ self.server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
74
+ self._access_token = None
75
+
76
+ async def async_auth_flow(self, request):
77
+ """httpx.Auth interface - add Bearer token to requests."""
78
+ if not self._access_token:
79
+ await self._obtain_token()
80
+
81
+ if self._access_token:
82
+ request.headers["Authorization"] = f"Bearer {self._access_token}"
83
+
84
+ yield request
85
+
86
+ async def _obtain_token(self):
87
+ """Get a valid access token by simulating the OAuth flow."""
88
+ import base64
89
+ import hashlib
90
+ import secrets
91
+
92
+ from mcp.shared.auth import OAuthClientInformationFull
93
+ from pydantic import AnyHttpUrl
94
+
95
+ # Generate PKCE challenge/verifier
96
+ code_verifier = (
97
+ base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
98
+ )
99
+ code_challenge = (
100
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
101
+ .decode()
102
+ .rstrip("=")
103
+ )
104
+
105
+ # Create HTTP client to talk to the server
106
+ async with httpx.AsyncClient() as http_client:
107
+ # 1. Discover OAuth metadata
108
+ metadata_url = (
109
+ f"{self.server_base_url}/.well-known/oauth-authorization-server"
110
+ )
111
+ response = await http_client.get(metadata_url)
112
+ response.raise_for_status()
113
+ metadata = response.json()
114
+
115
+ # 2. Register a client
116
+ client_info = OAuthClientInformationFull(
117
+ client_id="test_client_headless",
118
+ client_secret="test_secret_headless",
119
+ redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")],
120
+ )
121
+
122
+ register_response = await http_client.post(
123
+ metadata["registration_endpoint"],
124
+ json=client_info.model_dump(mode="json"),
125
+ )
126
+ register_response.raise_for_status()
127
+ registered_client = register_response.json()
128
+
129
+ # 3. Get authorization code (simulate user approval)
130
+ auth_params = {
131
+ "response_type": "code",
132
+ "client_id": registered_client["client_id"],
133
+ "redirect_uri": "http://localhost:8080/callback",
134
+ "code_challenge": code_challenge,
135
+ "code_challenge_method": "S256",
136
+ "state": "test_state_headless",
137
+ }
138
+
139
+ auth_response = await http_client.get(
140
+ metadata["authorization_endpoint"],
141
+ params=auth_params,
142
+ follow_redirects=False,
143
+ )
144
+
145
+ # Extract auth code from redirect
146
+ if auth_response.status_code == 302:
147
+ redirect_url = auth_response.headers["location"]
148
+ parsed = urlparse(redirect_url)
149
+ query_params = parse_qs(parsed.query)
150
+
151
+ if "error" in query_params:
152
+ error = query_params["error"][0]
153
+ error_desc = query_params.get(
154
+ "error_description", ["Unknown error"]
155
+ )[0]
156
+ raise RuntimeError(
157
+ f"OAuth authorization failed: {error} - {error_desc}"
158
+ )
159
+
160
+ auth_code = query_params["code"][0]
161
+
162
+ # 4. Exchange auth code for access token
163
+ token_data = {
164
+ "grant_type": "authorization_code",
165
+ "client_id": registered_client["client_id"],
166
+ "client_secret": registered_client["client_secret"],
167
+ "code": auth_code,
168
+ "redirect_uri": "http://localhost:8080/callback",
169
+ "code_verifier": code_verifier,
170
+ }
171
+
172
+ token_response = await http_client.post(
173
+ metadata["token_endpoint"], data=token_data
174
+ )
175
+ token_response.raise_for_status()
176
+ token_info = token_response.json()
177
+
178
+ self._access_token = token_info["access_token"]
179
+ else:
180
+ raise RuntimeError(f"Authorization failed: {auth_response.status_code}")
181
+
182
+
183
+ @pytest.fixture()
184
+ def client_with_headless_oauth(
185
+ streamable_http_server: str,
186
+ ) -> Generator[Client, None, None]:
187
+ """Client with headless OAuth that bypasses browser interaction."""
188
+
189
+ # Patch the OAuth function to return our headless provider
190
+ def headless_oauth(*args, **kwargs):
191
+ mcp_url = args[0] if args else kwargs.get("mcp_url", "")
192
+ if not mcp_url:
193
+ raise ValueError("mcp_url is required")
194
+ return HeadlessOAuthProvider(mcp_url)
195
+
196
+ with patch("fastmcp.client.auth.OAuth", side_effect=headless_oauth):
197
+ client = Client(
198
+ transport=StreamableHttpTransport(streamable_http_server),
199
+ auth=fastmcp.client.auth.OAuth(mcp_url=streamable_http_server),
200
+ )
201
+ yield client
202
+
203
+
204
+ async def test_unauthorized(client_unauthorized: Client):
205
+ """Test that unauthenticated requests are rejected."""
206
+ with pytest.raises(httpx.HTTPStatusError, match="401 Unauthorized"):
207
+ async with client_unauthorized:
208
+ pass
209
+
210
+
211
+ async def test_ping(client_with_headless_oauth: Client):
212
+ """Test that we can ping the server."""
213
+ async with client_with_headless_oauth:
214
+ assert await client_with_headless_oauth.ping()
215
+
216
+
217
+ async def test_list_tools(client_with_headless_oauth: Client):
218
+ """Test that we can list tools."""
219
+ async with client_with_headless_oauth:
220
+ tools = await client_with_headless_oauth.list_tools()
221
+ tool_names = [tool.name for tool in tools]
222
+ assert "add" in tool_names
223
+
224
+
225
+ async def test_call_tool(client_with_headless_oauth: Client):
226
+ """Test that we can call a tool."""
227
+ async with client_with_headless_oauth:
228
+ result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
229
+ assert result[0].text == "8" # type: ignore[attr-defined]
230
+
231
+
232
+ async def test_list_resources(client_with_headless_oauth: Client):
233
+ """Test that we can list resources."""
234
+ async with client_with_headless_oauth:
235
+ resources = await client_with_headless_oauth.list_resources()
236
+ resource_uris = [str(resource.uri) for resource in resources]
237
+ assert "resource://test" in resource_uris
238
+
239
+
240
+ async def test_read_resource(client_with_headless_oauth: Client):
241
+ """Test that we can read a resource."""
242
+ async with client_with_headless_oauth:
243
+ resource = await client_with_headless_oauth.read_resource("resource://test")
244
+ assert resource[0].text == "Hello from authenticated resource!" # type: ignore[attr-defined]
245
+
246
+
247
+ async def test_oauth_server_metadata_discovery(streamable_http_server: str):
248
+ """Test that we can discover OAuth metadata from the running server."""
249
+ parsed_url = urlparse(streamable_http_server)
250
+ server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
251
+
252
+ async with httpx.AsyncClient() as client:
253
+ # Test OAuth discovery endpoint
254
+ metadata_url = f"{server_base_url}/.well-known/oauth-authorization-server"
255
+ response = await client.get(metadata_url)
256
+ assert response.status_code == 200
257
+
258
+ metadata = response.json()
259
+ assert "authorization_endpoint" in metadata
260
+ assert "token_endpoint" in metadata
261
+ assert "registration_endpoint" in metadata
262
+
263
+ # The endpoints should be properly formed URLs
264
+ assert metadata["authorization_endpoint"].startswith(server_base_url)
265
+ assert metadata["token_endpoint"].startswith(server_base_url)
tests/cli/__init__.py ADDED
File without changes
tests/client/__init__.py CHANGED
@@ -1 +0,0 @@
1
- """Client tests package."""
 
 
tests/client/test_client.py CHANGED
@@ -16,7 +16,6 @@ from fastmcp.client.transports import (
16
  infer_transport,
17
  )
18
  from fastmcp.exceptions import ResourceError, ToolError
19
- from fastmcp.prompts.prompt import TextContent
20
  from fastmcp.server.server import FastMCP
21
 
22
 
@@ -201,8 +200,7 @@ async def test_get_prompt(fastmcp_server):
201
  result = await client.get_prompt("welcome", {"name": "Developer"})
202
 
203
  # The result should contain our welcome message
204
- assert isinstance(result.messages[0].content, TextContent)
205
- assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
206
  assert result.description == "Example greeting prompt."
207
 
208
 
@@ -214,8 +212,7 @@ async def test_get_prompt_mcp(fastmcp_server):
214
  result = await client.get_prompt_mcp("welcome", {"name": "Developer"})
215
 
216
  # The result should contain our welcome message
217
- assert isinstance(result.messages[0].content, TextContent)
218
- assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
219
  assert result.description == "Example greeting prompt."
220
 
221
 
@@ -342,6 +339,52 @@ async def test_client_nested_context_manager(fastmcp_server):
342
  assert client._session is None
343
 
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  async def test_resource_template(fastmcp_server):
346
  """Test using a resource template with InMemoryClient."""
347
  client = Client(transport=FastMCPTransport(fastmcp_server))
@@ -476,9 +519,8 @@ class TestErrorHandling:
476
  async with client:
477
  result = await client.call_tool_mcp("error_tool", {})
478
  assert result.isError
479
- assert isinstance(result.content[0], TextContent)
480
- assert "test error" in result.content[0].text
481
- assert "abc" in result.content[0].text
482
 
483
  async def test_general_tool_exceptions_are_masked_when_enabled(self):
484
  mcp = FastMCP("TestServer", mask_error_details=True)
@@ -492,9 +534,8 @@ class TestErrorHandling:
492
  async with client:
493
  result = await client.call_tool_mcp("error_tool", {})
494
  assert result.isError
495
- assert isinstance(result.content[0], TextContent)
496
- assert "test error" not in result.content[0].text
497
- assert "abc" not in result.content[0].text
498
 
499
  async def test_specific_tool_errors_are_sent_to_client(self):
500
  mcp = FastMCP("TestServer")
@@ -508,9 +549,8 @@ class TestErrorHandling:
508
  async with client:
509
  result = await client.call_tool_mcp("custom_error_tool", {})
510
  assert result.isError
511
- assert isinstance(result.content[0], TextContent)
512
- assert "test error" in result.content[0].text
513
- assert "abc" in result.content[0].text
514
 
515
  async def test_general_resource_exceptions_are_not_masked_by_default(self):
516
  mcp = FastMCP("TestServer")
 
16
  infer_transport,
17
  )
18
  from fastmcp.exceptions import ResourceError, ToolError
 
19
  from fastmcp.server.server import FastMCP
20
 
21
 
 
200
  result = await client.get_prompt("welcome", {"name": "Developer"})
201
 
202
  # The result should contain our welcome message
203
+ assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined]
 
204
  assert result.description == "Example greeting prompt."
205
 
206
 
 
212
  result = await client.get_prompt_mcp("welcome", {"name": "Developer"})
213
 
214
  # The result should contain our welcome message
215
+ assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined]
 
216
  assert result.description == "Example greeting prompt."
217
 
218
 
 
339
  assert client._session is None
340
 
341
 
342
+ async def test_concurrent_client_context_managers():
343
+ """
344
+ Test that concurrent client usage doesn't cause cross-task cancel scope issues.
345
+ https://github.com/jlowin/fastmcp/pull/643
346
+ """
347
+ # Create a simple server
348
+ server = FastMCP("Test Server")
349
+
350
+ @server.tool()
351
+ def echo(text: str) -> str:
352
+ """Echo tool"""
353
+ return text
354
+
355
+ # Create client
356
+ client = Client(server)
357
+
358
+ # Track results
359
+ results = {}
360
+ errors = []
361
+
362
+ async def use_client(task_id: str, delay: float = 0):
363
+ """Use the client with a small delay to ensure overlap"""
364
+ try:
365
+ async with client:
366
+ # Add a small delay to ensure contexts overlap
367
+ await asyncio.sleep(delay)
368
+ # Make an actual call to exercise the session
369
+ tools = await client.list_tools()
370
+ results[task_id] = len(tools)
371
+ except Exception as e:
372
+ errors.append((task_id, str(e)))
373
+
374
+ # Run multiple tasks concurrently
375
+ # The key is having them enter and exit the context at different times
376
+ await asyncio.gather(
377
+ use_client("task1", 0.0),
378
+ use_client("task2", 0.01), # Slight delay to ensure overlap
379
+ use_client("task3", 0.02),
380
+ return_exceptions=False,
381
+ )
382
+
383
+ assert len(errors) == 0, f"Errors occurred: {errors}"
384
+ assert len(results) == 3
385
+ assert all(count == 1 for count in results.values()) # All should see 1 tool
386
+
387
+
388
  async def test_resource_template(fastmcp_server):
389
  """Test using a resource template with InMemoryClient."""
390
  client = Client(transport=FastMCPTransport(fastmcp_server))
 
519
  async with client:
520
  result = await client.call_tool_mcp("error_tool", {})
521
  assert result.isError
522
+ assert "test error" in result.content[0].text # type: ignore[attr-defined]
523
+ assert "abc" in result.content[0].text # type: ignore[attr-defined]
 
524
 
525
  async def test_general_tool_exceptions_are_masked_when_enabled(self):
526
  mcp = FastMCP("TestServer", mask_error_details=True)
 
534
  async with client:
535
  result = await client.call_tool_mcp("error_tool", {})
536
  assert result.isError
537
+ assert "test error" not in result.content[0].text # type: ignore[attr-defined]
538
+ assert "abc" not in result.content[0].text # type: ignore[attr-defined]
 
539
 
540
  async def test_specific_tool_errors_are_sent_to_client(self):
541
  mcp = FastMCP("TestServer")
 
549
  async with client:
550
  result = await client.call_tool_mcp("custom_error_tool", {})
551
  assert result.isError
552
+ assert "test error" in result.content[0].text # type: ignore[attr-defined]
553
+ assert "abc" in result.content[0].text # type: ignore[attr-defined]
 
554
 
555
  async def test_general_resource_exceptions_are_not_masked_by_default(self):
556
  mcp = FastMCP("TestServer")
tests/client/test_openapi.py CHANGED
@@ -1,11 +1,8 @@
1
  import json
2
- import sys
3
  from collections.abc import Generator
4
 
5
  import pytest
6
- import uvicorn
7
  from fastapi import FastAPI, Request
8
- from mcp.types import TextContent, TextResourceContents
9
 
10
  from fastmcp import Client, FastMCP
11
  from fastmcp.client.transports import SSETransport, StreamableHttpTransport
@@ -35,75 +32,34 @@ def fastmcp_server_for_headers() -> FastMCP:
35
  return mcp
36
 
37
 
38
- class TestClientHeaders:
39
- def run_shttp_server(self, host: str, port: int) -> None:
40
- try:
41
- app = fastmcp_server_for_headers().http_app(transport="streamable-http")
42
- server = uvicorn.Server(
43
- config=uvicorn.Config(
44
- app=app,
45
- host=host,
46
- port=port,
47
- log_level="error",
48
- lifespan="on",
49
- )
50
- )
51
- server.run()
52
- except Exception as e:
53
- print(f"Server error: {e}")
54
- sys.exit(1)
55
- sys.exit(0)
56
-
57
- def run_sse_server(self, host: str, port: int) -> None:
58
- try:
59
- app = fastmcp_server_for_headers().http_app(transport="sse")
60
- server = uvicorn.Server(
61
- config=uvicorn.Config(
62
- app=app,
63
- host=host,
64
- port=port,
65
- log_level="error",
66
- lifespan="on",
67
- )
68
- )
69
- server.run()
70
- except Exception as e:
71
- print(f"Server error: {e}")
72
- sys.exit(1)
73
- sys.exit(0)
74
-
75
- def run_proxy_server(self, host: str, port: int, remote_url: str) -> None:
76
- try:
77
- client = Client(transport=StreamableHttpTransport(remote_url))
78
- app = FastMCP.as_proxy(client).http_app(transport="streamable-http")
79
- server = uvicorn.Server(
80
- config=uvicorn.Config(
81
- app=app,
82
- host=host,
83
- port=port,
84
- log_level="error",
85
- lifespan="on",
86
- )
87
- )
88
- server.run()
89
- except Exception as e:
90
- print(f"Server error: {e}")
91
- sys.exit(1)
92
- sys.exit(0)
93
 
 
 
 
 
 
 
 
 
94
  @pytest.fixture(scope="class")
95
  def shttp_server(self) -> Generator[str, None, None]:
96
- with run_server_in_process(self.run_shttp_server) as url:
97
  yield f"{url}/mcp"
98
 
99
  @pytest.fixture(scope="class")
100
  def sse_server(self) -> Generator[str, None, None]:
101
- with run_server_in_process(self.run_sse_server) as url:
102
  yield f"{url}/sse"
103
 
104
  @pytest.fixture(scope="class")
105
  def proxy_server(self, shttp_server: str) -> Generator[str, None, None]:
106
- with run_server_in_process(self.run_proxy_server, shttp_server + "/mcp") as url:
 
 
 
 
107
  yield f"{url}/mcp"
108
 
109
  async def test_client_headers_sse_resource(self, sse_server: str):
@@ -111,8 +67,7 @@ class TestClientHeaders:
111
  transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
112
  ) as client:
113
  result = await client.read_resource("resource://get_headers_headers_get")
114
- assert isinstance(result[0], TextResourceContents)
115
- headers = json.loads(result[0].text)
116
  assert headers["x-test"] == "test-123"
117
 
118
  async def test_client_headers_shttp_resource(self, shttp_server: str):
@@ -122,8 +77,7 @@ class TestClientHeaders:
122
  )
123
  ) as client:
124
  result = await client.read_resource("resource://get_headers_headers_get")
125
- assert isinstance(result[0], TextResourceContents)
126
- headers = json.loads(result[0].text)
127
  assert headers["x-test"] == "test-123"
128
 
129
  async def test_client_headers_sse_resource_template(self, sse_server: str):
@@ -133,8 +87,7 @@ class TestClientHeaders:
133
  result = await client.read_resource(
134
  "resource://get_header_by_name_headers/x-test"
135
  )
136
- assert isinstance(result[0], TextResourceContents)
137
- header = json.loads(result[0].text)
138
  assert header == "test-123"
139
 
140
  async def test_client_headers_shttp_resource_template(self, shttp_server: str):
@@ -146,8 +99,7 @@ class TestClientHeaders:
146
  result = await client.read_resource(
147
  "resource://get_header_by_name_headers/x-test"
148
  )
149
- assert isinstance(result[0], TextResourceContents)
150
- header = json.loads(result[0].text)
151
  assert header == "test-123"
152
 
153
  async def test_client_headers_sse_tool(self, sse_server: str):
@@ -155,8 +107,7 @@ class TestClientHeaders:
155
  transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
156
  ) as client:
157
  result = await client.call_tool("post_headers_headers_post")
158
- assert isinstance(result[0], TextContent)
159
- headers = json.loads(result[0].text)
160
  assert headers["x-test"] == "test-123"
161
 
162
  async def test_client_headers_shttp_tool(self, shttp_server: str):
@@ -166,8 +117,7 @@ class TestClientHeaders:
166
  )
167
  ) as client:
168
  result = await client.call_tool("post_headers_headers_post")
169
- assert isinstance(result[0], TextContent)
170
- headers = json.loads(result[0].text)
171
  assert headers["x-test"] == "test-123"
172
 
173
  async def test_client_overrides_server_headers(self, shttp_server: str):
@@ -177,8 +127,7 @@ class TestClientHeaders:
177
  )
178
  ) as client:
179
  result = await client.read_resource("resource://get_headers_headers_get")
180
- assert isinstance(result[0], TextResourceContents)
181
- headers = json.loads(result[0].text)
182
  assert headers["x-server-header"] == "test-client"
183
 
184
  async def test_client_with_excluded_header_is_ignored(self, sse_server: str):
@@ -193,8 +142,7 @@ class TestClientHeaders:
193
  )
194
  ) as client:
195
  result = await client.read_resource("resource://get_headers_headers_get")
196
- assert isinstance(result[0], TextResourceContents)
197
- headers = json.loads(result[0].text)
198
  assert headers["not-host"] == "1.2.3.4"
199
  assert headers["host"] == "fastapi"
200
 
@@ -204,6 +152,5 @@ class TestClientHeaders:
204
  """
205
  async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
206
  result = await client.read_resource("resource://get_headers_headers_get")
207
- assert isinstance(result[0], TextResourceContents)
208
- headers = json.loads(result[0].text)
209
  assert headers["x-server-header"] == "test-abc"
 
1
  import json
 
2
  from collections.abc import Generator
3
 
4
  import pytest
 
5
  from fastapi import FastAPI, Request
 
6
 
7
  from fastmcp import Client, FastMCP
8
  from fastmcp.client.transports import SSETransport, StreamableHttpTransport
 
32
  return mcp
33
 
34
 
35
+ def run_server(host: str, port: int, **kwargs) -> None:
36
+ fastmcp_server_for_headers().run(host=host, port=port, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
+
39
+ def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
40
+ client = Client(transport=StreamableHttpTransport(shttp_url))
41
+ app = FastMCP.as_proxy(client)
42
+ app.run(host=host, port=port, **kwargs)
43
+
44
+
45
+ class TestClientHeaders:
46
  @pytest.fixture(scope="class")
47
  def shttp_server(self) -> Generator[str, None, None]:
48
+ with run_server_in_process(run_server, transport="streamable-http") as url:
49
  yield f"{url}/mcp"
50
 
51
  @pytest.fixture(scope="class")
52
  def sse_server(self) -> Generator[str, None, None]:
53
+ with run_server_in_process(run_server, transport="sse") as url:
54
  yield f"{url}/sse"
55
 
56
  @pytest.fixture(scope="class")
57
  def proxy_server(self, shttp_server: str) -> Generator[str, None, None]:
58
+ with run_server_in_process(
59
+ run_proxy_server,
60
+ shttp_url=shttp_server,
61
+ transport="streamable-http",
62
+ ) as url:
63
  yield f"{url}/mcp"
64
 
65
  async def test_client_headers_sse_resource(self, sse_server: str):
 
67
  transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
68
  ) as client:
69
  result = await client.read_resource("resource://get_headers_headers_get")
70
+ headers = json.loads(result[0].text) # type: ignore[attr-defined]
 
71
  assert headers["x-test"] == "test-123"
72
 
73
  async def test_client_headers_shttp_resource(self, shttp_server: str):
 
77
  )
78
  ) as client:
79
  result = await client.read_resource("resource://get_headers_headers_get")
80
+ headers = json.loads(result[0].text) # type: ignore[attr-defined]
 
81
  assert headers["x-test"] == "test-123"
82
 
83
  async def test_client_headers_sse_resource_template(self, sse_server: str):
 
87
  result = await client.read_resource(
88
  "resource://get_header_by_name_headers/x-test"
89
  )
90
+ header = json.loads(result[0].text) # type: ignore[attr-defined]
 
91
  assert header == "test-123"
92
 
93
  async def test_client_headers_shttp_resource_template(self, shttp_server: str):
 
99
  result = await client.read_resource(
100
  "resource://get_header_by_name_headers/x-test"
101
  )
102
+ header = json.loads(result[0].text) # type: ignore[attr-defined]
 
103
  assert header == "test-123"
104
 
105
  async def test_client_headers_sse_tool(self, sse_server: str):
 
107
  transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
108
  ) as client:
109
  result = await client.call_tool("post_headers_headers_post")
110
+ headers = json.loads(result[0].text) # type: ignore[attr-defined]
 
111
  assert headers["x-test"] == "test-123"
112
 
113
  async def test_client_headers_shttp_tool(self, shttp_server: str):
 
117
  )
118
  ) as client:
119
  result = await client.call_tool("post_headers_headers_post")
120
+ headers = json.loads(result[0].text) # type: ignore[attr-defined]
 
121
  assert headers["x-test"] == "test-123"
122
 
123
  async def test_client_overrides_server_headers(self, shttp_server: str):
 
127
  )
128
  ) as client:
129
  result = await client.read_resource("resource://get_headers_headers_get")
130
+ headers = json.loads(result[0].text) # type: ignore[attr-defined]
 
131
  assert headers["x-server-header"] == "test-client"
132
 
133
  async def test_client_with_excluded_header_is_ignored(self, sse_server: str):
 
142
  )
143
  ) as client:
144
  result = await client.read_resource("resource://get_headers_headers_get")
145
+ headers = json.loads(result[0].text) # type: ignore[attr-defined]
 
146
  assert headers["not-host"] == "1.2.3.4"
147
  assert headers["host"] == "fastapi"
148
 
 
152
  """
153
  async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
154
  result = await client.read_resource("resource://get_headers_headers_get")
155
+ headers = json.loads(result[0].text) # type: ignore[attr-defined]
 
156
  assert headers["x-server-header"] == "test-abc"
tests/client/test_roots.py CHANGED
@@ -1,7 +1,6 @@
1
  import json
2
 
3
  import pytest
4
- from mcp.types import TextContent
5
 
6
  from fastmcp import Client, Context, FastMCP
7
 
@@ -41,8 +40,7 @@ class TestClientRoots:
41
  async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
42
  async with Client(fastmcp_server, roots=roots) as client:
43
  result = await client.call_tool("list_roots", {})
44
- assert isinstance(result[0], TextContent)
45
- assert json.loads(result[0].text) == [
46
  "file://x/y/z",
47
  "file://x/y/z",
48
  ]
 
1
  import json
2
 
3
  import pytest
 
4
 
5
  from fastmcp import Client, Context, FastMCP
6
 
 
40
  async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
41
  async with Client(fastmcp_server, roots=roots) as client:
42
  result = await client.call_tool("list_roots", {})
43
+ assert json.loads(result[0].text) == [ # type: ignore[attr-defined]
 
44
  "file://x/y/z",
45
  "file://x/y/z",
46
  ]
tests/client/test_sse.py CHANGED
@@ -6,7 +6,6 @@ from collections.abc import Generator
6
  import pytest
7
  import uvicorn
8
  from mcp import McpError
9
- from mcp.types import TextResourceContents
10
  from starlette.applications import Starlette
11
  from starlette.routing import Mount
12
 
@@ -64,22 +63,13 @@ def fastmcp_server():
64
  return server
65
 
66
 
67
- def run_server(host: str, port: int, path: str | None = None) -> None:
68
- try:
69
- app = fastmcp_server().http_app(transport="sse", path=path)
70
- server = uvicorn.Server(
71
- config=uvicorn.Config(app=app, host=host, port=port, log_level="error")
72
- )
73
- server.run()
74
- except Exception as e:
75
- print(f"Server error: {e}")
76
- sys.exit(1)
77
- sys.exit(0)
78
 
79
 
80
  @pytest.fixture(autouse=True, scope="module")
81
  def sse_server() -> Generator[str, None, None]:
82
- with run_server_in_process(run_server) as url:
83
  yield f"{url}/sse"
84
 
85
 
@@ -96,29 +86,23 @@ async def test_http_headers(sse_server: str):
96
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
97
  ) as client:
98
  raw_result = await client.read_resource("request://headers")
99
- assert isinstance(raw_result[0], TextResourceContents)
100
- json_result = json.loads(raw_result[0].text)
101
  assert "x-demo-header" in json_result
102
  assert json_result["x-demo-header"] == "ABC"
103
 
104
 
105
  def run_nested_server(host: str, port: int) -> None:
106
- try:
107
- app = fastmcp_server().sse_app(path="/mcp/sse", message_path="/mcp/messages")
108
- mount = Starlette(routes=[Mount("/nest-inner", app=app)])
109
- mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)])
110
- server = uvicorn.Server(
111
- config=uvicorn.Config(app=mount2, host=host, port=port, log_level="error")
112
- )
113
- server.run()
114
- except Exception as e:
115
- print(f"Server error: {e}")
116
- sys.exit(1)
117
- sys.exit(0)
118
 
119
 
120
  async def test_run_server_on_path():
121
- with run_server_in_process(run_server, "/help") as url:
122
  async with Client(transport=SSETransport(f"{url}/help")) as client:
123
  result = await client.ping()
124
  assert result is True
 
6
  import pytest
7
  import uvicorn
8
  from mcp import McpError
 
9
  from starlette.applications import Starlette
10
  from starlette.routing import Mount
11
 
 
63
  return server
64
 
65
 
66
+ def run_server(host: str, port: int, **kwargs) -> None:
67
+ fastmcp_server().run(host=host, port=port, **kwargs)
 
 
 
 
 
 
 
 
 
68
 
69
 
70
  @pytest.fixture(autouse=True, scope="module")
71
  def sse_server() -> Generator[str, None, None]:
72
+ with run_server_in_process(run_server, transport="sse") as url:
73
  yield f"{url}/sse"
74
 
75
 
 
86
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
87
  ) as client:
88
  raw_result = await client.read_resource("request://headers")
89
+ json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
 
90
  assert "x-demo-header" in json_result
91
  assert json_result["x-demo-header"] == "ABC"
92
 
93
 
94
  def run_nested_server(host: str, port: int) -> None:
95
+ app = fastmcp_server().sse_app(path="/mcp/sse", message_path="/mcp/messages")
96
+ mount = Starlette(routes=[Mount("/nest-inner", app=app)])
97
+ mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)])
98
+ server = uvicorn.Server(
99
+ config=uvicorn.Config(app=mount2, host=host, port=port, log_level="error")
100
+ )
101
+ server.run()
 
 
 
 
 
102
 
103
 
104
  async def test_run_server_on_path():
105
+ with run_server_in_process(run_server, transport="sse", path="/help") as url:
106
  async with Client(transport=SSETransport(f"{url}/help")) as client:
107
  result = await client.ping()
108
  assert result is True
tests/client/test_stdio.py CHANGED
@@ -1,7 +1,6 @@
1
  import inspect
2
 
3
  import pytest
4
- from mcp.types import TextContent
5
 
6
  from fastmcp import Client
7
  from fastmcp.client.transports import PythonStdioTransport, StdioTransport
@@ -49,13 +48,11 @@ class TestKeepAlive:
49
 
50
  async with client:
51
  result1 = await client.call_tool("pid")
52
- assert isinstance(result1[0], TextContent)
53
- pid1 = int(result1[0].text)
54
 
55
  async with client:
56
  result2 = await client.call_tool("pid")
57
- assert isinstance(result2[0], TextContent)
58
- pid2 = int(result2[0].text)
59
 
60
  assert pid1 == pid2
61
 
@@ -69,13 +66,11 @@ class TestKeepAlive:
69
 
70
  async with client:
71
  result1 = await client.call_tool("pid")
72
- assert isinstance(result1[0], TextContent)
73
- pid1 = int(result1[0].text)
74
 
75
  async with client:
76
  result2 = await client.call_tool("pid")
77
- assert isinstance(result2[0], TextContent)
78
- pid2 = int(result2[0].text)
79
 
80
  assert pid1 != pid2
81
 
@@ -85,15 +80,13 @@ class TestKeepAlive:
85
 
86
  async with client:
87
  result1 = await client.call_tool("pid")
88
- assert isinstance(result1[0], TextContent)
89
- pid1 = int(result1[0].text)
90
 
91
  await client.close()
92
 
93
  async with client:
94
  result2 = await client.call_tool("pid")
95
- assert isinstance(result2[0], TextContent)
96
- pid2 = int(result2[0].text)
97
 
98
  assert pid1 != pid2
99
 
@@ -103,17 +96,14 @@ class TestKeepAlive:
103
 
104
  async with client:
105
  result1 = await client.call_tool("pid")
106
- assert isinstance(result1[0], TextContent)
107
- pid1 = int(result1[0].text)
108
 
109
  async with client:
110
  result2 = await client.call_tool("pid")
111
- assert isinstance(result2[0], TextContent)
112
- pid2 = int(result2[0].text)
113
 
114
  result3 = await client.call_tool("pid")
115
- assert isinstance(result3[0], TextContent)
116
- pid3 = int(result3[0].text)
117
 
118
  assert pid1 == pid2 == pid3
119
 
 
1
  import inspect
2
 
3
  import pytest
 
4
 
5
  from fastmcp import Client
6
  from fastmcp.client.transports import PythonStdioTransport, StdioTransport
 
48
 
49
  async with client:
50
  result1 = await client.call_tool("pid")
51
+ pid1 = int(result1[0].text) # type: ignore[attr-defined]
 
52
 
53
  async with client:
54
  result2 = await client.call_tool("pid")
55
+ pid2 = int(result2[0].text) # type: ignore[attr-defined]
 
56
 
57
  assert pid1 == pid2
58
 
 
66
 
67
  async with client:
68
  result1 = await client.call_tool("pid")
69
+ pid1 = int(result1[0].text) # type: ignore[attr-defined]
 
70
 
71
  async with client:
72
  result2 = await client.call_tool("pid")
73
+ pid2 = int(result2[0].text) # type: ignore[attr-defined]
 
74
 
75
  assert pid1 != pid2
76
 
 
80
 
81
  async with client:
82
  result1 = await client.call_tool("pid")
83
+ pid1 = int(result1[0].text) # type: ignore[attr-defined]
 
84
 
85
  await client.close()
86
 
87
  async with client:
88
  result2 = await client.call_tool("pid")
89
+ pid2 = int(result2[0].text) # type: ignore[attr-defined]
 
90
 
91
  assert pid1 != pid2
92
 
 
96
 
97
  async with client:
98
  result1 = await client.call_tool("pid")
99
+ pid1 = int(result1[0].text) # type: ignore[attr-defined]
 
100
 
101
  async with client:
102
  result2 = await client.call_tool("pid")
103
+ pid2 = int(result2[0].text) # type: ignore[attr-defined]
 
104
 
105
  result3 = await client.call_tool("pid")
106
+ pid3 = int(result3[0].text) # type: ignore[attr-defined]
 
107
 
108
  assert pid1 == pid2 == pid3
109
 
tests/client/test_streamable_http.py CHANGED
@@ -6,7 +6,6 @@ from collections.abc import Generator
6
  import pytest
7
  import uvicorn
8
  from mcp import McpError
9
- from mcp.types import TextResourceContents
10
  from starlette.applications import Starlette
11
  from starlette.routing import Mount
12
 
@@ -64,28 +63,33 @@ def fastmcp_server():
64
  return server
65
 
66
 
67
- def run_server(host: str, port: int) -> None:
68
- try:
69
- app = fastmcp_server().http_app()
70
- server = uvicorn.Server(
71
- config=uvicorn.Config(
72
- app=app,
73
- host=host,
74
- port=port,
75
- log_level="error",
76
- lifespan="on",
77
- )
 
 
 
 
 
 
 
 
78
  )
79
- server.run()
80
- except Exception as e:
81
- print(f"Server error: {e}")
82
- sys.exit(1)
83
- sys.exit(0)
84
 
85
 
86
  @pytest.fixture(scope="module")
87
  def streamable_http_server() -> Generator[str, None, None]:
88
- with run_server_in_process(run_server) as url:
89
  yield f"{url}/mcp"
90
 
91
 
@@ -106,37 +110,11 @@ async def test_http_headers(streamable_http_server: str):
106
  )
107
  ) as client:
108
  raw_result = await client.read_resource("request://headers")
109
- assert isinstance(raw_result[0], TextResourceContents)
110
- json_result = json.loads(raw_result[0].text)
111
  assert "x-demo-header" in json_result
112
  assert json_result["x-demo-header"] == "ABC"
113
 
114
 
115
- def run_nested_server(host: str, port: int) -> None:
116
- try:
117
- mcp_app = fastmcp_server().http_app(path="/final/mcp")
118
-
119
- mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
120
- mount2 = Starlette(
121
- routes=[Mount("/nest-outer", app=mount)],
122
- lifespan=mcp_app.lifespan,
123
- )
124
- server = uvicorn.Server(
125
- config=uvicorn.Config(
126
- app=mount2,
127
- host=host,
128
- port=port,
129
- log_level="error",
130
- lifespan="on",
131
- )
132
- )
133
- server.run()
134
- except Exception as e:
135
- print(f"Server error: {e}")
136
- sys.exit(1)
137
- sys.exit(0)
138
-
139
-
140
  async def test_nested_streamable_http_server_resolves_correctly():
141
  # tests patch for
142
  # https://github.com/modelcontextprotocol/python-sdk/pull/659
 
6
  import pytest
7
  import uvicorn
8
  from mcp import McpError
 
9
  from starlette.applications import Starlette
10
  from starlette.routing import Mount
11
 
 
63
  return server
64
 
65
 
66
+ def run_server(host: str, port: int, **kwargs) -> None:
67
+ fastmcp_server().run(host=host, port=port, **kwargs)
68
+
69
+
70
+ def run_nested_server(host: str, port: int) -> None:
71
+ mcp_app = fastmcp_server().http_app(path="/final/mcp")
72
+
73
+ mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
74
+ mount2 = Starlette(
75
+ routes=[Mount("/nest-outer", app=mount)],
76
+ lifespan=mcp_app.lifespan,
77
+ )
78
+ server = uvicorn.Server(
79
+ config=uvicorn.Config(
80
+ app=mount2,
81
+ host=host,
82
+ port=port,
83
+ log_level="error",
84
+ lifespan="on",
85
  )
86
+ )
87
+ server.run()
 
 
 
88
 
89
 
90
  @pytest.fixture(scope="module")
91
  def streamable_http_server() -> Generator[str, None, None]:
92
+ with run_server_in_process(run_server, transport="streamable-http") as url:
93
  yield f"{url}/mcp"
94
 
95
 
 
110
  )
111
  ) as client:
112
  raw_result = await client.read_resource("request://headers")
113
+ json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
 
114
  assert "x-demo-header" in json_result
115
  assert json_result["x-demo-header"] == "ABC"
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  async def test_nested_streamable_http_server_resolves_correctly():
119
  # tests patch for
120
  # https://github.com/modelcontextprotocol/python-sdk/pull/659
tests/prompts/test_prompt_manager.py CHANGED
@@ -393,8 +393,7 @@ class TestContextHandling:
393
  messages = await prompt.render(arguments={"x": 42})
394
 
395
  assert len(messages) == 1
396
- assert isinstance(messages[0].content, TextContent)
397
- assert messages[0].content.text == "42"
398
 
399
  async def test_context_optional(self):
400
  """Test that context is optional when rendering prompts."""
@@ -416,8 +415,7 @@ class TestContextHandling:
416
  )
417
 
418
  assert len(messages) == 1
419
- assert isinstance(messages[0].content, TextContent)
420
- assert messages[0].content.text == "42"
421
 
422
  async def test_annotated_context_parameter_detection(self):
423
  """Test that annotated context parameters are properly detected in
 
393
  messages = await prompt.render(arguments={"x": 42})
394
 
395
  assert len(messages) == 1
396
+ assert messages[0].content.text == "42" # type: ignore[attr-defined]
 
397
 
398
  async def test_context_optional(self):
399
  """Test that context is optional when rendering prompts."""
 
415
  )
416
 
417
  assert len(messages) == 1
418
+ assert messages[0].content.text == "42" # type: ignore[attr-defined]
 
419
 
420
  async def test_annotated_context_parameter_detection(self):
421
  """Test that annotated context parameters are properly detected in
tests/resources/test_file_resources.py CHANGED
@@ -74,7 +74,6 @@ class TestFileResource:
74
  is_binary=True,
75
  )
76
  content = await resource.read()
77
- assert isinstance(content, bytes)
78
  assert content == b"test content"
79
 
80
  def test_relative_path_error(self):
 
74
  is_binary=True,
75
  )
76
  content = await resource.read()
 
77
  assert content == b"test content"
78
 
79
  def test_relative_path_error(self):
tests/server/http/__init__.py ADDED
File without changes
tests/server/http/test_http_dependencies.py CHANGED
@@ -1,10 +1,7 @@
1
  import json
2
- import sys
3
  from collections.abc import Generator
4
 
5
  import pytest
6
- import uvicorn
7
- from mcp.types import TextContent, TextResourceContents
8
 
9
  from fastmcp.client import Client
10
  from fastmcp.client.transports import SSETransport, StreamableHttpTransport
@@ -41,53 +38,19 @@ def fastmcp_server():
41
  return server
42
 
43
 
44
- def run_shttp_server(host: str, port: int) -> None:
45
- try:
46
- app = fastmcp_server().http_app(transport="streamable-http")
47
- server = uvicorn.Server(
48
- config=uvicorn.Config(
49
- app=app,
50
- host=host,
51
- port=port,
52
- log_level="error",
53
- lifespan="on",
54
- )
55
- )
56
- server.run()
57
- except Exception as e:
58
- print(f"Server error: {e}")
59
- sys.exit(1)
60
- sys.exit(0)
61
-
62
-
63
- def run_sse_server(host: str, port: int) -> None:
64
- try:
65
- app = fastmcp_server().http_app(transport="sse")
66
- server = uvicorn.Server(
67
- config=uvicorn.Config(
68
- app=app,
69
- host=host,
70
- port=port,
71
- log_level="error",
72
- lifespan="on",
73
- )
74
- )
75
- server.run()
76
- except Exception as e:
77
- print(f"Server error: {e}")
78
- sys.exit(1)
79
- sys.exit(0)
80
 
81
 
82
  @pytest.fixture(autouse=True, scope="module")
83
  def shttp_server() -> Generator[str, None, None]:
84
- with run_server_in_process(run_shttp_server) as url:
85
  yield f"{url}/mcp"
86
 
87
 
88
  @pytest.fixture(autouse=True, scope="module")
89
  def sse_server() -> Generator[str, None, None]:
90
- with run_server_in_process(run_sse_server) as url:
91
  yield f"{url}/sse"
92
 
93
 
@@ -99,8 +62,7 @@ async def test_http_headers_resource_shttp(shttp_server: str):
99
  )
100
  ) as client:
101
  raw_result = await client.read_resource("request://headers")
102
- assert isinstance(raw_result[0], TextResourceContents)
103
- json_result = json.loads(raw_result[0].text)
104
  assert "x-demo-header" in json_result
105
  assert json_result["x-demo-header"] == "ABC"
106
 
@@ -111,8 +73,7 @@ async def test_http_headers_resource_sse(sse_server: str):
111
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
112
  ) as client:
113
  raw_result = await client.read_resource("request://headers")
114
- assert isinstance(raw_result[0], TextResourceContents)
115
- json_result = json.loads(raw_result[0].text)
116
  assert "x-demo-header" in json_result
117
  assert json_result["x-demo-header"] == "ABC"
118
 
@@ -125,8 +86,7 @@ async def test_http_headers_tool_shttp(shttp_server: str):
125
  )
126
  ) as client:
127
  result = await client.call_tool("get_headers_tool")
128
- assert isinstance(result[0], TextContent)
129
- json_result = json.loads(result[0].text)
130
  assert "x-demo-header" in json_result
131
  assert json_result["x-demo-header"] == "ABC"
132
 
@@ -136,8 +96,7 @@ async def test_http_headers_tool_sse(sse_server: str):
136
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
137
  ) as client:
138
  result = await client.call_tool("get_headers_tool")
139
- assert isinstance(result[0], TextContent)
140
- json_result = json.loads(result[0].text)
141
  assert "x-demo-header" in json_result
142
  assert json_result["x-demo-header"] == "ABC"
143
 
@@ -150,8 +109,7 @@ async def test_http_headers_prompt_shttp(shttp_server: str):
150
  )
151
  ) as client:
152
  result = await client.get_prompt("get_headers_prompt")
153
- assert isinstance(result.messages[0].content, TextContent)
154
- json_result = json.loads(result.messages[0].content.text)
155
  assert "x-demo-header" in json_result
156
  assert json_result["x-demo-header"] == "ABC"
157
 
@@ -162,7 +120,6 @@ async def test_http_headers_prompt_sse(sse_server: str):
162
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
163
  ) as client:
164
  result = await client.get_prompt("get_headers_prompt")
165
- assert isinstance(result.messages[0].content, TextContent)
166
- json_result = json.loads(result.messages[0].content.text)
167
  assert "x-demo-header" in json_result
168
  assert json_result["x-demo-header"] == "ABC"
 
1
  import json
 
2
  from collections.abc import Generator
3
 
4
  import pytest
 
 
5
 
6
  from fastmcp.client import Client
7
  from fastmcp.client.transports import SSETransport, StreamableHttpTransport
 
38
  return server
39
 
40
 
41
+ def run_server(host: str, port: int, **kwargs) -> None:
42
+ fastmcp_server().run(host=host, port=port, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
  @pytest.fixture(autouse=True, scope="module")
46
  def shttp_server() -> Generator[str, None, None]:
47
+ with run_server_in_process(run_server, transport="streamable-http") as url:
48
  yield f"{url}/mcp"
49
 
50
 
51
  @pytest.fixture(autouse=True, scope="module")
52
  def sse_server() -> Generator[str, None, None]:
53
+ with run_server_in_process(run_server, transport="sse") as url:
54
  yield f"{url}/sse"
55
 
56
 
 
62
  )
63
  ) as client:
64
  raw_result = await client.read_resource("request://headers")
65
+ json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
 
66
  assert "x-demo-header" in json_result
67
  assert json_result["x-demo-header"] == "ABC"
68
 
 
73
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
74
  ) as client:
75
  raw_result = await client.read_resource("request://headers")
76
+ json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
 
77
  assert "x-demo-header" in json_result
78
  assert json_result["x-demo-header"] == "ABC"
79
 
 
86
  )
87
  ) as client:
88
  result = await client.call_tool("get_headers_tool")
89
+ json_result = json.loads(result[0].text) # type: ignore[attr-defined]
 
90
  assert "x-demo-header" in json_result
91
  assert json_result["x-demo-header"] == "ABC"
92
 
 
96
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
97
  ) as client:
98
  result = await client.call_tool("get_headers_tool")
99
+ json_result = json.loads(result[0].text) # type: ignore[attr-defined]
 
100
  assert "x-demo-header" in json_result
101
  assert json_result["x-demo-header"] == "ABC"
102
 
 
109
  )
110
  ) as client:
111
  result = await client.get_prompt("get_headers_prompt")
112
+ json_result = json.loads(result.messages[0].content.text) # type: ignore[attr-defined]
 
113
  assert "x-demo-header" in json_result
114
  assert json_result["x-demo-header"] == "ABC"
115
 
 
120
  transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
121
  ) as client:
122
  result = await client.get_prompt("get_headers_prompt")
123
+ json_result = json.loads(result.messages[0].content.text) # type: ignore[attr-defined]
 
124
  assert "x-demo-header" in json_result
125
  assert json_result["x-demo-header"] == "ABC"
tests/server/openapi/__init__.py ADDED
File without changes
tests/server/openapi/test_openapi.py CHANGED
@@ -9,7 +9,7 @@ from dirty_equals import IsStr
9
  from fastapi import FastAPI, HTTPException, Response
10
  from fastapi.responses import PlainTextResponse
11
  from httpx import ASGITransport, AsyncClient
12
- from mcp.types import BlobResourceContents, TextContent, TextResourceContents
13
  from pydantic import BaseModel, TypeAdapter
14
  from pydantic.networks import AnyUrl
15
 
@@ -234,11 +234,7 @@ class TestTools:
234
  "create_user_users_post", {"name": "David", "active": False}
235
  )
236
 
237
- # Convert TextContent to dict for comparison
238
- assert isinstance(tool_response, list) and len(tool_response) == 1
239
- assert isinstance(tool_response[0], TextContent)
240
-
241
- response_data = json.loads(tool_response[0].text)
242
  expected_user = User(id=4, name="David", active=False).model_dump()
243
  assert response_data == expected_user
244
 
@@ -249,8 +245,7 @@ class TestTools:
249
  # Check that the user was created via MCP
250
  async with Client(fastmcp_openapi_server) as client:
251
  user_response = await client.read_resource("resource://get_user_users/4")
252
- assert isinstance(user_response[0], TextResourceContents)
253
- response_text = user_response[0].text
254
  user = json.loads(response_text)
255
  assert user == expected_user
256
 
@@ -266,11 +261,7 @@ class TestTools:
266
  {"user_id": 1, "name": "XYZ"},
267
  )
268
 
269
- # Convert TextContent to dict for comparison
270
- assert isinstance(tool_response, list) and len(tool_response) == 1
271
- assert isinstance(tool_response[0], TextContent)
272
-
273
- response_data = json.loads(tool_response[0].text)
274
  expected_data = dict(id=1, name="XYZ", active=True)
275
  assert response_data == expected_data
276
 
@@ -281,8 +272,7 @@ class TestTools:
281
  # Check that the user was updated via MCP
282
  async with Client(fastmcp_openapi_server) as client:
283
  user_response = await client.read_resource("resource://get_user_users/1")
284
- assert isinstance(user_response[0], TextResourceContents)
285
- response_text = user_response[0].text
286
  user = json.loads(response_text)
287
  assert user == expected_data
288
 
@@ -305,9 +295,7 @@ class TestTools:
305
  )
306
  async with Client(mcp_server) as client:
307
  tool_response = await client.call_tool("get_users_users_get", {})
308
- assert isinstance(tool_response, list)
309
- assert isinstance(tool_response[0], TextContent)
310
- assert json.loads(tool_response[0].text) == [
311
  user.model_dump()
312
  for user in sorted(users_db.values(), key=lambda x: x.id)
313
  ]
@@ -341,8 +329,7 @@ class TestResources:
341
  resource_response = await client.read_resource(
342
  "resource://get_users_users_get"
343
  )
344
- assert isinstance(resource_response[0], TextResourceContents)
345
- response_text = resource_response[0].text
346
  resource = json.loads(response_text)
347
  assert resource == json_users
348
  response = await api_client.get("/users")
@@ -369,8 +356,7 @@ class TestResources:
369
  """Test reading a resource that returns a string."""
370
  async with Client(fastmcp_openapi_server) as client:
371
  resource_response = await client.read_resource("resource://ping_ping_get")
372
- assert isinstance(resource_response[0], TextResourceContents)
373
- assert resource_response[0].text == "pong"
374
 
375
 
376
  class TestResourceTemplates:
@@ -407,8 +393,7 @@ class TestResourceTemplates:
407
  resource_response = await client.read_resource(
408
  f"resource://get_user_users/{user_id}"
409
  )
410
- assert isinstance(resource_response[0], TextResourceContents)
411
- response_text = resource_response[0].text
412
  resource = json.loads(response_text)
413
 
414
  assert resource == users_db[user_id].model_dump()
@@ -430,8 +415,7 @@ class TestResourceTemplates:
430
  resource_response = await client.read_resource(
431
  f"resource://get_user_active_state_users/{is_active}/{user_id}"
432
  )
433
- assert isinstance(resource_response[0], TextResourceContents)
434
- response_text = resource_response[0].text
435
  resource = json.loads(response_text)
436
 
437
  assert resource == users_db[user_id].model_dump()
@@ -681,8 +665,7 @@ class TestOpenAPI30Compatibility:
681
  """Test reading a resource from an OpenAPI 3.0 server."""
682
  async with Client(openapi_30_server) as client:
683
  resource_response = await client.read_resource("resource://listProducts")
684
- assert isinstance(resource_response[0], TextResourceContents)
685
- response_text = resource_response[0].text
686
  content = json.loads(response_text)
687
  assert len(content) == 2
688
  assert content[0]["name"] == "Product 1"
@@ -692,8 +675,7 @@ class TestOpenAPI30Compatibility:
692
  """Test reading a resource from template from an OpenAPI 3.0 server."""
693
  async with Client(openapi_30_server) as client:
694
  resource_response = await client.read_resource("resource://getProduct/p1")
695
- assert isinstance(resource_response[0], TextResourceContents)
696
- response_text = resource_response[0].text
697
  content = json.loads(response_text)
698
  assert content["id"] == "p1"
699
  assert content["name"] == "Product 1"
@@ -707,8 +689,7 @@ class TestOpenAPI30Compatibility:
707
  )
708
  # Result should be a text content
709
  assert len(result) == 1
710
- assert isinstance(result[0], TextContent)
711
- product = json.loads(result[0].text)
712
  assert product["id"] == "p3"
713
  assert product["name"] == "New Product"
714
  assert product["price"] == 39.99
@@ -857,8 +838,7 @@ class TestOpenAPI31Compatibility:
857
  """Test reading a resource from an OpenAPI 3.1 server."""
858
  async with Client(openapi_31_server) as client:
859
  resource_response = await client.read_resource("resource://listOrders")
860
- assert isinstance(resource_response[0], TextResourceContents)
861
- response_text = resource_response[0].text
862
  content = json.loads(response_text)
863
  assert len(content) == 2
864
  assert content[0]["customer"] == "Alice"
@@ -868,8 +848,7 @@ class TestOpenAPI31Compatibility:
868
  """Test reading a resource from template from an OpenAPI 3.1 server."""
869
  async with Client(openapi_31_server) as client:
870
  resource_response = await client.read_resource("resource://getOrder/o1")
871
- assert isinstance(resource_response[0], TextResourceContents)
872
- response_text = resource_response[0].text
873
  content = json.loads(response_text)
874
  assert content["id"] == "o1"
875
  assert content["customer"] == "Alice"
@@ -883,8 +862,7 @@ class TestOpenAPI31Compatibility:
883
  )
884
  # Result should be a text content
885
  assert len(result) == 1
886
- assert isinstance(result[0], TextContent)
887
- order = json.loads(result[0].text)
888
  assert order["id"] == "o3"
889
  assert order["customer"] == "Charlie"
890
  assert order["items"] == ["item4", "item5"]
 
9
  from fastapi import FastAPI, HTTPException, Response
10
  from fastapi.responses import PlainTextResponse
11
  from httpx import ASGITransport, AsyncClient
12
+ from mcp.types import BlobResourceContents
13
  from pydantic import BaseModel, TypeAdapter
14
  from pydantic.networks import AnyUrl
15
 
 
234
  "create_user_users_post", {"name": "David", "active": False}
235
  )
236
 
237
+ response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
 
 
 
 
238
  expected_user = User(id=4, name="David", active=False).model_dump()
239
  assert response_data == expected_user
240
 
 
245
  # Check that the user was created via MCP
246
  async with Client(fastmcp_openapi_server) as client:
247
  user_response = await client.read_resource("resource://get_user_users/4")
248
+ response_text = user_response[0].text # type: ignore[attr-defined]
 
249
  user = json.loads(response_text)
250
  assert user == expected_user
251
 
 
261
  {"user_id": 1, "name": "XYZ"},
262
  )
263
 
264
+ response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
 
 
 
 
265
  expected_data = dict(id=1, name="XYZ", active=True)
266
  assert response_data == expected_data
267
 
 
272
  # Check that the user was updated via MCP
273
  async with Client(fastmcp_openapi_server) as client:
274
  user_response = await client.read_resource("resource://get_user_users/1")
275
+ response_text = user_response[0].text # type: ignore[attr-defined]
 
276
  user = json.loads(response_text)
277
  assert user == expected_data
278
 
 
295
  )
296
  async with Client(mcp_server) as client:
297
  tool_response = await client.call_tool("get_users_users_get", {})
298
+ assert json.loads(tool_response[0].text) == [ # type: ignore[attr-defined]
 
 
299
  user.model_dump()
300
  for user in sorted(users_db.values(), key=lambda x: x.id)
301
  ]
 
329
  resource_response = await client.read_resource(
330
  "resource://get_users_users_get"
331
  )
332
+ response_text = resource_response[0].text # type: ignore[attr-defined]
 
333
  resource = json.loads(response_text)
334
  assert resource == json_users
335
  response = await api_client.get("/users")
 
356
  """Test reading a resource that returns a string."""
357
  async with Client(fastmcp_openapi_server) as client:
358
  resource_response = await client.read_resource("resource://ping_ping_get")
359
+ assert resource_response[0].text == "pong" # type: ignore[attr-defined]
 
360
 
361
 
362
  class TestResourceTemplates:
 
393
  resource_response = await client.read_resource(
394
  f"resource://get_user_users/{user_id}"
395
  )
396
+ response_text = resource_response[0].text # type: ignore[attr-defined]
 
397
  resource = json.loads(response_text)
398
 
399
  assert resource == users_db[user_id].model_dump()
 
415
  resource_response = await client.read_resource(
416
  f"resource://get_user_active_state_users/{is_active}/{user_id}"
417
  )
418
+ response_text = resource_response[0].text # type: ignore[attr-defined]
 
419
  resource = json.loads(response_text)
420
 
421
  assert resource == users_db[user_id].model_dump()
 
665
  """Test reading a resource from an OpenAPI 3.0 server."""
666
  async with Client(openapi_30_server) as client:
667
  resource_response = await client.read_resource("resource://listProducts")
668
+ response_text = resource_response[0].text # type: ignore[attr-defined]
 
669
  content = json.loads(response_text)
670
  assert len(content) == 2
671
  assert content[0]["name"] == "Product 1"
 
675
  """Test reading a resource from template from an OpenAPI 3.0 server."""
676
  async with Client(openapi_30_server) as client:
677
  resource_response = await client.read_resource("resource://getProduct/p1")
678
+ response_text = resource_response[0].text # type: ignore[attr-defined]
 
679
  content = json.loads(response_text)
680
  assert content["id"] == "p1"
681
  assert content["name"] == "Product 1"
 
689
  )
690
  # Result should be a text content
691
  assert len(result) == 1
692
+ product = json.loads(result[0].text) # type: ignore[attr-defined]
 
693
  assert product["id"] == "p3"
694
  assert product["name"] == "New Product"
695
  assert product["price"] == 39.99
 
838
  """Test reading a resource from an OpenAPI 3.1 server."""
839
  async with Client(openapi_31_server) as client:
840
  resource_response = await client.read_resource("resource://listOrders")
841
+ response_text = resource_response[0].text # type: ignore[attr-defined]
 
842
  content = json.loads(response_text)
843
  assert len(content) == 2
844
  assert content[0]["customer"] == "Alice"
 
848
  """Test reading a resource from template from an OpenAPI 3.1 server."""
849
  async with Client(openapi_31_server) as client:
850
  resource_response = await client.read_resource("resource://getOrder/o1")
851
+ response_text = resource_response[0].text # type: ignore[attr-defined]
 
852
  content = json.loads(response_text)
853
  assert content["id"] == "o1"
854
  assert content["customer"] == "Alice"
 
862
  )
863
  # Result should be a text content
864
  assert len(result) == 1
865
+ order = json.loads(result[0].text) # type: ignore[attr-dict]
 
866
  assert order["id"] == "o3"
867
  assert order["customer"] == "Charlie"
868
  assert order["items"] == ["item4", "item5"]
tests/server/test_import_server.py CHANGED
@@ -1,8 +1,6 @@
1
  import json
2
  from urllib.parse import quote
3
 
4
- from mcp.types import TextContent, TextResourceContents
5
-
6
  from fastmcp.client.client import Client
7
  from fastmcp.server.server import FastMCP
8
 
@@ -223,8 +221,7 @@ async def test_call_imported_custom_named_tool():
223
 
224
  async with Client(main_app) as client:
225
  result = await client.call_tool("api_get_data", {"query": "test"})
226
- assert isinstance(result[0], TextContent)
227
- assert result[0].text == "Data for query: test"
228
 
229
 
230
  async def test_first_level_importing_with_custom_name():
@@ -278,8 +275,7 @@ async def test_call_nested_imported_tool():
278
  result = await main_app._tool_manager.call_tool(
279
  "service_provider_compute", {"input": 21}
280
  )
281
- assert isinstance(result[0], TextContent)
282
- assert result[0].text == "42"
283
 
284
 
285
  async def test_import_with_proxy_tools():
@@ -302,8 +298,7 @@ async def test_import_with_proxy_tools():
302
  await main_app.import_server("api", proxy_app)
303
 
304
  result = await main_app._mcp_call_tool("api_get_data", {"query": "test"})
305
- assert isinstance(result[0], TextContent)
306
- assert result[0].text == "Data for query: test"
307
 
308
 
309
  async def test_import_with_proxy_prompts():
@@ -326,8 +321,7 @@ async def test_import_with_proxy_prompts():
326
  await main_app.import_server("api", proxy_app)
327
 
328
  result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"})
329
- assert isinstance(result.messages[0].content, TextContent)
330
- assert result.messages[0].content.text == "Hello, World from API!"
331
  assert result.description == "Example greeting prompt."
332
 
333
 
@@ -356,8 +350,7 @@ async def test_import_with_proxy_resources():
356
  # Access the resource through the main app with the prefixed key
357
  async with Client(main_app) as client:
358
  result = await client.read_resource("config://api/settings")
359
- assert isinstance(result[0], TextResourceContents)
360
- content = json.loads(result[0].text)
361
  assert content["api_key"] == "12345"
362
  assert content["base_url"] == "https://api.example.com"
363
 
@@ -387,8 +380,7 @@ async def test_import_with_proxy_resource_templates():
387
  quoted_email = quote("john@example.com", safe="")
388
  async with Client(main_app) as client:
389
  result = await client.read_resource(f"user://api/{quoted_name}/{quoted_email}")
390
- assert isinstance(result[0], TextResourceContents)
391
- content = json.loads(result[0].text)
392
  assert content["name"] == "John Doe"
393
  assert content["email"] == "john@example.com"
394
 
 
1
  import json
2
  from urllib.parse import quote
3
 
 
 
4
  from fastmcp.client.client import Client
5
  from fastmcp.server.server import FastMCP
6
 
 
221
 
222
  async with Client(main_app) as client:
223
  result = await client.call_tool("api_get_data", {"query": "test"})
224
+ assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
 
225
 
226
 
227
  async def test_first_level_importing_with_custom_name():
 
275
  result = await main_app._tool_manager.call_tool(
276
  "service_provider_compute", {"input": 21}
277
  )
278
+ assert result[0].text == "42" # type: ignore[attr-defined]
 
279
 
280
 
281
  async def test_import_with_proxy_tools():
 
298
  await main_app.import_server("api", proxy_app)
299
 
300
  result = await main_app._mcp_call_tool("api_get_data", {"query": "test"})
301
+ assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
 
302
 
303
 
304
  async def test_import_with_proxy_prompts():
 
321
  await main_app.import_server("api", proxy_app)
322
 
323
  result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"})
324
+ assert result.messages[0].content.text == "Hello, World from API!" # type: ignore[attr-defined]
 
325
  assert result.description == "Example greeting prompt."
326
 
327
 
 
350
  # Access the resource through the main app with the prefixed key
351
  async with Client(main_app) as client:
352
  result = await client.read_resource("config://api/settings")
353
+ content = json.loads(result[0].text) # type: ignore[attr-defined]
 
354
  assert content["api_key"] == "12345"
355
  assert content["base_url"] == "https://api.example.com"
356
 
 
380
  quoted_email = quote("john@example.com", safe="")
381
  async with Client(main_app) as client:
382
  result = await client.read_resource(f"user://api/{quoted_name}/{quoted_email}")
383
+ content = json.loads(result[0].text) # type: ignore[attr-defined]
 
384
  assert content["name"] == "John Doe"
385
  assert content["email"] == "john@example.com"
386
 
tests/server/test_lifespan.py DELETED
@@ -1,396 +0,0 @@
1
- """Tests for lifespan functionality in both low-level and FastMCP servers."""
2
-
3
- import os
4
- import sys
5
- import traceback
6
- from collections.abc import AsyncIterator
7
- from contextlib import asynccontextmanager
8
- from pathlib import Path
9
-
10
- import anyio
11
- import httpx
12
- import uvicorn
13
- from mcp.server.lowlevel.server import NotificationOptions, Server
14
- from mcp.server.models import InitializationOptions
15
- from mcp.shared.message import SessionMessage
16
- from mcp.types import (
17
- ClientCapabilities,
18
- Implementation,
19
- InitializeRequestParams,
20
- JSONRPCMessage,
21
- JSONRPCNotification,
22
- JSONRPCRequest,
23
- )
24
- from pydantic import TypeAdapter
25
- from starlette.applications import Starlette
26
- from starlette.routing import Mount
27
-
28
- from fastmcp import Context, FastMCP
29
- from fastmcp.utilities.tests import run_server_in_process
30
-
31
-
32
- async def test_lowlevel_server_lifespan():
33
- """Test that lifespan works in low-level server."""
34
-
35
- @asynccontextmanager
36
- async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]:
37
- """Test lifespan context that tracks startup/shutdown."""
38
- context = {"started": False, "shutdown": False}
39
- try:
40
- context["started"] = True
41
- yield context
42
- finally:
43
- context["shutdown"] = True
44
-
45
- server = Server("test", lifespan=test_lifespan)
46
-
47
- # Create memory streams for testing
48
- send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
49
- send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
50
-
51
- # Create a tool that accesses lifespan context
52
- @server.call_tool()
53
- async def check_lifespan(name: str, arguments: dict) -> list:
54
- ctx = server.request_context
55
- assert isinstance(ctx.lifespan_context, dict)
56
- assert ctx.lifespan_context["started"]
57
- assert not ctx.lifespan_context["shutdown"]
58
- return [{"type": "text", "text": "true"}]
59
-
60
- # Run server in background task
61
- async with (
62
- anyio.create_task_group() as tg,
63
- send_stream1,
64
- receive_stream1,
65
- send_stream2,
66
- receive_stream2,
67
- ):
68
-
69
- async def run_server():
70
- await server.run(
71
- receive_stream1,
72
- send_stream2,
73
- InitializationOptions(
74
- server_name="test",
75
- server_version="0.1.0",
76
- capabilities=server.get_capabilities(
77
- notification_options=NotificationOptions(),
78
- experimental_capabilities={},
79
- ),
80
- ),
81
- raise_exceptions=True,
82
- )
83
-
84
- tg.start_soon(run_server)
85
-
86
- # Initialize the server
87
- params = InitializeRequestParams(
88
- protocolVersion="2024-11-05",
89
- capabilities=ClientCapabilities(),
90
- clientInfo=Implementation(name="test-client", version="0.1.0"),
91
- )
92
- await send_stream1.send(
93
- SessionMessage(
94
- JSONRPCMessage(
95
- root=JSONRPCRequest(
96
- jsonrpc="2.0",
97
- id=1,
98
- method="initialize",
99
- params=TypeAdapter(InitializeRequestParams).dump_python(params),
100
- )
101
- )
102
- )
103
- )
104
- response = await receive_stream2.receive()
105
- response = response.message
106
-
107
- # Send initialized notification
108
- await send_stream1.send(
109
- SessionMessage(
110
- JSONRPCMessage(
111
- root=JSONRPCNotification(
112
- jsonrpc="2.0",
113
- method="notifications/initialized",
114
- )
115
- )
116
- )
117
- )
118
-
119
- # Call the tool to verify lifespan context
120
- await send_stream1.send(
121
- SessionMessage(
122
- JSONRPCMessage(
123
- root=JSONRPCRequest(
124
- jsonrpc="2.0",
125
- id=2,
126
- method="tools/call",
127
- params={"name": "check_lifespan", "arguments": {}},
128
- )
129
- )
130
- )
131
- )
132
-
133
- # Get response and verify
134
- response = await receive_stream2.receive()
135
- response = response.message
136
- assert response.root.result["content"][0]["text"] == "true"
137
-
138
- # Cancel server task
139
- tg.cancel_scope.cancel()
140
-
141
-
142
- async def test_fastmcp_server_lifespan():
143
- """Test that lifespan works in FastMCP server."""
144
-
145
- @asynccontextmanager
146
- async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]:
147
- """Test lifespan context that tracks startup/shutdown."""
148
- context = {"started": False, "shutdown": False}
149
- try:
150
- context["started"] = True
151
- yield context
152
- finally:
153
- context["shutdown"] = True
154
-
155
- server = FastMCP("test", lifespan=test_lifespan)
156
-
157
- # Create memory streams for testing
158
- send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
159
- send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
160
-
161
- # Add a tool that checks lifespan context
162
- @server.tool()
163
- def check_lifespan(ctx: Context) -> bool:
164
- """Tool that checks lifespan context."""
165
- assert isinstance(ctx.request_context.lifespan_context, dict)
166
- assert ctx.request_context.lifespan_context["started"]
167
- assert not ctx.request_context.lifespan_context["shutdown"]
168
- return True
169
-
170
- # Run server in background task
171
- async with (
172
- anyio.create_task_group() as tg,
173
- send_stream1,
174
- receive_stream1,
175
- send_stream2,
176
- receive_stream2,
177
- ):
178
-
179
- async def run_server():
180
- await server._mcp_server.run(
181
- receive_stream1,
182
- send_stream2,
183
- server._mcp_server.create_initialization_options(),
184
- raise_exceptions=True,
185
- )
186
-
187
- tg.start_soon(run_server)
188
-
189
- # Initialize the server
190
- params = InitializeRequestParams(
191
- protocolVersion="2024-11-05",
192
- capabilities=ClientCapabilities(),
193
- clientInfo=Implementation(name="test-client", version="0.1.0"),
194
- )
195
- await send_stream1.send(
196
- SessionMessage(
197
- JSONRPCMessage(
198
- root=JSONRPCRequest(
199
- jsonrpc="2.0",
200
- id=1,
201
- method="initialize",
202
- params=TypeAdapter(InitializeRequestParams).dump_python(params),
203
- )
204
- )
205
- )
206
- )
207
- response = await receive_stream2.receive()
208
- response = response.message
209
-
210
- # Send initialized notification
211
- await send_stream1.send(
212
- SessionMessage(
213
- JSONRPCMessage(
214
- root=JSONRPCNotification(
215
- jsonrpc="2.0",
216
- method="notifications/initialized",
217
- )
218
- )
219
- )
220
- )
221
-
222
- # Call the tool to verify lifespan context
223
- await send_stream1.send(
224
- SessionMessage(
225
- JSONRPCMessage(
226
- root=JSONRPCRequest(
227
- jsonrpc="2.0",
228
- id=2,
229
- method="tools/call",
230
- params={"name": "check_lifespan", "arguments": {}},
231
- )
232
- )
233
- )
234
- )
235
-
236
- # Get response and verify
237
- response = await receive_stream2.receive()
238
- response = response.message
239
- assert response.root.result["content"][0]["text"] == "true"
240
-
241
- # Cancel server task
242
- tg.cancel_scope.cancel()
243
-
244
-
245
- def run_server_with_incorrect_lifespan_setup(
246
- host: str, port: int, server_log_file_path: str
247
- ) -> None:
248
- os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True)
249
-
250
- CUSTOM_LOGGING_CONFIG = {
251
- "version": 1,
252
- "disable_existing_loggers": False,
253
- "formatters": {
254
- "default": {
255
- "()": "uvicorn.logging.DefaultFormatter",
256
- "fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s",
257
- "datefmt": "%Y-%m-%d %H:%M:%S",
258
- "use_colors": False,
259
- },
260
- "access": {
261
- "()": "uvicorn.logging.AccessFormatter",
262
- "fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s',
263
- "datefmt": "%Y-%m-%d %H:%M:%S",
264
- "use_colors": False,
265
- },
266
- },
267
- "handlers": {
268
- "file_default": {
269
- "formatter": "default",
270
- "class": "logging.FileHandler",
271
- "filename": server_log_file_path,
272
- "mode": "w",
273
- },
274
- "file_access": {
275
- "formatter": "access",
276
- "class": "logging.FileHandler",
277
- "filename": server_log_file_path,
278
- "mode": "a",
279
- },
280
- },
281
- "loggers": {
282
- "uvicorn": { # Catches uvicorn root logs
283
- "handlers": ["file_default"],
284
- "level": "DEBUG",
285
- "propagate": False,
286
- },
287
- "uvicorn.error": {
288
- "handlers": ["file_default"],
289
- "level": "DEBUG",
290
- "propagate": False,
291
- },
292
- "uvicorn.access": {
293
- "handlers": ["file_access"],
294
- "level": "INFO",
295
- "propagate": False,
296
- },
297
- },
298
- "root": {
299
- "handlers": ["file_default"],
300
- "level": "DEBUG",
301
- },
302
- }
303
-
304
- try:
305
- mcp = FastMCP()
306
-
307
- @mcp.tool("ping_tool", "A simple ping tool for the test server")
308
- def ping_tool() -> str:
309
- return "pong"
310
-
311
- mcp_asgi_app = mcp.http_app(transport="streamable-http")
312
-
313
- parent_app = Starlette(
314
- routes=[Mount("/mounted_mcp", app=mcp_asgi_app)],
315
- )
316
-
317
- uvicorn.run(
318
- parent_app,
319
- host=host,
320
- port=port,
321
- log_config=CUSTOM_LOGGING_CONFIG,
322
- log_level=None,
323
- )
324
- sys.exit(0)
325
- except Exception as e_outer:
326
- with open(server_log_file_path, "a") as f_fallback:
327
- f_fallback.write(
328
- "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n"
329
- )
330
- f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n")
331
- f_fallback.write(traceback.format_exc())
332
- sys.exit(1)
333
-
334
-
335
- async def test_missing_lifespan_logs_informative_error(tmp_path: Path):
336
- server_log_file = tmp_path / "server.log"
337
-
338
- with run_server_in_process(
339
- run_server_with_incorrect_lifespan_setup, str(server_log_file)
340
- ) as server_url:
341
- full_mcp_path = server_url + "/mounted_mcp/mcp/"
342
-
343
- client_triggered_error = False
344
- response_status = -1
345
- response_body = ""
346
- try:
347
- async with httpx.AsyncClient(timeout=10) as client:
348
- response = await client.post(
349
- full_mcp_path,
350
- json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"},
351
- )
352
- response_status = response.status_code
353
- response_body = response.text
354
- if response.status_code == 500:
355
- client_triggered_error = True
356
- else:
357
- print(
358
- f"Client received unexpected status code: {response.status_code} "
359
- f"Response: {response_body[:500]}"
360
- )
361
- except httpx.RequestError as e:
362
- print(f"Client request failed with RequestError: {e}")
363
- client_triggered_error = True
364
-
365
- assert client_triggered_error, (
366
- f"Client request did not result in a 500 error or a request error. "
367
- f"Status: {response_status}, Body: {response_body[:500]}"
368
- )
369
-
370
- assert server_log_file.exists(), (
371
- f"Server log file was not created at {server_log_file}"
372
- )
373
- log_content = server_log_file.read_text()
374
-
375
- print(f"--- Captured Server Log Content ({server_log_file}) ---")
376
- print(log_content)
377
- print("--- End Server Log Content ---")
378
-
379
- # Core assertions for the enhanced error message
380
- assert (
381
- "FastMCP's StreamableHTTPSessionManager task group was not initialized"
382
- in log_content
383
- )
384
- assert "lifespan=mcp_app.lifespan" in log_content
385
- assert "gofastmcp.com/deployment/asgi" in log_content
386
- assert "Original error: Task group is not initialized" in log_content
387
-
388
- # Check for Uvicorn's own error logging wrapper for the request
389
- assert "ERROR" in log_content # General check for ERROR level logs
390
- assert "Exception in ASGI application" in log_content
391
-
392
- # Sanity checks for server operation and logging setup
393
- assert "Uvicorn running on" in log_content
394
- assert (
395
- "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content
396
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/server/test_logging.py CHANGED
@@ -2,6 +2,7 @@ import asyncio
2
  import logging
3
  from unittest.mock import AsyncMock, Mock, patch
4
 
 
5
  import pytest
6
 
7
  from fastmcp.server.server import FastMCP
@@ -27,7 +28,7 @@ async def test_uvicorn_logging_default_level(
27
  """Tests that FastMCP passes log_level to uvicorn.Config if no log_config is given."""
28
  mock_server_instance = AsyncMock()
29
  mock_uvicorn_server_constructor.return_value = mock_server_instance
30
- serve_finished_event = asyncio.Event()
31
  mock_server_instance.serve.side_effect = serve_finished_event.wait
32
 
33
  test_log_level = "warning"
@@ -63,7 +64,7 @@ async def test_uvicorn_logging_with_custom_log_config(
63
  """Tests that FastMCP passes log_config to uvicorn.Config and not log_level."""
64
  mock_server_instance = AsyncMock()
65
  mock_uvicorn_server_constructor.return_value = mock_server_instance
66
- serve_finished_event = asyncio.Event()
67
  mock_server_instance.serve.side_effect = serve_finished_event.wait
68
 
69
  sample_log_config = {
@@ -123,7 +124,7 @@ async def test_uvicorn_logging_custom_log_config_overrides_log_level_param(
123
  """Tests log_config precedence if log_level is also passed to run_http_async."""
124
  mock_server_instance = AsyncMock()
125
  mock_uvicorn_server_constructor.return_value = mock_server_instance
126
- serve_finished_event = asyncio.Event()
127
  mock_server_instance.serve.side_effect = serve_finished_event.wait
128
 
129
  sample_log_config = {
 
2
  import logging
3
  from unittest.mock import AsyncMock, Mock, patch
4
 
5
+ import anyio
6
  import pytest
7
 
8
  from fastmcp.server.server import FastMCP
 
28
  """Tests that FastMCP passes log_level to uvicorn.Config if no log_config is given."""
29
  mock_server_instance = AsyncMock()
30
  mock_uvicorn_server_constructor.return_value = mock_server_instance
31
+ serve_finished_event = anyio.Event()
32
  mock_server_instance.serve.side_effect = serve_finished_event.wait
33
 
34
  test_log_level = "warning"
 
64
  """Tests that FastMCP passes log_config to uvicorn.Config and not log_level."""
65
  mock_server_instance = AsyncMock()
66
  mock_uvicorn_server_constructor.return_value = mock_server_instance
67
+ serve_finished_event = anyio.Event()
68
  mock_server_instance.serve.side_effect = serve_finished_event.wait
69
 
70
  sample_log_config = {
 
124
  """Tests log_config precedence if log_level is also passed to run_http_async."""
125
  mock_server_instance = AsyncMock()
126
  mock_uvicorn_server_constructor.return_value = mock_server_instance
127
+ serve_finished_event = anyio.Event()
128
  mock_server_instance.serve.side_effect = serve_finished_event.wait
129
 
130
  sample_log_config = {
tests/server/test_mount.py CHANGED
@@ -3,8 +3,6 @@ import sys
3
  from contextlib import asynccontextmanager
4
 
5
  import pytest
6
- from mcp.server.lowlevel.helper_types import ReadResourceContents
7
- from mcp.types import TextContent, TextResourceContents
8
 
9
  from fastmcp import FastMCP
10
  from fastmcp.client import Client
@@ -36,8 +34,7 @@ class TestBasicMount:
36
 
37
  async with Client(main_app) as client:
38
  result = await client.call_tool("sub_sub_tool", {})
39
- assert isinstance(result[0], TextContent)
40
- assert result[0].text == "This is from the sub app"
41
 
42
  async def test_mount_with_custom_separator(self):
43
  """Test mounting with a custom tool separator (deprecated but still supported)."""
@@ -57,8 +54,7 @@ class TestBasicMount:
57
 
58
  # Call the tool
59
  result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
60
- assert isinstance(result[0], TextContent)
61
- assert result[0].text == "Hello, World!"
62
 
63
  async def test_mount_invalid_resource_prefix(self):
64
  main_app = FastMCP("MainApp")
@@ -147,12 +143,10 @@ class TestMultipleServerMount:
147
 
148
  # Call tools from both mounted servers
149
  result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
150
- assert isinstance(result1[0], TextContent)
151
- assert result1[0].text == "Weather forecast"
152
 
153
  result2 = await main_app._mcp_call_tool("news_get_headlines", {})
154
- assert isinstance(result2[0], TextContent)
155
- assert result2[0].text == "News headlines"
156
 
157
  async def test_mount_same_prefix(self):
158
  """Test that mounting with the same prefix replaces the previous mount."""
@@ -227,8 +221,7 @@ class TestMultipleServerMount:
227
 
228
  # Test calling a tool
229
  result = await client.call_tool("working_working_tool", {})
230
- assert isinstance(result[0], TextContent)
231
- assert result[0].text == "Working tool"
232
 
233
  # Test resources
234
  resources = await client.list_resources()
@@ -284,8 +277,7 @@ class TestDynamicChanges:
284
 
285
  # Call the dynamically added tool
286
  result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
287
- assert isinstance(result[0], TextContent)
288
- assert result[0].text == "Added after mounting"
289
 
290
  async def test_removing_tool_after_mounting(self):
291
  """Test that tools removed from mounted servers are no longer accessible."""
@@ -335,8 +327,7 @@ class TestResourcesAndTemplates:
335
  # Check that resource can be accessed
336
  async with Client(main_app) as client:
337
  result = await client.read_resource("data://data/users")
338
- assert isinstance(result[0], TextResourceContents)
339
- assert json.loads(result[0].text) == ["user1", "user2"]
340
 
341
  async def test_mount_with_resource_templates(self):
342
  """Test mounting a server with resource templates."""
@@ -357,8 +348,7 @@ class TestResourcesAndTemplates:
357
  # Check template instantiation
358
  async with Client(main_app) as client:
359
  result = await client.read_resource("users://api/123/profile")
360
- assert isinstance(result[0], TextResourceContents)
361
- profile = json.loads(result[0].text)
362
  assert profile["id"] == "123"
363
  assert profile["name"] == "User 123"
364
 
@@ -382,8 +372,7 @@ class TestResourcesAndTemplates:
382
  # Check access to the resource
383
  async with Client(main_app) as client:
384
  result = await client.read_resource("data://data/config")
385
- assert isinstance(result[0], TextResourceContents)
386
- config = json.loads(result[0].text)
387
  assert config["version"] == "1.0"
388
 
389
 
@@ -461,8 +450,7 @@ class TestProxyServer:
461
 
462
  # Call the tool
463
  result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
464
- assert isinstance(result[0], TextContent)
465
- assert result[0].text == "Data for test"
466
 
467
  async def test_dynamically_adding_to_proxied_server(self):
468
  """Test that changes to the original server are reflected in the mounted proxy."""
@@ -489,8 +477,7 @@ class TestProxyServer:
489
 
490
  # Call the tool
491
  result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
492
- assert isinstance(result[0], TextContent)
493
- assert result[0].text == "Dynamic data"
494
 
495
  async def test_proxy_server_with_resources(self):
496
  """Test mounting a proxy server with resources."""
@@ -512,8 +499,7 @@ class TestProxyServer:
512
 
513
  # Resource should be accessible through main app
514
  result = await main_app._mcp_read_resource("config://proxy/settings")
515
- assert isinstance(result[0], ReadResourceContents)
516
- config = json.loads(result[0].content)
517
  assert config["api_key"] == "12345"
518
 
519
  async def test_proxy_server_with_prompts(self):
 
3
  from contextlib import asynccontextmanager
4
 
5
  import pytest
 
 
6
 
7
  from fastmcp import FastMCP
8
  from fastmcp.client import Client
 
34
 
35
  async with Client(main_app) as client:
36
  result = await client.call_tool("sub_sub_tool", {})
37
+ assert result[0].text == "This is from the sub app" # type: ignore[attr-defined]
 
38
 
39
  async def test_mount_with_custom_separator(self):
40
  """Test mounting with a custom tool separator (deprecated but still supported)."""
 
54
 
55
  # Call the tool
56
  result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
57
+ assert result[0].text == "Hello, World!" # type: ignore[attr-defined]
 
58
 
59
  async def test_mount_invalid_resource_prefix(self):
60
  main_app = FastMCP("MainApp")
 
143
 
144
  # Call tools from both mounted servers
145
  result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
146
+ assert result1[0].text == "Weather forecast" # type: ignore[attr-defined]
 
147
 
148
  result2 = await main_app._mcp_call_tool("news_get_headlines", {})
149
+ assert result2[0].text == "News headlines" # type: ignore[attr-defined]
 
150
 
151
  async def test_mount_same_prefix(self):
152
  """Test that mounting with the same prefix replaces the previous mount."""
 
221
 
222
  # Test calling a tool
223
  result = await client.call_tool("working_working_tool", {})
224
+ assert result[0].text == "Working tool" # type: ignore[attr-defined]
 
225
 
226
  # Test resources
227
  resources = await client.list_resources()
 
277
 
278
  # Call the dynamically added tool
279
  result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
280
+ assert result[0].text == "Added after mounting" # type: ignore[attr-defined]
 
281
 
282
  async def test_removing_tool_after_mounting(self):
283
  """Test that tools removed from mounted servers are no longer accessible."""
 
327
  # Check that resource can be accessed
328
  async with Client(main_app) as client:
329
  result = await client.read_resource("data://data/users")
330
+ assert json.loads(result[0].text) == ["user1", "user2"] # type: ignore[attr-defined]
 
331
 
332
  async def test_mount_with_resource_templates(self):
333
  """Test mounting a server with resource templates."""
 
348
  # Check template instantiation
349
  async with Client(main_app) as client:
350
  result = await client.read_resource("users://api/123/profile")
351
+ profile = json.loads(result[0].text) # type: ignore
 
352
  assert profile["id"] == "123"
353
  assert profile["name"] == "User 123"
354
 
 
372
  # Check access to the resource
373
  async with Client(main_app) as client:
374
  result = await client.read_resource("data://data/config")
375
+ config = json.loads(result[0].text) # type: ignore[attr-defined]
 
376
  assert config["version"] == "1.0"
377
 
378
 
 
450
 
451
  # Call the tool
452
  result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
453
+ assert result[0].text == "Data for test" # type: ignore[attr-defined]
 
454
 
455
  async def test_dynamically_adding_to_proxied_server(self):
456
  """Test that changes to the original server are reflected in the mounted proxy."""
 
477
 
478
  # Call the tool
479
  result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
480
+ assert result[0].text == "Dynamic data" # type: ignore[attr-defined]
 
481
 
482
  async def test_proxy_server_with_resources(self):
483
  """Test mounting a proxy server with resources."""
 
499
 
500
  # Resource should be accessible through main app
501
  result = await main_app._mcp_read_resource("config://proxy/settings")
502
+ config = json.loads(result[0].content) # type: ignore[attr-defined]
 
503
  assert config["api_key"] == "12345"
504
 
505
  async def test_proxy_server_with_prompts(self):
tests/server/test_proxy.py CHANGED
@@ -1,8 +1,8 @@
1
  import json
2
  from typing import Any
3
 
4
- import mcp.types
5
  import pytest
 
6
  from dirty_equals import Contains
7
  from mcp import McpError
8
 
@@ -89,16 +89,14 @@ async def test_as_proxy_with_server(fastmcp_server):
89
  """FastMCP.as_proxy should accept a FastMCP instance."""
90
  proxy = FastMCP.as_proxy(fastmcp_server)
91
  result = await proxy._mcp_call_tool("greet", {"name": "Test"})
92
- assert isinstance(result[0], mcp.types.TextContent)
93
- assert result[0].text == "Hello, Test!"
94
 
95
 
96
  async def test_as_proxy_with_transport(fastmcp_server):
97
  """FastMCP.as_proxy should accept a ClientTransport."""
98
  proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
99
  result = await proxy._mcp_call_tool("greet", {"name": "Test"})
100
- assert isinstance(result[0], mcp.types.TextContent)
101
- assert result[0].text == "Hello, Test!"
102
 
103
 
104
  def test_as_proxy_with_url():
@@ -137,9 +135,7 @@ class TestTools:
137
  async def test_call_tool_calls_tool(self, proxy_server):
138
  async with Client(proxy_server) as client:
139
  proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
140
-
141
- assert isinstance(proxy_result[0], mcp.types.TextContent)
142
- assert proxy_result[0].text == "3"
143
 
144
  async def test_error_tool_raises_error(self, proxy_server):
145
  with pytest.raises(ToolError, match=""):
@@ -163,8 +159,7 @@ class TestResources:
163
  async def test_read_resource(self, proxy_server: FastMCPProxy):
164
  async with Client(proxy_server) as client:
165
  result = await client.read_resource("resource://wave")
166
- assert isinstance(result[0], mcp.types.TextResourceContents)
167
- assert result[0].text == "👋"
168
 
169
  async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
170
  async with Client(fastmcp_server) as client:
@@ -176,8 +171,7 @@ class TestResources:
176
  async def test_read_json_resource(self, proxy_server: FastMCPProxy):
177
  async with Client(proxy_server) as client:
178
  result = await client.read_resource("data://users")
179
- assert isinstance(result[0], mcp.types.TextResourceContents)
180
- assert json.loads(result[0].text) == USERS
181
 
182
  async def test_read_resource_returns_none_if_not_found(self, proxy_server):
183
  with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"):
@@ -201,8 +195,7 @@ class TestResourceTemplates:
201
  async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
202
  async with Client(proxy_server) as client:
203
  result = await client.read_resource(f"data://user/{id}")
204
- assert isinstance(result[0], mcp.types.TextResourceContents)
205
- assert json.loads(result[0].text) == USERS[id - 1]
206
 
207
  async def test_read_resource_template_same_as_original(
208
  self, fastmcp_server, proxy_server
@@ -238,7 +231,28 @@ class TestPrompts:
238
  async def test_render_prompt_calls_prompt(self, proxy_server):
239
  async with Client(proxy_server) as client:
240
  result = await client.get_prompt("welcome", {"name": "Alice"})
241
- assert isinstance(result.messages[0], mcp.types.PromptMessage)
242
  assert result.messages[0].role == "user"
243
- assert isinstance(result.messages[0].content, mcp.types.TextContent)
244
- assert result.messages[0].content.text == "Welcome to FastMCP, Alice!"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  from typing import Any
3
 
 
4
  import pytest
5
+ from anyio import create_task_group
6
  from dirty_equals import Contains
7
  from mcp import McpError
8
 
 
89
  """FastMCP.as_proxy should accept a FastMCP instance."""
90
  proxy = FastMCP.as_proxy(fastmcp_server)
91
  result = await proxy._mcp_call_tool("greet", {"name": "Test"})
92
+ assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
 
93
 
94
 
95
  async def test_as_proxy_with_transport(fastmcp_server):
96
  """FastMCP.as_proxy should accept a ClientTransport."""
97
  proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
98
  result = await proxy._mcp_call_tool("greet", {"name": "Test"})
99
+ assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
 
100
 
101
 
102
  def test_as_proxy_with_url():
 
135
  async def test_call_tool_calls_tool(self, proxy_server):
136
  async with Client(proxy_server) as client:
137
  proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
138
+ assert proxy_result[0].text == "3" # type: ignore[attr-defined]
 
 
139
 
140
  async def test_error_tool_raises_error(self, proxy_server):
141
  with pytest.raises(ToolError, match=""):
 
159
  async def test_read_resource(self, proxy_server: FastMCPProxy):
160
  async with Client(proxy_server) as client:
161
  result = await client.read_resource("resource://wave")
162
+ assert result[0].text == "👋" # type: ignore[attr-defined]
 
163
 
164
  async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
165
  async with Client(fastmcp_server) as client:
 
171
  async def test_read_json_resource(self, proxy_server: FastMCPProxy):
172
  async with Client(proxy_server) as client:
173
  result = await client.read_resource("data://users")
174
+ assert json.loads(result[0].text) == USERS # type: ignore[attr-defined]
 
175
 
176
  async def test_read_resource_returns_none_if_not_found(self, proxy_server):
177
  with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"):
 
195
  async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
196
  async with Client(proxy_server) as client:
197
  result = await client.read_resource(f"data://user/{id}")
198
+ assert json.loads(result[0].text) == USERS[id - 1] # type: ignore[attr-defined]
 
199
 
200
  async def test_read_resource_template_same_as_original(
201
  self, fastmcp_server, proxy_server
 
231
  async def test_render_prompt_calls_prompt(self, proxy_server):
232
  async with Client(proxy_server) as client:
233
  result = await client.get_prompt("welcome", {"name": "Alice"})
 
234
  assert result.messages[0].role == "user"
235
+ assert result.messages[0].content.text == "Welcome to FastMCP, Alice!" # type: ignore[attr-defined]
236
+
237
+
238
+ async def test_proxy_handles_multiple_concurrent_tasks_correctly(
239
+ proxy_server: FastMCPProxy,
240
+ ):
241
+ results = {}
242
+
243
+ async def get_and_store(name, coro):
244
+ results[name] = await coro()
245
+
246
+ async with create_task_group() as tg:
247
+ tg.start_soon(get_and_store, "prompts", proxy_server.get_prompts)
248
+ tg.start_soon(get_and_store, "resources", proxy_server.get_resources)
249
+ tg.start_soon(get_and_store, "tools", proxy_server.get_tools)
250
+
251
+ assert list(results) == Contains("resources", "prompts", "tools")
252
+ assert list(results["prompts"]) == Contains("welcome")
253
+ assert [r.name for r in results["resources"].values()] == Contains(
254
+ "data://users", "resource://wave"
255
+ )
256
+ assert list(results["tools"]) == Contains(
257
+ "greet", "add", "error_tool", "tool_without_description"
258
+ )
tests/server/test_server.py CHANGED
@@ -2,10 +2,6 @@ from typing import Annotated
2
 
3
  import pytest
4
  from mcp import McpError
5
- from mcp.types import (
6
- TextContent,
7
- TextResourceContents,
8
- )
9
  from pydantic import Field
10
 
11
  from fastmcp import Client, FastMCP
@@ -16,6 +12,7 @@ from fastmcp.server.server import (
16
  has_resource_prefix,
17
  remove_resource_prefix,
18
  )
 
19
 
20
 
21
  class TestCreateServer:
@@ -48,8 +45,7 @@ class TestCreateServer:
48
  result = await client.call_tool("hello_world", {})
49
  assert len(result) == 1
50
  content = result[0]
51
- assert isinstance(content, TextContent)
52
- assert "¡Hola, 世界! 👋" == content.text
53
 
54
 
55
  class TestTools:
@@ -98,6 +94,24 @@ class TestTools:
98
  with pytest.raises(NotFoundError, match="Unknown tool: adder"):
99
  await mcp._mcp_call_tool("adder", {"a": 1, "b": 2})
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
  class TestToolDecorator:
103
  async def test_no_tools_before_decorator(self):
@@ -114,8 +128,7 @@ class TestToolDecorator:
114
  return x + y
115
 
116
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
117
- assert isinstance(result[0], TextContent)
118
- assert result[0].text == "3"
119
 
120
  async def test_tool_decorator_incorrect_usage(self):
121
  mcp = FastMCP()
@@ -134,8 +147,7 @@ class TestToolDecorator:
134
  return x + y
135
 
136
  result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
137
- assert isinstance(result[0], TextContent)
138
- assert result[0].text == "3"
139
 
140
  async def test_tool_decorator_with_description(self):
141
  mcp = FastMCP()
@@ -163,8 +175,7 @@ class TestToolDecorator:
163
  obj = MyClass(10)
164
  mcp.add_tool(obj.add)
165
  result = await mcp._mcp_call_tool("add", {"y": 2})
166
- assert isinstance(result[0], TextContent)
167
- assert result[0].text == "12"
168
 
169
  async def test_tool_decorator_classmethod(self):
170
  mcp = FastMCP()
@@ -178,8 +189,7 @@ class TestToolDecorator:
178
 
179
  mcp.add_tool(MyClass.add)
180
  result = await mcp._mcp_call_tool("add", {"y": 2})
181
- assert isinstance(result[0], TextContent)
182
- assert result[0].text == "12"
183
 
184
  async def test_tool_decorator_staticmethod(self):
185
  mcp = FastMCP()
@@ -191,8 +201,7 @@ class TestToolDecorator:
191
  return x + y
192
 
193
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
194
- assert isinstance(result[0], TextContent)
195
- assert result[0].text == "3"
196
 
197
  async def test_tool_decorator_async_function(self):
198
  mcp = FastMCP()
@@ -202,8 +211,7 @@ class TestToolDecorator:
202
  return x + y
203
 
204
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
205
- assert isinstance(result[0], TextContent)
206
- assert result[0].text == "3"
207
 
208
  async def test_tool_decorator_classmethod_async_function(self):
209
  mcp = FastMCP()
@@ -217,8 +225,7 @@ class TestToolDecorator:
217
 
218
  mcp.add_tool(MyClass.add)
219
  result = await mcp._mcp_call_tool("add", {"y": 2})
220
- assert isinstance(result[0], TextContent)
221
- assert result[0].text == "12"
222
 
223
  async def test_tool_decorator_staticmethod_async_function(self):
224
  mcp = FastMCP()
@@ -230,8 +237,7 @@ class TestToolDecorator:
230
 
231
  mcp.add_tool(MyClass.add)
232
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
233
- assert isinstance(result[0], TextContent)
234
- assert result[0].text == "3"
235
 
236
  async def test_tool_decorator_with_tags(self):
237
  """Test that the tool decorator properly sets tags."""
@@ -262,8 +268,7 @@ class TestToolDecorator:
262
 
263
  # Call the tool by its custom name
264
  result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
265
- assert isinstance(result[0], TextContent)
266
- assert result[0].text == "15"
267
 
268
  # Original name should not be registered
269
  assert "multiply" not in tools
@@ -316,8 +321,7 @@ class TestResourceDecorator:
316
 
317
  async with Client(mcp) as client:
318
  result = await client.read_resource("resource://data")
319
- assert isinstance(result[0], TextResourceContents)
320
- assert result[0].text == "Hello, world!"
321
 
322
  async def test_resource_decorator_incorrect_usage(self):
323
  mcp = FastMCP()
@@ -344,8 +348,7 @@ class TestResourceDecorator:
344
 
345
  async with Client(mcp) as client:
346
  result = await client.read_resource("resource://data")
347
- assert isinstance(result[0], TextResourceContents)
348
- assert result[0].text == "Hello, world!"
349
 
350
  async def test_resource_decorator_with_description(self):
351
  mcp = FastMCP()
@@ -389,8 +392,7 @@ class TestResourceDecorator:
389
 
390
  async with Client(mcp) as client:
391
  result = await client.read_resource("resource://data")
392
- assert isinstance(result[0], TextResourceContents)
393
- assert result[0].text == "My prefix: Hello, world!"
394
 
395
  async def test_resource_decorator_classmethod(self):
396
  mcp = FastMCP()
@@ -408,8 +410,7 @@ class TestResourceDecorator:
408
 
409
  async with Client(mcp) as client:
410
  result = await client.read_resource("resource://data")
411
- assert isinstance(result[0], TextResourceContents)
412
- assert result[0].text == "Class prefix: Hello, world!"
413
 
414
  async def test_resource_decorator_staticmethod(self):
415
  mcp = FastMCP()
@@ -422,8 +423,7 @@ class TestResourceDecorator:
422
 
423
  async with Client(mcp) as client:
424
  result = await client.read_resource("resource://data")
425
- assert isinstance(result[0], TextResourceContents)
426
- assert result[0].text == "Static Hello, world!"
427
 
428
  async def test_resource_decorator_async_function(self):
429
  mcp = FastMCP()
@@ -434,8 +434,7 @@ class TestResourceDecorator:
434
 
435
  async with Client(mcp) as client:
436
  result = await client.read_resource("resource://data")
437
- assert isinstance(result[0], TextResourceContents)
438
- assert result[0].text == "Async Hello, world!"
439
 
440
 
441
  class TestTemplateDecorator:
@@ -454,8 +453,7 @@ class TestTemplateDecorator:
454
 
455
  async with Client(mcp) as client:
456
  result = await client.read_resource("resource://test/data")
457
- assert isinstance(result[0], TextResourceContents)
458
- assert result[0].text == "Data for test"
459
 
460
  async def test_template_decorator_incorrect_usage(self):
461
  mcp = FastMCP()
@@ -482,8 +480,7 @@ class TestTemplateDecorator:
482
 
483
  async with Client(mcp) as client:
484
  result = await client.read_resource("resource://test/data")
485
- assert isinstance(result[0], TextResourceContents)
486
- assert result[0].text == "Data for test"
487
 
488
  async def test_template_decorator_with_description(self):
489
  mcp = FastMCP()
@@ -514,8 +511,7 @@ class TestTemplateDecorator:
514
 
515
  async with Client(mcp) as client:
516
  result = await client.read_resource("resource://test/data")
517
- assert isinstance(result[0], TextResourceContents)
518
- assert result[0].text == "My prefix: Data for test"
519
 
520
  async def test_template_decorator_classmethod(self):
521
  mcp = FastMCP()
@@ -535,8 +531,7 @@ class TestTemplateDecorator:
535
 
536
  async with Client(mcp) as client:
537
  result = await client.read_resource("resource://test/data")
538
- assert isinstance(result[0], TextResourceContents)
539
- assert result[0].text == "Class prefix: Data for test"
540
 
541
  async def test_template_decorator_staticmethod(self):
542
  mcp = FastMCP()
@@ -549,8 +544,7 @@ class TestTemplateDecorator:
549
 
550
  async with Client(mcp) as client:
551
  result = await client.read_resource("resource://test/data")
552
- assert isinstance(result[0], TextResourceContents)
553
- assert result[0].text == "Static Data for test"
554
 
555
  async def test_template_decorator_async_function(self):
556
  mcp = FastMCP()
@@ -561,8 +555,7 @@ class TestTemplateDecorator:
561
 
562
  async with Client(mcp) as client:
563
  result = await client.read_resource("resource://test/data")
564
- assert isinstance(result[0], TextResourceContents)
565
- assert result[0].text == "Async Data for test"
566
 
567
  async def test_template_decorator_with_tags(self):
568
  """Test that the template decorator properly sets tags."""
@@ -603,8 +596,7 @@ class TestPromptDecorator:
603
  assert prompt.name == "fn"
604
  # Don't compare functions directly since validate_call wraps them
605
  content = await prompt.render()
606
- assert isinstance(content[0].content, TextContent)
607
- assert content[0].content.text == "Hello, world!"
608
 
609
  async def test_prompt_decorator_incorrect_usage(self):
610
  mcp = FastMCP()
@@ -629,8 +621,7 @@ class TestPromptDecorator:
629
  prompt = prompts_dict["custom_name"]
630
  assert prompt.name == "custom_name"
631
  content = await prompt.render()
632
- assert isinstance(content[0].content, TextContent)
633
- assert content[0].content.text == "Hello, world!"
634
 
635
  async def test_prompt_decorator_with_description(self):
636
  mcp = FastMCP()
@@ -644,8 +635,7 @@ class TestPromptDecorator:
644
  prompt = prompts_dict["fn"]
645
  assert prompt.description == "A custom description"
646
  content = await prompt.render()
647
- assert isinstance(content[0].content, TextContent)
648
- assert content[0].content.text == "Hello, world!"
649
 
650
  async def test_prompt_decorator_with_parameters(self):
651
  mcp = FastMCP()
@@ -668,16 +658,14 @@ class TestPromptDecorator:
668
  result = await client.get_prompt("test_prompt", {"name": "World"})
669
  assert len(result.messages) == 1
670
  message = result.messages[0]
671
- assert isinstance(message.content, TextContent)
672
- assert message.content.text == "Hello, World!"
673
 
674
  result = await client.get_prompt(
675
  "test_prompt", {"name": "World", "greeting": "Hi"}
676
  )
677
  assert len(result.messages) == 1
678
  message = result.messages[0]
679
- assert isinstance(message.content, TextContent)
680
- assert message.content.text == "Hi, World!"
681
 
682
  async def test_prompt_decorator_instance_method(self):
683
  mcp = FastMCP()
@@ -696,8 +684,7 @@ class TestPromptDecorator:
696
  result = await client.get_prompt("test_prompt")
697
  assert len(result.messages) == 1
698
  message = result.messages[0]
699
- assert isinstance(message.content, TextContent)
700
- assert message.content.text == "My prefix: Hello, world!"
701
 
702
  async def test_prompt_decorator_classmethod(self):
703
  mcp = FastMCP()
@@ -715,8 +702,7 @@ class TestPromptDecorator:
715
  result = await client.get_prompt("test_prompt")
716
  assert len(result.messages) == 1
717
  message = result.messages[0]
718
- assert isinstance(message.content, TextContent)
719
- assert message.content.text == "Class prefix: Hello, world!"
720
 
721
  async def test_prompt_decorator_staticmethod(self):
722
  mcp = FastMCP()
@@ -731,8 +717,7 @@ class TestPromptDecorator:
731
  result = await client.get_prompt("test_prompt")
732
  assert len(result.messages) == 1
733
  message = result.messages[0]
734
- assert isinstance(message.content, TextContent)
735
- assert message.content.text == "Static Hello, world!"
736
 
737
  async def test_prompt_decorator_async_function(self):
738
  mcp = FastMCP()
@@ -745,8 +730,7 @@ class TestPromptDecorator:
745
  result = await client.get_prompt("test_prompt")
746
  assert len(result.messages) == 1
747
  message = result.messages[0]
748
- assert isinstance(message.content, TextContent)
749
- assert message.content.text == "Async Hello, world!"
750
 
751
  async def test_prompt_decorator_with_tags(self):
752
  """Test that the prompt decorator properly sets tags."""
@@ -943,20 +927,17 @@ class TestResourcePrefixMounting:
943
  async with Client(main_server) as client:
944
  # Regular resource
945
  result = await client.read_resource("resource://prefix/test-resource")
946
- assert isinstance(result[0], TextResourceContents)
947
- assert result[0].text == "Resource content"
948
 
949
  # Absolute path resource
950
  result = await client.read_resource("resource://prefix//absolute/path")
951
- assert isinstance(result[0], TextResourceContents)
952
- assert result[0].text == "Absolute resource content"
953
 
954
  # Template resource
955
  result = await client.read_resource(
956
  "resource://prefix/param-value/template"
957
  )
958
- assert isinstance(result[0], TextResourceContents)
959
- assert result[0].text == "Template resource with param-value"
960
 
961
  @pytest.mark.parametrize(
962
  "uri,prefix,expected_match,expected_strip",
@@ -1032,15 +1013,12 @@ class TestResourcePrefixMounting:
1032
  # Verify we can access the resources
1033
  async with Client(target_server) as client:
1034
  result = await client.read_resource("resource://imported/test-resource")
1035
- assert isinstance(result[0], TextResourceContents)
1036
- assert result[0].text == "Resource content"
1037
 
1038
  result = await client.read_resource("resource://imported//absolute/path")
1039
- assert isinstance(result[0], TextResourceContents)
1040
- assert result[0].text == "Absolute resource content"
1041
 
1042
  result = await client.read_resource(
1043
  "resource://imported/param-value/template"
1044
  )
1045
- assert isinstance(result[0], TextResourceContents)
1046
- assert result[0].text == "Template resource with param-value"
 
2
 
3
  import pytest
4
  from mcp import McpError
 
 
 
 
5
  from pydantic import Field
6
 
7
  from fastmcp import Client, FastMCP
 
12
  has_resource_prefix,
13
  remove_resource_prefix,
14
  )
15
+ from fastmcp.tools.tool import Tool
16
 
17
 
18
  class TestCreateServer:
 
45
  result = await client.call_tool("hello_world", {})
46
  assert len(result) == 1
47
  content = result[0]
48
+ assert content.text == "¡Hola, 世界! 👋" # type: ignore[attr-defined]
 
49
 
50
 
51
  class TestTools:
 
94
  with pytest.raises(NotFoundError, match="Unknown tool: adder"):
95
  await mcp._mcp_call_tool("adder", {"a": 1, "b": 2})
96
 
97
+ async def test_add_tool_at_init(self):
98
+ def f(x: int) -> int:
99
+ return x + 1
100
+
101
+ def g(x: int) -> int:
102
+ """add two to a number"""
103
+ return x + 2
104
+
105
+ g_tool = Tool.from_function(g, name="g-tool")
106
+
107
+ mcp = FastMCP(tools=[f, g_tool])
108
+
109
+ tools = await mcp.get_tools()
110
+ assert len(tools) == 2
111
+ assert tools["f"].name == "f"
112
+ assert tools["g-tool"].name == "g-tool"
113
+ assert tools["g-tool"].description == "add two to a number"
114
+
115
 
116
  class TestToolDecorator:
117
  async def test_no_tools_before_decorator(self):
 
128
  return x + y
129
 
130
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
131
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
132
 
133
  async def test_tool_decorator_incorrect_usage(self):
134
  mcp = FastMCP()
 
147
  return x + y
148
 
149
  result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
150
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
151
 
152
  async def test_tool_decorator_with_description(self):
153
  mcp = FastMCP()
 
175
  obj = MyClass(10)
176
  mcp.add_tool(obj.add)
177
  result = await mcp._mcp_call_tool("add", {"y": 2})
178
+ assert result[0].text == "12" # type: ignore[attr-defined]
 
179
 
180
  async def test_tool_decorator_classmethod(self):
181
  mcp = FastMCP()
 
189
 
190
  mcp.add_tool(MyClass.add)
191
  result = await mcp._mcp_call_tool("add", {"y": 2})
192
+ assert result[0].text == "12" # type: ignore[attr-defined]
 
193
 
194
  async def test_tool_decorator_staticmethod(self):
195
  mcp = FastMCP()
 
201
  return x + y
202
 
203
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
204
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
205
 
206
  async def test_tool_decorator_async_function(self):
207
  mcp = FastMCP()
 
211
  return x + y
212
 
213
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
214
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
215
 
216
  async def test_tool_decorator_classmethod_async_function(self):
217
  mcp = FastMCP()
 
225
 
226
  mcp.add_tool(MyClass.add)
227
  result = await mcp._mcp_call_tool("add", {"y": 2})
228
+ assert result[0].text == "12" # type: ignore[attr-defined]
 
229
 
230
  async def test_tool_decorator_staticmethod_async_function(self):
231
  mcp = FastMCP()
 
237
 
238
  mcp.add_tool(MyClass.add)
239
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
240
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
241
 
242
  async def test_tool_decorator_with_tags(self):
243
  """Test that the tool decorator properly sets tags."""
 
268
 
269
  # Call the tool by its custom name
270
  result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
271
+ assert result[0].text == "15" # type: ignore[attr-defined]
 
272
 
273
  # Original name should not be registered
274
  assert "multiply" not in tools
 
321
 
322
  async with Client(mcp) as client:
323
  result = await client.read_resource("resource://data")
324
+ assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
 
325
 
326
  async def test_resource_decorator_incorrect_usage(self):
327
  mcp = FastMCP()
 
348
 
349
  async with Client(mcp) as client:
350
  result = await client.read_resource("resource://data")
351
+ assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
 
352
 
353
  async def test_resource_decorator_with_description(self):
354
  mcp = FastMCP()
 
392
 
393
  async with Client(mcp) as client:
394
  result = await client.read_resource("resource://data")
395
+ assert result[0].text == "My prefix: Hello, world!" # type: ignore[attr-defined]
 
396
 
397
  async def test_resource_decorator_classmethod(self):
398
  mcp = FastMCP()
 
410
 
411
  async with Client(mcp) as client:
412
  result = await client.read_resource("resource://data")
413
+ assert result[0].text == "Class prefix: Hello, world!" # type: ignore[attr-defined]
 
414
 
415
  async def test_resource_decorator_staticmethod(self):
416
  mcp = FastMCP()
 
423
 
424
  async with Client(mcp) as client:
425
  result = await client.read_resource("resource://data")
426
+ assert result[0].text == "Static Hello, world!" # type: ignore[attr-defined]
 
427
 
428
  async def test_resource_decorator_async_function(self):
429
  mcp = FastMCP()
 
434
 
435
  async with Client(mcp) as client:
436
  result = await client.read_resource("resource://data")
437
+ assert result[0].text == "Async Hello, world!" # type: ignore[attr-defined]
 
438
 
439
 
440
  class TestTemplateDecorator:
 
453
 
454
  async with Client(mcp) as client:
455
  result = await client.read_resource("resource://test/data")
456
+ assert result[0].text == "Data for test" # type: ignore[attr-defined]
 
457
 
458
  async def test_template_decorator_incorrect_usage(self):
459
  mcp = FastMCP()
 
480
 
481
  async with Client(mcp) as client:
482
  result = await client.read_resource("resource://test/data")
483
+ assert result[0].text == "Data for test" # type: ignore[attr-defined]
 
484
 
485
  async def test_template_decorator_with_description(self):
486
  mcp = FastMCP()
 
511
 
512
  async with Client(mcp) as client:
513
  result = await client.read_resource("resource://test/data")
514
+ assert result[0].text == "My prefix: Data for test" # type: ignore[attr-defined]
 
515
 
516
  async def test_template_decorator_classmethod(self):
517
  mcp = FastMCP()
 
531
 
532
  async with Client(mcp) as client:
533
  result = await client.read_resource("resource://test/data")
534
+ assert result[0].text == "Class prefix: Data for test" # type: ignore[attr-defined]
 
535
 
536
  async def test_template_decorator_staticmethod(self):
537
  mcp = FastMCP()
 
544
 
545
  async with Client(mcp) as client:
546
  result = await client.read_resource("resource://test/data")
547
+ assert result[0].text == "Static Data for test" # type: ignore[attr-defined]
 
548
 
549
  async def test_template_decorator_async_function(self):
550
  mcp = FastMCP()
 
555
 
556
  async with Client(mcp) as client:
557
  result = await client.read_resource("resource://test/data")
558
+ assert result[0].text == "Async Data for test" # type: ignore[attr-defined]
 
559
 
560
  async def test_template_decorator_with_tags(self):
561
  """Test that the template decorator properly sets tags."""
 
596
  assert prompt.name == "fn"
597
  # Don't compare functions directly since validate_call wraps them
598
  content = await prompt.render()
599
+ assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
 
600
 
601
  async def test_prompt_decorator_incorrect_usage(self):
602
  mcp = FastMCP()
 
621
  prompt = prompts_dict["custom_name"]
622
  assert prompt.name == "custom_name"
623
  content = await prompt.render()
624
+ assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
 
625
 
626
  async def test_prompt_decorator_with_description(self):
627
  mcp = FastMCP()
 
635
  prompt = prompts_dict["fn"]
636
  assert prompt.description == "A custom description"
637
  content = await prompt.render()
638
+ assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
 
639
 
640
  async def test_prompt_decorator_with_parameters(self):
641
  mcp = FastMCP()
 
658
  result = await client.get_prompt("test_prompt", {"name": "World"})
659
  assert len(result.messages) == 1
660
  message = result.messages[0]
661
+ assert message.content.text == "Hello, World!" # type: ignore[attr-defined]
 
662
 
663
  result = await client.get_prompt(
664
  "test_prompt", {"name": "World", "greeting": "Hi"}
665
  )
666
  assert len(result.messages) == 1
667
  message = result.messages[0]
668
+ assert message.content.text == "Hi, World!" # type: ignore[attr-defined]
 
669
 
670
  async def test_prompt_decorator_instance_method(self):
671
  mcp = FastMCP()
 
684
  result = await client.get_prompt("test_prompt")
685
  assert len(result.messages) == 1
686
  message = result.messages[0]
687
+ assert message.content.text == "My prefix: Hello, world!" # type: ignore[attr-defined]
 
688
 
689
  async def test_prompt_decorator_classmethod(self):
690
  mcp = FastMCP()
 
702
  result = await client.get_prompt("test_prompt")
703
  assert len(result.messages) == 1
704
  message = result.messages[0]
705
+ assert message.content.text == "Class prefix: Hello, world!" # type: ignore[attr-defined]
 
706
 
707
  async def test_prompt_decorator_staticmethod(self):
708
  mcp = FastMCP()
 
717
  result = await client.get_prompt("test_prompt")
718
  assert len(result.messages) == 1
719
  message = result.messages[0]
720
+ assert message.content.text == "Static Hello, world!" # type: ignore[attr-defined]
 
721
 
722
  async def test_prompt_decorator_async_function(self):
723
  mcp = FastMCP()
 
730
  result = await client.get_prompt("test_prompt")
731
  assert len(result.messages) == 1
732
  message = result.messages[0]
733
+ assert message.content.text == "Async Hello, world!" # type: ignore[attr-defined]
 
734
 
735
  async def test_prompt_decorator_with_tags(self):
736
  """Test that the prompt decorator properly sets tags."""
 
927
  async with Client(main_server) as client:
928
  # Regular resource
929
  result = await client.read_resource("resource://prefix/test-resource")
930
+ assert result[0].text == "Resource content" # type: ignore[attr-defined]
 
931
 
932
  # Absolute path resource
933
  result = await client.read_resource("resource://prefix//absolute/path")
934
+ assert result[0].text == "Absolute resource content" # type: ignore[attr-defined]
 
935
 
936
  # Template resource
937
  result = await client.read_resource(
938
  "resource://prefix/param-value/template"
939
  )
940
+ assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined]
 
941
 
942
  @pytest.mark.parametrize(
943
  "uri,prefix,expected_match,expected_strip",
 
1013
  # Verify we can access the resources
1014
  async with Client(target_server) as client:
1015
  result = await client.read_resource("resource://imported/test-resource")
1016
+ assert result[0].text == "Resource content" # type: ignore[attr-defined]
 
1017
 
1018
  result = await client.read_resource("resource://imported//absolute/path")
1019
+ assert result[0].text == "Absolute resource content" # type: ignore[attr-defined]
 
1020
 
1021
  result = await client.read_resource(
1022
  "resource://imported/param-value/template"
1023
  )
1024
+ assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined]
 
tests/server/test_server_interactions.py CHANGED
@@ -10,7 +10,6 @@ import pydantic_core
10
  import pytest
11
  from mcp import McpError
12
  from mcp.types import (
13
- BlobResourceContents,
14
  ImageContent,
15
  TextContent,
16
  TextResourceContents,
@@ -77,14 +76,12 @@ class TestTools:
77
  async def test_call_tool(self, tool_server: FastMCP):
78
  async with Client(tool_server) as client:
79
  result = await client.call_tool("add", {"x": 1, "y": 2})
80
- assert isinstance(result[0], TextContent)
81
- assert result[0].text == "3"
82
 
83
  async def test_call_tool_as_client(self, tool_server: FastMCP):
84
  async with Client(tool_server) as client:
85
  result = await client.call_tool("add", {"x": 1, "y": 2})
86
- assert isinstance(result[0], TextContent)
87
- assert result[0].text == "3"
88
 
89
  async def test_call_tool_error(self, tool_server: FastMCP):
90
  async with Client(tool_server) as client:
@@ -113,8 +110,7 @@ class TestTools:
113
  async def test_tool_returns_list(self, tool_server: FastMCP):
114
  async with Client(tool_server) as client:
115
  result = await client.call_tool("list_tool", {})
116
- assert isinstance(result[0], TextContent)
117
- assert result[0].text == '[\n "x",\n 2\n]'
118
 
119
 
120
  class TestToolReturnTypes:
@@ -127,8 +123,7 @@ class TestToolReturnTypes:
127
 
128
  async with Client(mcp) as client:
129
  result = await client.call_tool("string_tool", {})
130
- assert isinstance(result[0], TextContent)
131
- assert result[0].text == "Hello, world!"
132
 
133
  async def test_bytes(self, tmp_path: Path):
134
  mcp = FastMCP()
@@ -139,8 +134,7 @@ class TestToolReturnTypes:
139
 
140
  async with Client(mcp) as client:
141
  result = await client.call_tool("bytes_tool", {})
142
- assert isinstance(result[0], TextContent)
143
- assert result[0].text == '"Hello, world!"'
144
 
145
  async def test_uuid(self):
146
  mcp = FastMCP()
@@ -153,8 +147,7 @@ class TestToolReturnTypes:
153
 
154
  async with Client(mcp) as client:
155
  result = await client.call_tool("uuid_tool", {})
156
- assert isinstance(result[0], TextContent)
157
- assert result[0].text == pydantic_core.to_json(test_uuid).decode()
158
 
159
  async def test_path(self):
160
  mcp = FastMCP()
@@ -167,8 +160,7 @@ class TestToolReturnTypes:
167
 
168
  async with Client(mcp) as client:
169
  result = await client.call_tool("path_tool", {})
170
- assert isinstance(result[0], TextContent)
171
- assert result[0].text == pydantic_core.to_json(test_path).decode()
172
 
173
  async def test_datetime(self):
174
  mcp = FastMCP()
@@ -181,8 +173,7 @@ class TestToolReturnTypes:
181
 
182
  async with Client(mcp) as client:
183
  result = await client.call_tool("datetime_tool", {})
184
- assert isinstance(result[0], TextContent)
185
- assert result[0].text == pydantic_core.to_json(dt).decode()
186
 
187
  async def test_image(self, tmp_path: Path):
188
  mcp = FastMCP()
@@ -337,8 +328,7 @@ class TestToolParameters:
337
  async with Client(mcp) as client:
338
  # String with integer value should be coerced to int
339
  result = await client.call_tool("add_one", {"x": "42"})
340
- assert isinstance(result[0], TextContent)
341
- assert result[0].text == "43"
342
 
343
  async def test_tool_bool_coercion(self):
344
  """Test string-to-bool type coercion."""
@@ -351,12 +341,10 @@ class TestToolParameters:
351
  async with Client(mcp) as client:
352
  # String with boolean value should be coerced to bool
353
  result = await client.call_tool("toggle", {"flag": "true"})
354
- assert isinstance(result[0], TextContent)
355
- assert result[0].text == "false"
356
 
357
  result = await client.call_tool("toggle", {"flag": "false"})
358
- assert isinstance(result[0], TextContent)
359
- assert result[0].text == "true"
360
 
361
  async def test_annotated_field_validation(self):
362
  mcp = FastMCP()
@@ -411,8 +399,7 @@ class TestToolParameters:
411
 
412
  async with Client(mcp) as client:
413
  result = await client.call_tool("analyze", {"x": "a"})
414
- assert isinstance(result[0], TextContent)
415
- assert result[0].text == "a"
416
 
417
  async def test_enum_type_validation_error(self):
418
  mcp = FastMCP()
@@ -444,8 +431,7 @@ class TestToolParameters:
444
 
445
  async with Client(mcp) as client:
446
  result = await client.call_tool("analyze", {"x": "red"})
447
- assert isinstance(result[0], TextContent)
448
- assert result[0].text == "red"
449
 
450
  async def test_union_type_validation(self):
451
  mcp = FastMCP()
@@ -456,12 +442,10 @@ class TestToolParameters:
456
 
457
  async with Client(mcp) as client:
458
  result = await client.call_tool("analyze", {"x": 1})
459
- assert isinstance(result[0], TextContent)
460
- assert result[0].text == "1"
461
 
462
  result = await client.call_tool("analyze", {"x": 1.0})
463
- assert isinstance(result[0], TextContent)
464
- assert result[0].text == "1.0"
465
 
466
  with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
467
  await client.call_tool("analyze", {"x": "not a number"})
@@ -479,8 +463,7 @@ class TestToolParameters:
479
 
480
  async with Client(mcp) as client:
481
  result = await client.call_tool("send_path", {"path": str(test_path)})
482
- assert isinstance(result[0], TextContent)
483
- assert result[0].text == str(test_path)
484
 
485
  async def test_path_type_error(self):
486
  mcp = FastMCP()
@@ -505,8 +488,7 @@ class TestToolParameters:
505
 
506
  async with Client(mcp) as client:
507
  result = await client.call_tool("send_uuid", {"x": test_uuid})
508
- assert isinstance(result[0], TextContent)
509
- assert result[0].text == str(test_uuid)
510
 
511
  async def test_uuid_type_error(self):
512
  mcp = FastMCP()
@@ -530,8 +512,7 @@ class TestToolParameters:
530
 
531
  async with Client(mcp) as client:
532
  result = await client.call_tool("send_datetime", {"x": dt})
533
- assert isinstance(result[0], TextContent)
534
- assert result[0].text == dt.isoformat()
535
 
536
  async def test_datetime_type_parse_string(self):
537
  mcp = FastMCP()
@@ -544,8 +525,7 @@ class TestToolParameters:
544
  result = await client.call_tool(
545
  "send_datetime", {"x": "2021-01-01T00:00:00"}
546
  )
547
- assert isinstance(result[0], TextContent)
548
- assert result[0].text == "2021-01-01T00:00:00"
549
 
550
  async def test_datetime_type_error(self):
551
  mcp = FastMCP()
@@ -567,8 +547,7 @@ class TestToolParameters:
567
 
568
  async with Client(mcp) as client:
569
  result = await client.call_tool("send_date", {"x": datetime.date.today()})
570
- assert isinstance(result[0], TextContent)
571
- assert result[0].text == datetime.date.today().isoformat()
572
 
573
  async def test_date_type_parse_string(self):
574
  mcp = FastMCP()
@@ -579,8 +558,7 @@ class TestToolParameters:
579
 
580
  async with Client(mcp) as client:
581
  result = await client.call_tool("send_date", {"x": "2021-01-01"})
582
- assert isinstance(result[0], TextContent)
583
- assert result[0].text == "2021-01-01"
584
 
585
  async def test_timedelta_type(self):
586
  mcp = FastMCP()
@@ -593,8 +571,7 @@ class TestToolParameters:
593
  result = await client.call_tool(
594
  "send_timedelta", {"x": datetime.timedelta(days=1)}
595
  )
596
- assert isinstance(result[0], TextContent)
597
- assert result[0].text == "1 day, 0:00:00"
598
 
599
  async def test_timedelta_type_parse_int(self):
600
  mcp = FastMCP()
@@ -605,8 +582,7 @@ class TestToolParameters:
605
 
606
  async with Client(mcp) as client:
607
  result = await client.call_tool("send_timedelta", {"x": 1000})
608
- assert isinstance(result[0], TextContent)
609
- assert result[0].text == "0:16:40"
610
 
611
 
612
  class TestToolContextInjection:
@@ -639,7 +615,7 @@ class TestToolContextInjection:
639
  result = await client.call_tool("tool_with_context", {"x": 42})
640
  assert len(result) == 1
641
  content = result[0]
642
- assert isinstance(content, TextContent)
643
 
644
  async def test_async_context(self):
645
  """Test that context works in async functions."""
@@ -654,8 +630,7 @@ class TestToolContextInjection:
654
  result = await client.call_tool("async_tool", {"x": 42})
655
  assert len(result) == 1
656
  content = result[0]
657
- assert isinstance(content, TextContent)
658
- assert content.text == "Async request 2: 42"
659
 
660
  async def test_optional_context(self):
661
  """Test that context is optional."""
@@ -669,8 +644,7 @@ class TestToolContextInjection:
669
  result = await client.call_tool("no_context", {"x": 21})
670
  assert len(result) == 1
671
  content = result[0]
672
- assert isinstance(content, TextContent)
673
- assert content.text == "42"
674
 
675
  async def test_context_resource_access(self):
676
  """Test that context can access resources."""
@@ -692,8 +666,7 @@ class TestToolContextInjection:
692
  result = await client.call_tool("tool_with_resource", {})
693
  assert len(result) == 1
694
  content = result[0]
695
- assert isinstance(content, TextContent)
696
- assert "Read resource: resource data" in content.text
697
 
698
  async def test_tool_decorator_with_tags(self):
699
  """Test that the tool decorator properly sets tags."""
@@ -721,8 +694,7 @@ class TestToolContextInjection:
721
 
722
  async with Client(mcp) as client:
723
  result = await client.call_tool("MyTool", {"x": 2})
724
- assert isinstance(result[0], TextContent)
725
- assert result[0].text == "4"
726
 
727
 
728
  class TestResource:
@@ -739,8 +711,7 @@ class TestResource:
739
 
740
  async with Client(mcp) as client:
741
  result = await client.read_resource(AnyUrl("resource://test"))
742
- assert isinstance(result[0], TextResourceContents)
743
- assert result[0].text == "Hello, world!"
744
 
745
  async def test_binary_resource(self):
746
  mcp = FastMCP()
@@ -758,8 +729,7 @@ class TestResource:
758
 
759
  async with Client(mcp) as client:
760
  result = await client.read_resource(AnyUrl("resource://binary"))
761
- assert isinstance(result[0], BlobResourceContents)
762
- assert result[0].blob == base64.b64encode(b"Binary data").decode()
763
 
764
  async def test_file_resource_text(self, tmp_path: Path):
765
  mcp = FastMCP()
@@ -775,8 +745,7 @@ class TestResource:
775
 
776
  async with Client(mcp) as client:
777
  result = await client.read_resource(AnyUrl("file://test.txt"))
778
- assert isinstance(result[0], TextResourceContents)
779
- assert result[0].text == "Hello from file!"
780
 
781
  async def test_file_resource_binary(self, tmp_path: Path):
782
  mcp = FastMCP()
@@ -795,8 +764,7 @@ class TestResource:
795
 
796
  async with Client(mcp) as client:
797
  result = await client.read_resource(AnyUrl("file://test.bin"))
798
- assert isinstance(result[0], BlobResourceContents)
799
- assert result[0].blob == base64.b64encode(b"Binary file data").decode()
800
 
801
 
802
  class TestResourceContext:
@@ -810,8 +778,7 @@ class TestResourceContext:
810
 
811
  async with Client(mcp) as client:
812
  result = await client.read_resource(AnyUrl("resource://test"))
813
- assert isinstance(result[0], TextResourceContents)
814
- assert result[0].text == "2"
815
 
816
 
817
  class TestResourceTemplates:
@@ -860,8 +827,7 @@ class TestResourceTemplates:
860
 
861
  async with Client(mcp) as client:
862
  result = await client.read_resource(AnyUrl("resource://test/data"))
863
- assert isinstance(result[0], TextResourceContents)
864
- assert result[0].text == "Data for test"
865
 
866
  async def test_resource_mismatched_params(self):
867
  """Test that mismatched parameters raise an error"""
@@ -888,8 +854,7 @@ class TestResourceTemplates:
888
  result = await client.read_resource(
889
  AnyUrl("resource://cursor/fastmcp/data")
890
  )
891
- assert isinstance(result[0], TextResourceContents)
892
- assert result[0].text == "Data for cursor/fastmcp"
893
 
894
  async def test_resource_multiple_mismatched_params(self):
895
  """Test that mismatched parameters raise an error"""
@@ -913,8 +878,7 @@ class TestResourceTemplates:
913
 
914
  async with Client(mcp) as client:
915
  result = await client.read_resource(AnyUrl("resource://static"))
916
- assert isinstance(result[0], TextResourceContents)
917
- assert result[0].text == "Static data"
918
 
919
  async def test_template_with_varkwargs(self):
920
  """Test that a template can have **kwargs."""
@@ -926,8 +890,7 @@ class TestResourceTemplates:
926
 
927
  async with Client(mcp) as client:
928
  result = await client.read_resource(AnyUrl("test://1/2/3"))
929
- assert isinstance(result[0], TextResourceContents)
930
- assert result[0].text == "6"
931
 
932
  async def test_template_with_default_params(self):
933
  """Test that a template can have default parameters."""
@@ -946,13 +909,11 @@ class TestResourceTemplates:
946
  # Call the template and verify it uses the default value
947
  async with Client(mcp) as client:
948
  result = await client.read_resource(AnyUrl("math://add/5"))
949
- assert isinstance(result[0], TextResourceContents)
950
- assert result[0].text == "15" # 5 + default 10
951
 
952
  # Can also call with explicit params
953
  result2 = await client.read_resource(AnyUrl("math://add/7"))
954
- assert isinstance(result2[0], TextResourceContents)
955
- assert result2[0].text == "17" # 7 + default 10
956
 
957
  async def test_template_to_resource_conversion(self):
958
  """Test that a template can be converted to a resource."""
@@ -971,8 +932,7 @@ class TestResourceTemplates:
971
  # When accessed, should create a concrete resource
972
  async with Client(mcp) as client:
973
  result = await client.read_resource(AnyUrl("resource://test/data"))
974
- assert isinstance(result[0], TextResourceContents)
975
- assert result[0].text == "Data for test"
976
 
977
  async def test_stacked_resource_template_decorators(self):
978
  """Test that resource template decorators can be stacked."""
@@ -1011,15 +971,15 @@ class TestResourceTemplates:
1011
  email_result = await client.read_resource(
1012
  AnyUrl("users://email/user@example.com")
1013
  )
1014
- assert isinstance(email_result[0], TextResourceContents)
1015
- email_data = json.loads(email_result[0].text)
1016
  assert email_data["lookup"] == "email"
1017
  assert email_data["email"] == "user@example.com"
1018
 
1019
  # Test lookup by name
1020
  name_result = await client.read_resource(AnyUrl("users://name/John"))
1021
- assert isinstance(name_result[0], TextResourceContents)
1022
- name_data = json.loads(name_result[0].text)
1023
  assert name_data["lookup"] == "name"
1024
  assert name_data["name"] == "John"
1025
  assert name_data["email"] == "dummy@example.com"
@@ -1044,8 +1004,7 @@ class TestResourceTemplates:
1044
 
1045
  async with Client(mcp) as client:
1046
  result = await client.read_resource(AnyUrl("resource://test/data"))
1047
- assert isinstance(result[0], TextResourceContents)
1048
- assert result[0].text == "Template resource: test/data"
1049
 
1050
  async def test_templates_match_in_order_of_definition(self):
1051
  """
@@ -1065,12 +1024,10 @@ class TestResourceTemplates:
1065
 
1066
  async with Client(mcp) as client:
1067
  result = await client.read_resource(AnyUrl("resource://a/b/c"))
1068
- assert isinstance(result[0], TextResourceContents)
1069
- assert result[0].text == "Template resource 1: a/b/c"
1070
 
1071
  result = await client.read_resource(AnyUrl("resource://a/b"))
1072
- assert isinstance(result[0], TextResourceContents)
1073
- assert result[0].text == "Template resource 1: a/b"
1074
 
1075
  async def test_templates_shadow_each_other_reorder(self):
1076
  """
@@ -1089,12 +1046,10 @@ class TestResourceTemplates:
1089
 
1090
  async with Client(mcp) as client:
1091
  result = await client.read_resource(AnyUrl("resource://a/b/c"))
1092
- assert isinstance(result[0], TextResourceContents)
1093
- assert result[0].text == "Template resource 2: a/b/c"
1094
 
1095
  result = await client.read_resource(AnyUrl("resource://a/b"))
1096
- assert isinstance(result[0], TextResourceContents)
1097
- assert result[0].text == "Template resource 1: a/b"
1098
 
1099
 
1100
  class TestResourceTemplateContext:
@@ -1108,8 +1063,7 @@ class TestResourceTemplateContext:
1108
 
1109
  async with Client(mcp) as client:
1110
  result = await client.read_resource(AnyUrl("resource://test"))
1111
- assert isinstance(result[0], TextResourceContents)
1112
- assert result[0].text.startswith("Resource template: test 2")
1113
 
1114
  async def test_resource_template_context_with_callable_object(self):
1115
  mcp = FastMCP()
@@ -1122,8 +1076,7 @@ class TestResourceTemplateContext:
1122
 
1123
  async with Client(mcp) as client:
1124
  result = await client.read_resource(AnyUrl("resource://test"))
1125
- assert isinstance(result[0], TextResourceContents)
1126
- assert result[0].text.startswith("Resource template: test 2")
1127
 
1128
 
1129
  class TestPrompts:
@@ -1143,8 +1096,7 @@ class TestPrompts:
1143
  assert prompt.name == "fn"
1144
  # Don't compare functions directly since validate_call wraps them
1145
  content = await prompt.render()
1146
- assert isinstance(content[0].content, TextContent)
1147
- assert content[0].content.text == "Hello, world!"
1148
 
1149
  async def test_prompt_decorator_with_name(self):
1150
  """Test prompt decorator with custom name."""
@@ -1159,8 +1111,7 @@ class TestPrompts:
1159
  prompt = prompts_dict["custom_name"]
1160
  assert prompt.name == "custom_name"
1161
  content = await prompt.render()
1162
- assert isinstance(content[0].content, TextContent)
1163
- assert content[0].content.text == "Hello, world!"
1164
 
1165
  async def test_prompt_decorator_with_description(self):
1166
  """Test prompt decorator with custom description."""
@@ -1175,8 +1126,7 @@ class TestPrompts:
1175
  prompt = prompts_dict["fn"]
1176
  assert prompt.description == "A custom description"
1177
  content = await prompt.render()
1178
- assert isinstance(content[0].content, TextContent)
1179
- assert content[0].content.text == "Hello, world!"
1180
 
1181
  def test_prompt_decorator_error(self):
1182
  """Test error when decorator is used incorrectly."""
@@ -1224,8 +1174,7 @@ class TestPrompts:
1224
  message = result.messages[0]
1225
  assert message.role == "user"
1226
  content = message.content
1227
- assert isinstance(content, TextContent)
1228
- assert content.text == "Hello, World!"
1229
 
1230
  async def test_get_prompt_with_resource(self):
1231
  """Test getting a prompt that returns resource content."""
@@ -1249,10 +1198,10 @@ class TestPrompts:
1249
  result = await client.get_prompt("fn")
1250
  assert result.messages[0].role == "user"
1251
  content = result.messages[0].content
1252
- assert isinstance(content, EmbeddedResource)
1253
  resource = content.resource
1254
- assert isinstance(resource, TextResourceContents)
1255
- assert resource.text == "File contents"
1256
  assert resource.mimeType == "text/plain"
1257
 
1258
  async def test_get_unknown_prompt(self):
@@ -1342,5 +1291,4 @@ class TestPromptContext:
1342
  assert len(result.messages) == 1
1343
  message = result.messages[0]
1344
  assert message.role == "user"
1345
- assert isinstance(message.content, TextContent)
1346
- assert message.content.text == "Hello, World! 2"
 
10
  import pytest
11
  from mcp import McpError
12
  from mcp.types import (
 
13
  ImageContent,
14
  TextContent,
15
  TextResourceContents,
 
76
  async def test_call_tool(self, tool_server: FastMCP):
77
  async with Client(tool_server) as client:
78
  result = await client.call_tool("add", {"x": 1, "y": 2})
79
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
80
 
81
  async def test_call_tool_as_client(self, tool_server: FastMCP):
82
  async with Client(tool_server) as client:
83
  result = await client.call_tool("add", {"x": 1, "y": 2})
84
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
85
 
86
  async def test_call_tool_error(self, tool_server: FastMCP):
87
  async with Client(tool_server) as client:
 
110
  async def test_tool_returns_list(self, tool_server: FastMCP):
111
  async with Client(tool_server) as client:
112
  result = await client.call_tool("list_tool", {})
113
+ assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
 
114
 
115
 
116
  class TestToolReturnTypes:
 
123
 
124
  async with Client(mcp) as client:
125
  result = await client.call_tool("string_tool", {})
126
+ assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
 
127
 
128
  async def test_bytes(self, tmp_path: Path):
129
  mcp = FastMCP()
 
134
 
135
  async with Client(mcp) as client:
136
  result = await client.call_tool("bytes_tool", {})
137
+ assert result[0].text == '"Hello, world!"' # type: ignore[attr-defined]
 
138
 
139
  async def test_uuid(self):
140
  mcp = FastMCP()
 
147
 
148
  async with Client(mcp) as client:
149
  result = await client.call_tool("uuid_tool", {})
150
+ assert result[0].text == pydantic_core.to_json(test_uuid).decode() # type: ignore[attr-defined]
 
151
 
152
  async def test_path(self):
153
  mcp = FastMCP()
 
160
 
161
  async with Client(mcp) as client:
162
  result = await client.call_tool("path_tool", {})
163
+ assert result[0].text == pydantic_core.to_json(test_path).decode() # type: ignore[attr-defined]
 
164
 
165
  async def test_datetime(self):
166
  mcp = FastMCP()
 
173
 
174
  async with Client(mcp) as client:
175
  result = await client.call_tool("datetime_tool", {})
176
+ assert result[0].text == pydantic_core.to_json(dt).decode() # type: ignore[attr-defined]
 
177
 
178
  async def test_image(self, tmp_path: Path):
179
  mcp = FastMCP()
 
328
  async with Client(mcp) as client:
329
  # String with integer value should be coerced to int
330
  result = await client.call_tool("add_one", {"x": "42"})
331
+ assert result[0].text == "43" # type: ignore[attr-defined]
 
332
 
333
  async def test_tool_bool_coercion(self):
334
  """Test string-to-bool type coercion."""
 
341
  async with Client(mcp) as client:
342
  # String with boolean value should be coerced to bool
343
  result = await client.call_tool("toggle", {"flag": "true"})
344
+ assert result[0].text == "false" # type: ignore[attr-defined]
 
345
 
346
  result = await client.call_tool("toggle", {"flag": "false"})
347
+ assert result[0].text == "true" # type: ignore[attr-defined]
 
348
 
349
  async def test_annotated_field_validation(self):
350
  mcp = FastMCP()
 
399
 
400
  async with Client(mcp) as client:
401
  result = await client.call_tool("analyze", {"x": "a"})
402
+ assert result[0].text == "a" # type: ignore[attr-defined]
 
403
 
404
  async def test_enum_type_validation_error(self):
405
  mcp = FastMCP()
 
431
 
432
  async with Client(mcp) as client:
433
  result = await client.call_tool("analyze", {"x": "red"})
434
+ assert result[0].text == "red" # type: ignore[attr-defined]
 
435
 
436
  async def test_union_type_validation(self):
437
  mcp = FastMCP()
 
442
 
443
  async with Client(mcp) as client:
444
  result = await client.call_tool("analyze", {"x": 1})
445
+ assert result[0].text == "1" # type: ignore[attr-defined]
 
446
 
447
  result = await client.call_tool("analyze", {"x": 1.0})
448
+ assert result[0].text == "1.0" # type: ignore[attr-defined]
 
449
 
450
  with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
451
  await client.call_tool("analyze", {"x": "not a number"})
 
463
 
464
  async with Client(mcp) as client:
465
  result = await client.call_tool("send_path", {"path": str(test_path)})
466
+ assert result[0].text == str(test_path) # type: ignore[attr-defined]
 
467
 
468
  async def test_path_type_error(self):
469
  mcp = FastMCP()
 
488
 
489
  async with Client(mcp) as client:
490
  result = await client.call_tool("send_uuid", {"x": test_uuid})
491
+ assert result[0].text == str(test_uuid) # type: ignore[attr-defined]
 
492
 
493
  async def test_uuid_type_error(self):
494
  mcp = FastMCP()
 
512
 
513
  async with Client(mcp) as client:
514
  result = await client.call_tool("send_datetime", {"x": dt})
515
+ assert result[0].text == dt.isoformat() # type: ignore[attr-defined]
 
516
 
517
  async def test_datetime_type_parse_string(self):
518
  mcp = FastMCP()
 
525
  result = await client.call_tool(
526
  "send_datetime", {"x": "2021-01-01T00:00:00"}
527
  )
528
+ assert result[0].text == "2021-01-01T00:00:00" # type: ignore[attr-defined]
 
529
 
530
  async def test_datetime_type_error(self):
531
  mcp = FastMCP()
 
547
 
548
  async with Client(mcp) as client:
549
  result = await client.call_tool("send_date", {"x": datetime.date.today()})
550
+ assert result[0].text == datetime.date.today().isoformat() # type: ignore[attr-defined]
 
551
 
552
  async def test_date_type_parse_string(self):
553
  mcp = FastMCP()
 
558
 
559
  async with Client(mcp) as client:
560
  result = await client.call_tool("send_date", {"x": "2021-01-01"})
561
+ assert result[0].text == "2021-01-01" # type: ignore[attr-defined]
 
562
 
563
  async def test_timedelta_type(self):
564
  mcp = FastMCP()
 
571
  result = await client.call_tool(
572
  "send_timedelta", {"x": datetime.timedelta(days=1)}
573
  )
574
+ assert result[0].text == "1 day, 0:00:00" # type: ignore[attr-defined]
 
575
 
576
  async def test_timedelta_type_parse_int(self):
577
  mcp = FastMCP()
 
582
 
583
  async with Client(mcp) as client:
584
  result = await client.call_tool("send_timedelta", {"x": 1000})
585
+ assert result[0].text == "0:16:40" # type: ignore[attr-defined]
 
586
 
587
 
588
  class TestToolContextInjection:
 
615
  result = await client.call_tool("tool_with_context", {"x": 42})
616
  assert len(result) == 1
617
  content = result[0]
618
+ assert content.text == "2" # type: ignore[attr-defined]
619
 
620
  async def test_async_context(self):
621
  """Test that context works in async functions."""
 
630
  result = await client.call_tool("async_tool", {"x": 42})
631
  assert len(result) == 1
632
  content = result[0]
633
+ assert content.text == "Async request 2: 42" # type: ignore[attr-defined]
 
634
 
635
  async def test_optional_context(self):
636
  """Test that context is optional."""
 
644
  result = await client.call_tool("no_context", {"x": 21})
645
  assert len(result) == 1
646
  content = result[0]
647
+ assert content.text == "42" # type: ignore[attr-defined]
 
648
 
649
  async def test_context_resource_access(self):
650
  """Test that context can access resources."""
 
666
  result = await client.call_tool("tool_with_resource", {})
667
  assert len(result) == 1
668
  content = result[0]
669
+ assert "Read resource: resource data" in content.text # type: ignore[attr-defined]
 
670
 
671
  async def test_tool_decorator_with_tags(self):
672
  """Test that the tool decorator properly sets tags."""
 
694
 
695
  async with Client(mcp) as client:
696
  result = await client.call_tool("MyTool", {"x": 2})
697
+ assert result[0].text == "4" # type: ignore[attr-defined]
 
698
 
699
 
700
  class TestResource:
 
711
 
712
  async with Client(mcp) as client:
713
  result = await client.read_resource(AnyUrl("resource://test"))
714
+ assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
 
715
 
716
  async def test_binary_resource(self):
717
  mcp = FastMCP()
 
729
 
730
  async with Client(mcp) as client:
731
  result = await client.read_resource(AnyUrl("resource://binary"))
732
+ assert result[0].blob == base64.b64encode(b"Binary data").decode() # type: ignore[attr-defined]
 
733
 
734
  async def test_file_resource_text(self, tmp_path: Path):
735
  mcp = FastMCP()
 
745
 
746
  async with Client(mcp) as client:
747
  result = await client.read_resource(AnyUrl("file://test.txt"))
748
+ assert result[0].text == "Hello from file!" # type: ignore[attr-defined]
 
749
 
750
  async def test_file_resource_binary(self, tmp_path: Path):
751
  mcp = FastMCP()
 
764
 
765
  async with Client(mcp) as client:
766
  result = await client.read_resource(AnyUrl("file://test.bin"))
767
+ assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
 
768
 
769
 
770
  class TestResourceContext:
 
778
 
779
  async with Client(mcp) as client:
780
  result = await client.read_resource(AnyUrl("resource://test"))
781
+ assert result[0].text == "2" # type: ignore[attr-defined]
 
782
 
783
 
784
  class TestResourceTemplates:
 
827
 
828
  async with Client(mcp) as client:
829
  result = await client.read_resource(AnyUrl("resource://test/data"))
830
+ assert result[0].text == "Data for test" # type: ignore[attr-defined]
 
831
 
832
  async def test_resource_mismatched_params(self):
833
  """Test that mismatched parameters raise an error"""
 
854
  result = await client.read_resource(
855
  AnyUrl("resource://cursor/fastmcp/data")
856
  )
857
+ assert result[0].text == "Data for cursor/fastmcp" # type: ignore[attr-defined]
 
858
 
859
  async def test_resource_multiple_mismatched_params(self):
860
  """Test that mismatched parameters raise an error"""
 
878
 
879
  async with Client(mcp) as client:
880
  result = await client.read_resource(AnyUrl("resource://static"))
881
+ assert result[0].text == "Static data" # type: ignore[attr-defined]
 
882
 
883
  async def test_template_with_varkwargs(self):
884
  """Test that a template can have **kwargs."""
 
890
 
891
  async with Client(mcp) as client:
892
  result = await client.read_resource(AnyUrl("test://1/2/3"))
893
+ assert result[0].text == "6" # type: ignore[attr-defined]
 
894
 
895
  async def test_template_with_default_params(self):
896
  """Test that a template can have default parameters."""
 
909
  # Call the template and verify it uses the default value
910
  async with Client(mcp) as client:
911
  result = await client.read_resource(AnyUrl("math://add/5"))
912
+ assert result[0].text == "15" # type: ignore[attr-defined]
 
913
 
914
  # Can also call with explicit params
915
  result2 = await client.read_resource(AnyUrl("math://add/7"))
916
+ assert result2[0].text == "17" # type: ignore[attr-defined]
 
917
 
918
  async def test_template_to_resource_conversion(self):
919
  """Test that a template can be converted to a resource."""
 
932
  # When accessed, should create a concrete resource
933
  async with Client(mcp) as client:
934
  result = await client.read_resource(AnyUrl("resource://test/data"))
935
+ assert result[0].text == "Data for test" # type: ignore[attr-defined]
 
936
 
937
  async def test_stacked_resource_template_decorators(self):
938
  """Test that resource template decorators can be stacked."""
 
971
  email_result = await client.read_resource(
972
  AnyUrl("users://email/user@example.com")
973
  )
974
+ assert email_result[0].text # type: ignore[attr-defined]
975
+ email_data = json.loads(email_result[0].text) # type: ignore[attr-defined]
976
  assert email_data["lookup"] == "email"
977
  assert email_data["email"] == "user@example.com"
978
 
979
  # Test lookup by name
980
  name_result = await client.read_resource(AnyUrl("users://name/John"))
981
+ assert name_result[0].text # type: ignore[attr-defined]
982
+ name_data = json.loads(name_result[0].text) # type: ignore[attr-defined]
983
  assert name_data["lookup"] == "name"
984
  assert name_data["name"] == "John"
985
  assert name_data["email"] == "dummy@example.com"
 
1004
 
1005
  async with Client(mcp) as client:
1006
  result = await client.read_resource(AnyUrl("resource://test/data"))
1007
+ assert result[0].text == "Template resource: test/data" # type: ignore[attr-defined]
 
1008
 
1009
  async def test_templates_match_in_order_of_definition(self):
1010
  """
 
1024
 
1025
  async with Client(mcp) as client:
1026
  result = await client.read_resource(AnyUrl("resource://a/b/c"))
1027
+ assert result[0].text == "Template resource 1: a/b/c" # type: ignore[attr-defined]
 
1028
 
1029
  result = await client.read_resource(AnyUrl("resource://a/b"))
1030
+ assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
 
1031
 
1032
  async def test_templates_shadow_each_other_reorder(self):
1033
  """
 
1046
 
1047
  async with Client(mcp) as client:
1048
  result = await client.read_resource(AnyUrl("resource://a/b/c"))
1049
+ assert result[0].text == "Template resource 2: a/b/c" # type: ignore[attr-defined]
 
1050
 
1051
  result = await client.read_resource(AnyUrl("resource://a/b"))
1052
+ assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
 
1053
 
1054
 
1055
  class TestResourceTemplateContext:
 
1063
 
1064
  async with Client(mcp) as client:
1065
  result = await client.read_resource(AnyUrl("resource://test"))
1066
+ assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined]
 
1067
 
1068
  async def test_resource_template_context_with_callable_object(self):
1069
  mcp = FastMCP()
 
1076
 
1077
  async with Client(mcp) as client:
1078
  result = await client.read_resource(AnyUrl("resource://test"))
1079
+ assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined]
 
1080
 
1081
 
1082
  class TestPrompts:
 
1096
  assert prompt.name == "fn"
1097
  # Don't compare functions directly since validate_call wraps them
1098
  content = await prompt.render()
1099
+ assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
 
1100
 
1101
  async def test_prompt_decorator_with_name(self):
1102
  """Test prompt decorator with custom name."""
 
1111
  prompt = prompts_dict["custom_name"]
1112
  assert prompt.name == "custom_name"
1113
  content = await prompt.render()
1114
+ assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
 
1115
 
1116
  async def test_prompt_decorator_with_description(self):
1117
  """Test prompt decorator with custom description."""
 
1126
  prompt = prompts_dict["fn"]
1127
  assert prompt.description == "A custom description"
1128
  content = await prompt.render()
1129
+ assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
 
1130
 
1131
  def test_prompt_decorator_error(self):
1132
  """Test error when decorator is used incorrectly."""
 
1174
  message = result.messages[0]
1175
  assert message.role == "user"
1176
  content = message.content
1177
+ assert content.text == "Hello, World!" # type: ignore[attr-defined]
 
1178
 
1179
  async def test_get_prompt_with_resource(self):
1180
  """Test getting a prompt that returns resource content."""
 
1198
  result = await client.get_prompt("fn")
1199
  assert result.messages[0].role == "user"
1200
  content = result.messages[0].content
1201
+ assert isinstance(content, EmbeddedResource) # type: ignore[attr-defined]
1202
  resource = content.resource
1203
+ assert isinstance(resource, TextResourceContents) # type: ignore[attr-defined]
1204
+ assert resource.text == "File contents" # type: ignore[attr-defined]
1205
  assert resource.mimeType == "text/plain"
1206
 
1207
  async def test_get_unknown_prompt(self):
 
1291
  assert len(result.messages) == 1
1292
  message = result.messages[0]
1293
  assert message.role == "user"
1294
+ assert message.content.text == "Hello, World! 2" # type: ignore[attr-defined]
 
tests/server/test_tool_annotations.py CHANGED
@@ -1,6 +1,6 @@
1
  from typing import Any
2
 
3
- from mcp.types import TextContent, ToolAnnotations
4
 
5
  from fastmcp import Client, FastMCP
6
 
@@ -212,8 +212,7 @@ async def test_tool_functionality_with_annotations():
212
  "create_item", {"name": "test_item", "value": 42}
213
  )
214
  assert len(result) == 1
215
- assert isinstance(result[0], TextContent)
216
 
217
  # The result should contain the expected JSON
218
- assert '"name": "test_item"' in result[0].text
219
- assert '"value": 42' in result[0].text
 
1
  from typing import Any
2
 
3
+ from mcp.types import ToolAnnotations
4
 
5
  from fastmcp import Client, FastMCP
6
 
 
212
  "create_item", {"name": "test_item", "value": 42}
213
  )
214
  assert len(result) == 1
 
215
 
216
  # The result should contain the expected JSON
217
+ assert '"name": "test_item"' in result[0].text # type: ignore[attr-defined]
218
+ assert '"value": 42' in result[0].text # type: ignore[attr-defined]
tests/test_examples.py CHANGED
@@ -1,10 +1,5 @@
1
  """Tests for example servers"""
2
 
3
- from mcp.types import (
4
- PromptMessage,
5
- TextContent,
6
- TextResourceContents,
7
- )
8
  from pydantic import AnyUrl
9
 
10
  from fastmcp import Client
@@ -17,8 +12,7 @@ async def test_simple_echo():
17
  async with Client(mcp) as client:
18
  result = await client.call_tool("echo", {"text": "hello"})
19
  assert len(result) == 1
20
- assert isinstance(result[0], TextContent)
21
- assert result[0].text == "hello"
22
 
23
 
24
  async def test_complex_inputs():
@@ -31,8 +25,7 @@ async def test_complex_inputs():
31
  "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
32
  )
33
  assert len(result) == 1
34
- assert isinstance(result[0], TextContent)
35
- assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]'
36
 
37
 
38
  async def test_desktop(monkeypatch):
@@ -43,15 +36,12 @@ async def test_desktop(monkeypatch):
43
  # Test the add function
44
  result = await client.call_tool("add", {"a": 1, "b": 2})
45
  assert len(result) == 1
46
- assert isinstance(result[0], TextContent)
47
- assert result[0].text == "3"
48
 
49
  async with Client(mcp) as client:
50
  result = await client.read_resource(AnyUrl("greeting://rooter12"))
51
  assert len(result) == 1
52
- assert isinstance(result[0], TextResourceContents)
53
- assert isinstance(result[0].text, str)
54
- assert result[0].text == "Hello, rooter12!"
55
 
56
 
57
  async def test_echo():
@@ -61,27 +51,19 @@ async def test_echo():
61
  async with Client(mcp) as client:
62
  result = await client.call_tool("echo_tool", {"text": "hello"})
63
  assert len(result) == 1
64
- assert isinstance(result[0], TextContent)
65
- assert result[0].text == "hello"
66
 
67
  async with Client(mcp) as client:
68
  result = await client.read_resource(AnyUrl("echo://static"))
69
  assert len(result) == 1
70
- assert isinstance(result[0], TextResourceContents)
71
- assert isinstance(result[0].text, str)
72
- assert result[0].text == "Echo!"
73
 
74
  async with Client(mcp) as client:
75
  result = await client.read_resource(AnyUrl("echo://server42"))
76
  assert len(result) == 1
77
- assert isinstance(result[0], TextResourceContents)
78
- assert isinstance(result[0].text, str)
79
- assert result[0].text == "Echo: server42"
80
 
81
  async with Client(mcp) as client:
82
  result = await client.get_prompt("echo", {"text": "hello"})
83
  assert len(result.messages) == 1
84
- assert isinstance(result.messages[0], PromptMessage)
85
- assert isinstance(result.messages[0].content, TextContent)
86
- assert isinstance(result.messages[0].content.text, str)
87
- assert result.messages[0].content.text == "hello"
 
1
  """Tests for example servers"""
2
 
 
 
 
 
 
3
  from pydantic import AnyUrl
4
 
5
  from fastmcp import Client
 
12
  async with Client(mcp) as client:
13
  result = await client.call_tool("echo", {"text": "hello"})
14
  assert len(result) == 1
15
+ assert result[0].text == "hello" # type: ignore[attr-defined]
 
16
 
17
 
18
  async def test_complex_inputs():
 
25
  "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
26
  )
27
  assert len(result) == 1
28
+ assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
 
29
 
30
 
31
  async def test_desktop(monkeypatch):
 
36
  # Test the add function
37
  result = await client.call_tool("add", {"a": 1, "b": 2})
38
  assert len(result) == 1
39
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
40
 
41
  async with Client(mcp) as client:
42
  result = await client.read_resource(AnyUrl("greeting://rooter12"))
43
  assert len(result) == 1
44
+ assert result[0].text == "Hello, rooter12!" # type: ignore[attr-defined]
 
 
45
 
46
 
47
  async def test_echo():
 
51
  async with Client(mcp) as client:
52
  result = await client.call_tool("echo_tool", {"text": "hello"})
53
  assert len(result) == 1
54
+ assert result[0].text == "hello" # type: ignore[attr-defined]
 
55
 
56
  async with Client(mcp) as client:
57
  result = await client.read_resource(AnyUrl("echo://static"))
58
  assert len(result) == 1
59
+ assert result[0].text == "Echo!" # type: ignore[attr-defined]
 
 
60
 
61
  async with Client(mcp) as client:
62
  result = await client.read_resource(AnyUrl("echo://server42"))
63
  assert len(result) == 1
64
+ assert result[0].text == "Echo: server42" # type: ignore[attr-defined]
 
 
65
 
66
  async with Client(mcp) as client:
67
  result = await client.get_prompt("echo", {"text": "hello"})
68
  assert len(result.messages) == 1
69
+ assert result.messages[0].content.text == "hello" # type: ignore[attr-defined]
 
 
 
tests/tools/test_tool.py CHANGED
@@ -1,5 +1,5 @@
1
  import pytest
2
- from mcp.types import ImageContent, TextContent
3
  from pydantic import BaseModel
4
 
5
  from fastmcp import FastMCP, Image
@@ -209,9 +209,7 @@ class TestLegacyToolJsonParsing:
209
 
210
  # Run the tool which will do JSON parsing
211
  result = await tool.run(json_args)
212
- assert len(result) == 1
213
- assert isinstance(result[0], TextContent)
214
- assert result[0].text == "1-a,b,c"
215
 
216
  async def test_str_vs_list_str(self):
217
  """Test handling of string vs list[str] type annotations."""
@@ -223,23 +221,17 @@ class TestLegacyToolJsonParsing:
223
 
224
  # Test regular string input (should remain a string)
225
  result = await tool.run({"str_or_list": "hello"})
226
- assert len(result) == 1
227
- assert isinstance(result[0], TextContent)
228
- assert result[0].text == "hello"
229
 
230
  # Test JSON string input (should be parsed as a string)
231
  result = await tool.run({"str_or_list": '"hello"'})
232
- assert len(result) == 1
233
- assert isinstance(result[0], TextContent)
234
- assert result[0].text == "hello"
235
 
236
  # Test JSON list input (should be parsed as a list)
237
  result = await tool.run({"str_or_list": '["hello", "world"]'})
238
- assert len(result) == 1
239
- assert isinstance(result[0], TextContent)
240
 
241
  # The exact formatting might vary, so we just check that it contains the key elements
242
- text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "")
243
  assert "hello" in text_without_whitespace
244
  assert "world" in text_without_whitespace
245
  assert "[" in text_without_whitespace
@@ -256,9 +248,7 @@ class TestLegacyToolJsonParsing:
256
  # Invalid JSON should remain a string
257
  invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
258
  result = await tool.run({"string": invalid_json})
259
- assert len(result) == 1
260
- assert isinstance(result[0], TextContent)
261
- assert result[0].text == invalid_json
262
 
263
  async def test_keep_str_union_as_str(self):
264
  """Test that string arguments are kept as strings when parsing would create an invalid value"""
@@ -273,9 +263,7 @@ class TestLegacyToolJsonParsing:
273
  # Invalid JSON for the union type should remain a string
274
  invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
275
  result = await tool.run({"string": invalid_json})
276
- assert len(result) == 1
277
- assert isinstance(result[0], TextContent)
278
- assert result[0].text == invalid_json
279
 
280
  async def test_complex_type_validation(self):
281
  """Test that parsed JSON is validated against complex types"""
@@ -292,11 +280,9 @@ class TestLegacyToolJsonParsing:
292
  # Valid JSON for the model
293
  valid_json = '{"x": 1, "y": {"1": "hello"}}'
294
  result = await tool.run({"data": valid_json})
295
- assert len(result) == 1
296
- assert isinstance(result[0], TextContent)
297
- assert '"x": 1' in result[0].text
298
- assert '"y": {' in result[0].text
299
- assert '"1": "hello"' in result[0].text
300
 
301
  # Invalid JSON for the model (y has string keys, not int keys)
302
  # Should throw a validation error
@@ -317,8 +303,7 @@ class TestLegacyToolJsonParsing:
317
  result = await client.call_tool(
318
  "process_list", {"items": "[1, 2, 3, 4, 5]"}
319
  )
320
- assert isinstance(result[0], TextContent)
321
- assert result[0].text == "15"
322
 
323
  async def test_tool_list_coercion_error(self):
324
  """Test that a list coercion error is raised if the input is not a valid list."""
@@ -348,8 +333,7 @@ class TestLegacyToolJsonParsing:
348
  result = await client.call_tool(
349
  "process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'}
350
  )
351
- assert isinstance(result[0], TextContent)
352
- assert result[0].text == "6"
353
 
354
  async def test_tool_set_coercion(self):
355
  """Test JSON string to set type coercion."""
@@ -362,8 +346,7 @@ class TestLegacyToolJsonParsing:
362
 
363
  async with Client(mcp) as client:
364
  result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
365
- assert isinstance(result[0], TextContent)
366
- assert result[0].text == "15"
367
 
368
  async def test_tool_tuple_coercion(self):
369
  """Test JSON string to tuple type coercion."""
@@ -376,5 +359,4 @@ class TestLegacyToolJsonParsing:
376
 
377
  async with Client(mcp) as client:
378
  result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
379
- assert isinstance(result[0], TextContent)
380
- assert result[0].text == "4"
 
1
  import pytest
2
+ from mcp.types import ImageContent
3
  from pydantic import BaseModel
4
 
5
  from fastmcp import FastMCP, Image
 
209
 
210
  # Run the tool which will do JSON parsing
211
  result = await tool.run(json_args)
212
+ assert result[0].text == "1-a,b,c" # type: ignore[attr-dict]
 
 
213
 
214
  async def test_str_vs_list_str(self):
215
  """Test handling of string vs list[str] type annotations."""
 
221
 
222
  # Test regular string input (should remain a string)
223
  result = await tool.run({"str_or_list": "hello"})
224
+ assert result[0].text == "hello" # type: ignore[attr-dict]
 
 
225
 
226
  # Test JSON string input (should be parsed as a string)
227
  result = await tool.run({"str_or_list": '"hello"'})
228
+ assert result[0].text == "hello" # type: ignore[attr-dict]
 
 
229
 
230
  # Test JSON list input (should be parsed as a list)
231
  result = await tool.run({"str_or_list": '["hello", "world"]'})
 
 
232
 
233
  # The exact formatting might vary, so we just check that it contains the key elements
234
+ text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "") # type: ignore[attr-dict]
235
  assert "hello" in text_without_whitespace
236
  assert "world" in text_without_whitespace
237
  assert "[" in text_without_whitespace
 
248
  # Invalid JSON should remain a string
249
  invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
250
  result = await tool.run({"string": invalid_json})
251
+ assert result[0].text == invalid_json # type: ignore[attr-dict]
 
 
252
 
253
  async def test_keep_str_union_as_str(self):
254
  """Test that string arguments are kept as strings when parsing would create an invalid value"""
 
263
  # Invalid JSON for the union type should remain a string
264
  invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
265
  result = await tool.run({"string": invalid_json})
266
+ assert result[0].text == invalid_json # type: ignore[attr-dict]
 
 
267
 
268
  async def test_complex_type_validation(self):
269
  """Test that parsed JSON is validated against complex types"""
 
280
  # Valid JSON for the model
281
  valid_json = '{"x": 1, "y": {"1": "hello"}}'
282
  result = await tool.run({"data": valid_json})
283
+ assert '"x": 1' in result[0].text # type: ignore[attr-dict]
284
+ assert '"y": {' in result[0].text # type: ignore[attr-dict]
285
+ assert '"1": "hello"' in result[0].text # type: ignore[attr-dict]
 
 
286
 
287
  # Invalid JSON for the model (y has string keys, not int keys)
288
  # Should throw a validation error
 
303
  result = await client.call_tool(
304
  "process_list", {"items": "[1, 2, 3, 4, 5]"}
305
  )
306
+ assert result[0].text == "15" # type: ignore[attr-dict]
 
307
 
308
  async def test_tool_list_coercion_error(self):
309
  """Test that a list coercion error is raised if the input is not a valid list."""
 
333
  result = await client.call_tool(
334
  "process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'}
335
  )
336
+ assert result[0].text == "6" # type: ignore[attr-dict]
 
337
 
338
  async def test_tool_set_coercion(self):
339
  """Test JSON string to set type coercion."""
 
346
 
347
  async with Client(mcp) as client:
348
  result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
349
+ assert result[0].text == "15" # type: ignore[attr-dict]
 
350
 
351
  async def test_tool_tuple_coercion(self):
352
  """Test JSON string to tuple type coercion."""
 
359
 
360
  async with Client(mcp) as client:
361
  result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
362
+ assert result[0].text == "4" # type: ignore[attr-dict]
 
tests/tools/test_tool_manager.py CHANGED
@@ -5,7 +5,7 @@ from typing import Annotated, Any
5
 
6
  import pydantic_core
7
  import pytest
8
- from mcp.types import ImageContent, TextContent
9
  from pydantic import BaseModel
10
 
11
  from fastmcp import Context, FastMCP, Image
@@ -318,13 +318,8 @@ class TestCallTools:
318
  manager = ToolManager()
319
  manager.add_tool_from_fn(add)
320
  result = await manager.call_tool("add", {"a": 1, "b": 2})
321
- assert isinstance(result, list)
322
- assert len(result) == 1
323
- from mcp.types import TextContent
324
 
325
- assert isinstance(result[0], TextContent)
326
- assert result[0].text == "3"
327
- assert json.loads(result[0].text) == 3
328
 
329
  async def test_call_async_tool(self):
330
  async def double(n: int) -> int:
@@ -334,12 +329,7 @@ class TestCallTools:
334
  manager = ToolManager()
335
  manager.add_tool_from_fn(double)
336
  result = await manager.call_tool("double", {"n": 5})
337
- assert isinstance(result, list)
338
- assert len(result) == 1
339
-
340
- assert isinstance(result[0], TextContent)
341
- assert result[0].text == "10"
342
- assert json.loads(result[0].text) == 10
343
 
344
  async def test_call_tool_callable_object(self):
345
  class Adder:
@@ -352,11 +342,7 @@ class TestCallTools:
352
  manager = ToolManager()
353
  manager.add_tool_from_fn(Adder())
354
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
355
- assert isinstance(result, list)
356
- assert len(result) == 1
357
- assert isinstance(result[0], TextContent)
358
- assert result[0].text == "3"
359
- assert json.loads(result[0].text) == 3
360
 
361
  async def test_call_tool_callable_object_async(self):
362
  class Adder:
@@ -369,11 +355,7 @@ class TestCallTools:
369
  manager = ToolManager()
370
  manager.add_tool_from_fn(Adder())
371
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
372
- assert isinstance(result, list)
373
- assert len(result) == 1
374
- assert isinstance(result[0], TextContent)
375
- assert result[0].text == "3"
376
- assert json.loads(result[0].text) == 3
377
 
378
  async def test_call_tool_with_default_args(self):
379
  def add(a: int, b: int = 1) -> int:
@@ -383,12 +365,8 @@ class TestCallTools:
383
  manager = ToolManager()
384
  manager.add_tool_from_fn(add)
385
  result = await manager.call_tool("add", {"a": 1})
386
- assert isinstance(result, list)
387
- assert len(result) == 1
388
 
389
- assert isinstance(result[0], TextContent)
390
- assert result[0].text == "2"
391
- assert json.loads(result[0].text) == 2
392
 
393
  async def test_call_tool_with_missing_args(self):
394
  def add(a: int, b: int) -> int:
@@ -413,11 +391,7 @@ class TestCallTools:
413
  manager.add_tool_from_fn(sum_vals)
414
 
415
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
416
- assert isinstance(result, list)
417
- assert len(result) == 1
418
- assert isinstance(result[0], TextContent)
419
- assert result[0].text == "6"
420
- assert json.loads(result[0].text) == 6
421
 
422
  async def test_call_tool_with_list_int_input_legacy_behavior(self):
423
  """Legacy behavior -- parse a stringified JSON object"""
@@ -431,11 +405,7 @@ class TestCallTools:
431
 
432
  with temporary_settings(tool_attempt_parse_json_args=True):
433
  result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
434
- assert isinstance(result, list)
435
- assert len(result) == 1
436
- assert isinstance(result[0], TextContent)
437
- assert result[0].text == "6"
438
- assert json.loads(result[0].text) == 6
439
 
440
  async def test_call_tool_with_list_str_or_str_input(self):
441
  def concat_strs(vals: list[str] | str) -> str:
@@ -446,16 +416,10 @@ class TestCallTools:
446
 
447
  # Try both with plain python object and with JSON list
448
  result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
449
- assert isinstance(result, list)
450
- assert len(result) == 1
451
- assert isinstance(result[0], TextContent)
452
- assert result[0].text == "abc"
453
 
454
  result = await manager.call_tool("concat_strs", {"vals": "a"})
455
- assert isinstance(result, list)
456
- assert len(result) == 1
457
- assert isinstance(result[0], TextContent)
458
- assert result[0].text == "a"
459
 
460
  async def test_call_tool_with_list_str_or_str_input_legacy_behavior(self):
461
  """Legacy behavior -- parse a stringified JSON object"""
@@ -468,16 +432,10 @@ class TestCallTools:
468
 
469
  with temporary_settings(tool_attempt_parse_json_args=True):
470
  result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
471
- assert isinstance(result, list)
472
- assert len(result) == 1
473
- assert isinstance(result[0], TextContent)
474
- assert result[0].text == "abc"
475
 
476
  result = await manager.call_tool("concat_strs", {"vals": '"a"'})
477
- assert isinstance(result, list)
478
- assert len(result) == 1
479
- assert isinstance(result[0], TextContent)
480
- assert result[0].text == "a"
481
 
482
  async def test_call_tool_with_complex_model(self):
483
  class MyShrimpTank(BaseModel):
@@ -507,10 +465,7 @@ class TestCallTools:
507
  },
508
  )
509
 
510
- assert isinstance(result, list)
511
- assert len(result) == 1
512
- assert isinstance(result[0], TextContent)
513
- assert result[0].text == '[\n "rex",\n "gertrude"\n]'
514
 
515
  async def test_call_tool_with_custom_serializer(self):
516
  """Test that a custom serializer provided to FastMCP is used by tools."""
@@ -530,10 +485,7 @@ class TestCallTools:
530
  manager.add_tool_from_fn(get_data)
531
 
532
  result = await manager.call_tool("get_data", {})
533
- assert isinstance(result, list)
534
- assert len(result) == 1
535
- assert isinstance(result[0], TextContent)
536
- assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}'
537
 
538
  async def test_call_tool_with_list_result_custom_serializer(self):
539
  """Test that a custom serializer provided to FastMCP is used by tools that return lists."""
@@ -555,12 +507,9 @@ class TestCallTools:
555
  manager.add_tool_from_fn(get_data)
556
 
557
  result = await manager.call_tool("get_data", {})
558
- assert isinstance(result, list)
559
- assert len(result) == 1
560
- assert isinstance(result[0], TextContent)
561
  assert (
562
- result[0].text
563
- == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]'
564
  )
565
 
566
  async def test_custom_serializer_fallback_on_error(self):
@@ -580,10 +529,7 @@ class TestCallTools:
580
  manager.add_tool_from_fn(get_data)
581
 
582
  result = await manager.call_tool("get_data", {})
583
- assert isinstance(result, list)
584
- assert len(result) == 1
585
- assert isinstance(result[0], TextContent)
586
- assert result[0].text == pydantic_core.to_json(uuid_result).decode()
587
 
588
 
589
  class TestToolSchema:
@@ -648,10 +594,7 @@ class TestContextHandling:
648
 
649
  with context:
650
  result = await manager.call_tool("tool_with_context", {"x": 42})
651
- assert isinstance(result, list)
652
- assert len(result) == 1
653
- assert isinstance(result[0], TextContent)
654
- assert result[0].text == "42"
655
 
656
  async def test_context_injection_async(self):
657
  """Test that context is properly injected in async tools."""
@@ -668,14 +611,10 @@ class TestContextHandling:
668
 
669
  with context:
670
  result = await manager.call_tool("async_tool", {"x": 42})
671
- assert isinstance(result, list)
672
- assert len(result) == 1
673
- assert isinstance(result[0], TextContent)
674
- assert result[0].text == "42"
675
 
676
  async def test_context_optional(self):
677
  """Test that context is optional when calling tools."""
678
- from mcp.types import TextContent
679
 
680
  def tool_with_context(x: int, ctx: Context | None) -> int:
681
  return x
@@ -689,10 +628,7 @@ class TestContextHandling:
689
 
690
  with context:
691
  result = await manager.call_tool("tool_with_context", {"x": 42})
692
- assert isinstance(result, list)
693
- assert len(result) == 1
694
- assert isinstance(result[0], TextContent)
695
- assert result[0].text == "42"
696
 
697
  def test_parameterized_context_parameter_detection(self):
698
  """Test that context parameters are properly detected in
@@ -782,7 +718,6 @@ class TestCustomToolNames:
782
 
783
  async def test_call_tool_with_custom_name(self):
784
  """Test calling a tool added with a custom name."""
785
- from mcp.types import TextContent
786
 
787
  def multiply(a: int, b: int) -> int:
788
  """Multiply two numbers."""
@@ -793,11 +728,7 @@ class TestCustomToolNames:
793
 
794
  # Tool should be callable by its custom name
795
  result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
796
- assert isinstance(result, list)
797
- assert len(result) == 1
798
- assert isinstance(result[0], TextContent)
799
- assert result[0].text == "15"
800
- assert json.loads(result[0].text) == 15
801
 
802
  # Original name should not be registered
803
  with pytest.raises(NotFoundError, match="Unknown tool: multiply"):
 
5
 
6
  import pydantic_core
7
  import pytest
8
+ from mcp.types import ImageContent
9
  from pydantic import BaseModel
10
 
11
  from fastmcp import Context, FastMCP, Image
 
318
  manager = ToolManager()
319
  manager.add_tool_from_fn(add)
320
  result = await manager.call_tool("add", {"a": 1, "b": 2})
 
 
 
321
 
322
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
 
323
 
324
  async def test_call_async_tool(self):
325
  async def double(n: int) -> int:
 
329
  manager = ToolManager()
330
  manager.add_tool_from_fn(double)
331
  result = await manager.call_tool("double", {"n": 5})
332
+ assert result[0].text == "10" # type: ignore[attr-defined]
 
 
 
 
 
333
 
334
  async def test_call_tool_callable_object(self):
335
  class Adder:
 
342
  manager = ToolManager()
343
  manager.add_tool_from_fn(Adder())
344
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
345
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
 
 
 
346
 
347
  async def test_call_tool_callable_object_async(self):
348
  class Adder:
 
355
  manager = ToolManager()
356
  manager.add_tool_from_fn(Adder())
357
  result = await manager.call_tool("Adder", {"x": 1, "y": 2})
358
+ assert result[0].text == "3" # type: ignore[attr-defined]
 
 
 
 
359
 
360
  async def test_call_tool_with_default_args(self):
361
  def add(a: int, b: int = 1) -> int:
 
365
  manager = ToolManager()
366
  manager.add_tool_from_fn(add)
367
  result = await manager.call_tool("add", {"a": 1})
 
 
368
 
369
+ assert result[0].text == "2" # type: ignore[attr-defined]
 
 
370
 
371
  async def test_call_tool_with_missing_args(self):
372
  def add(a: int, b: int) -> int:
 
391
  manager.add_tool_from_fn(sum_vals)
392
 
393
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
394
+ assert result[0].text == "6" # type: ignore[attr-defined]
 
 
 
 
395
 
396
  async def test_call_tool_with_list_int_input_legacy_behavior(self):
397
  """Legacy behavior -- parse a stringified JSON object"""
 
405
 
406
  with temporary_settings(tool_attempt_parse_json_args=True):
407
  result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
408
+ assert result[0].text == "6" # type: ignore[attr-defined]
 
 
 
 
409
 
410
  async def test_call_tool_with_list_str_or_str_input(self):
411
  def concat_strs(vals: list[str] | str) -> str:
 
416
 
417
  # Try both with plain python object and with JSON list
418
  result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
419
+ assert result[0].text == "abc" # type: ignore[attr-defined]
 
 
 
420
 
421
  result = await manager.call_tool("concat_strs", {"vals": "a"})
422
+ assert result[0].text == "a" # type: ignore[attr-defined]
 
 
 
423
 
424
  async def test_call_tool_with_list_str_or_str_input_legacy_behavior(self):
425
  """Legacy behavior -- parse a stringified JSON object"""
 
432
 
433
  with temporary_settings(tool_attempt_parse_json_args=True):
434
  result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
435
+ assert result[0].text == "abc" # type: ignore[attr-defined]
 
 
 
436
 
437
  result = await manager.call_tool("concat_strs", {"vals": '"a"'})
438
+ assert result[0].text == "a" # type: ignore[attr-defined]
 
 
 
439
 
440
  async def test_call_tool_with_complex_model(self):
441
  class MyShrimpTank(BaseModel):
 
465
  },
466
  )
467
 
468
+ assert result[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
 
 
 
469
 
470
  async def test_call_tool_with_custom_serializer(self):
471
  """Test that a custom serializer provided to FastMCP is used by tools."""
 
485
  manager.add_tool_from_fn(get_data)
486
 
487
  result = await manager.call_tool("get_data", {})
488
+ assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
 
 
 
489
 
490
  async def test_call_tool_with_list_result_custom_serializer(self):
491
  """Test that a custom serializer provided to FastMCP is used by tools that return lists."""
 
507
  manager.add_tool_from_fn(get_data)
508
 
509
  result = await manager.call_tool("get_data", {})
 
 
 
510
  assert (
511
+ result[0].text # type: ignore[attr-defined]
512
+ == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
513
  )
514
 
515
  async def test_custom_serializer_fallback_on_error(self):
 
529
  manager.add_tool_from_fn(get_data)
530
 
531
  result = await manager.call_tool("get_data", {})
532
+ assert result[0].text == pydantic_core.to_json(uuid_result).decode() # type: ignore[attr-defined]
 
 
 
533
 
534
 
535
  class TestToolSchema:
 
594
 
595
  with context:
596
  result = await manager.call_tool("tool_with_context", {"x": 42})
597
+ assert result[0].text == "42" # type: ignore[attr-defined]
 
 
 
598
 
599
  async def test_context_injection_async(self):
600
  """Test that context is properly injected in async tools."""
 
611
 
612
  with context:
613
  result = await manager.call_tool("async_tool", {"x": 42})
614
+ assert result[0].text == "42" # type: ignore[attr-defined]
 
 
 
615
 
616
  async def test_context_optional(self):
617
  """Test that context is optional when calling tools."""
 
618
 
619
  def tool_with_context(x: int, ctx: Context | None) -> int:
620
  return x
 
628
 
629
  with context:
630
  result = await manager.call_tool("tool_with_context", {"x": 42})
631
+ assert result[0].text == "42" # type: ignore[attr-defined]
 
 
 
632
 
633
  def test_parameterized_context_parameter_detection(self):
634
  """Test that context parameters are properly detected in
 
718
 
719
  async def test_call_tool_with_custom_name(self):
720
  """Test calling a tool added with a custom name."""
 
721
 
722
  def multiply(a: int, b: int) -> int:
723
  """Multiply two numbers."""
 
728
 
729
  # Tool should be callable by its custom name
730
  result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
731
+ assert result[0].text == "15" # type: ignore[attr-defined]
 
 
 
 
732
 
733
  # Original name should not be registered
734
  with pytest.raises(NotFoundError, match="Unknown tool: multiply"):
tests/utilities/test_mcp_config.py CHANGED
@@ -1,8 +1,6 @@
1
  import inspect
2
  from pathlib import Path
3
 
4
- from mcp.types import TextContent
5
-
6
  from fastmcp.client.client import Client
7
  from fastmcp.client.transports import (
8
  SSETransport,
@@ -136,7 +134,5 @@ async def test_multi_client(tmp_path: Path):
136
 
137
  result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
138
  result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
139
- assert isinstance(result_1[0], TextContent)
140
- assert result_1[0].text == "3"
141
- assert isinstance(result_2[0], TextContent)
142
- assert result_2[0].text == "3"
 
1
  import inspect
2
  from pathlib import Path
3
 
 
 
4
  from fastmcp.client.client import Client
5
  from fastmcp.client.transports import (
6
  SSETransport,
 
134
 
135
  result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
136
  result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
137
+ assert result_1[0].text == "3" # type: ignore[attr-dict]
138
+ assert result_2[0].text == "3" # type: ignore[attr-dict]