Jeremiah Lowin commited on
Commit
0cc9ac9
·
2 Parent(s): 42e32fe5608a39

Merge branch 'config-dicts' of https://github.com/jlowin/fastmcp into config-dicts

Browse files
.github/workflows/run-tests.yml CHANGED
@@ -44,7 +44,7 @@ jobs:
44
  python-version: ${{ matrix.python-version }}
45
 
46
  - name: Install FastMCP
47
- run: uv sync --dev --locked
48
 
49
  - name: Run tests
50
- run: uv run pytest
 
44
  python-version: ${{ matrix.python-version }}
45
 
46
  - name: Install FastMCP
47
+ run: uv sync --locked
48
 
49
  - name: Run tests
50
+ run: uv run pytest tests
docs/patterns/openapi.mdx CHANGED
@@ -61,19 +61,27 @@ Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determi
61
  # Simplified version of the actual mapping rules
62
  DEFAULT_ROUTE_MAPPINGS = [
63
  # GET with path parameters -> ResourceTemplate
64
- RouteMap(methods=["GET"], pattern=r".*\{.*\}.*",
65
- route_type=RouteType.RESOURCE_TEMPLATE),
 
 
 
66
 
67
  # GET without path parameters -> Resource
68
- RouteMap(methods=["GET"], pattern=r".*",
69
- route_type=RouteType.RESOURCE),
 
 
 
70
 
71
  # All other methods -> Tool
72
- RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
73
- pattern=r".*", route_type=RouteType.TOOL),
 
 
 
74
  ]
75
  ```
76
-
77
  ### Custom Route Maps
78
 
79
  Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps.
@@ -97,6 +105,35 @@ mcp = await FastMCP.from_openapi(
97
  )
98
  ```
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ## How It Works
101
 
102
  1. FastMCP parses your OpenAPI spec to extract routes and schemas
 
61
  # Simplified version of the actual mapping rules
62
  DEFAULT_ROUTE_MAPPINGS = [
63
  # GET with path parameters -> ResourceTemplate
64
+ RouteMap(
65
+ methods=["GET"],
66
+ pattern=r".*\{.*\}.*",
67
+ route_type=RouteType.RESOURCE_TEMPLATE,
68
+ ),
69
 
70
  # GET without path parameters -> Resource
71
+ RouteMap(
72
+ methods=["GET"],
73
+ pattern=r".*",
74
+ route_type=RouteType.RESOURCE,
75
+ ),
76
 
77
  # All other methods -> Tool
78
+ RouteMap(
79
+ methods="*",
80
+ pattern=r".*",
81
+ route_type=RouteType.TOOL,
82
+ ),
83
  ]
84
  ```
 
85
  ### Custom Route Maps
86
 
87
  Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps.
 
105
  )
106
  ```
107
 
108
+
109
+ ### All Routes as Tools
110
+
111
+ When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `all_routes_as_tools` parameter to automatically map every route to a Tool:
112
+
113
+ ```python
114
+ # Make all endpoints tools, regardless of HTTP method
115
+ mcp = FastMCP.from_openapi(
116
+ openapi_spec=spec,
117
+ client=api_client,
118
+ all_routes_as_tools=True
119
+ )
120
+ ```
121
+
122
+ This is equivalent to defining a single route map that matches all routes:
123
+
124
+ ```python
125
+ # Same effect as all_routes_as_tools=True
126
+ mcp = FastMCP.from_openapi(
127
+ openapi_spec=spec,
128
+ client=api_client,
129
+ route_maps=[
130
+ RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
131
+ ]
132
+ )
133
+ ```
134
+
135
+ Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead.
136
+
137
  ## How It Works
138
 
139
  1. FastMCP parses your OpenAPI spec to extract routes and schemas
src/fastmcp/server/http.py CHANGED
@@ -306,7 +306,29 @@ def create_streamable_http_app(
306
  async def handle_streamable_http(
307
  scope: Scope, receive: Receive, send: Send
308
  ) -> None:
309
- await session_manager.handle_request(scope, receive, send)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
  # Get auth middleware and routes
312
  auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
 
306
  async def handle_streamable_http(
307
  scope: Scope, receive: Receive, send: Send
308
  ) -> None:
309
+ try:
310
+ await session_manager.handle_request(scope, receive, send)
311
+ except RuntimeError as e:
312
+ if str(e) == "Task group is not initialized. Make sure to use run().":
313
+ logger.error(
314
+ f"Original RuntimeError from mcp library: {e}", exc_info=True
315
+ )
316
+ new_error_message = (
317
+ "FastMCP's StreamableHTTPSessionManager task group was not initialized. "
318
+ "This commonly occurs when the FastMCP application's lifespan is not "
319
+ "passed to the parent ASGI application (e.g., FastAPI or Starlette). "
320
+ "Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
321
+ "parent app's constructor, where `mcp_app` is the application instance "
322
+ "returned by `fastmcp_instance.http_app()`. \\n"
323
+ "For more details, see the FastMCP ASGI integration documentation: "
324
+ "https://gofastmcp.com/deployment/asgi"
325
+ )
326
+ # Raise a new RuntimeError that includes the original error's message
327
+ # for full context, but leads with the more helpful guidance.
328
+ raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
329
+ else:
330
+ # Re-raise other RuntimeErrors if they don't match the specific message
331
+ raise
332
 
333
  # Get auth middleware and routes
334
  auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
src/fastmcp/server/openapi.py CHANGED
@@ -47,7 +47,7 @@ class RouteType(enum.Enum):
47
  class RouteMap:
48
  """Mapping configuration for HTTP routes to FastMCP component types."""
49
 
50
- methods: list[HttpMethod]
51
  pattern: Pattern[str] | str
52
  route_type: RouteType
53
 
@@ -86,7 +86,7 @@ def _determine_route_type(
86
  # Check mappings in priority order (first match wins)
87
  for route_map in mappings:
88
  # Check if the HTTP method matches
89
- if route.method in route_map.methods:
90
  # Handle both string patterns and compiled Pattern objects
91
  if isinstance(route_map.pattern, Pattern):
92
  pattern_matches = route_map.pattern.search(route.path)
 
47
  class RouteMap:
48
  """Mapping configuration for HTTP routes to FastMCP component types."""
49
 
50
+ methods: list[HttpMethod] | Literal["*"]
51
  pattern: Pattern[str] | str
52
  route_type: RouteType
53
 
 
86
  # Check mappings in priority order (first match wins)
87
  for route_map in mappings:
88
  # Check if the HTTP method matches
89
+ if route_map.methods == "*" or route.method in route_map.methods:
90
  # Handle both string patterns and compiled Pattern objects
91
  if isinstance(route_map.pattern, Pattern):
92
  pattern_matches = route_map.pattern.search(route.path)
src/fastmcp/server/server.py CHANGED
@@ -62,7 +62,7 @@ from fastmcp.utilities.logging import get_logger
62
  if TYPE_CHECKING:
63
  from fastmcp.client import Client
64
  from fastmcp.client.transports import ClientTransport
65
- from fastmcp.server.openapi import FastMCPOpenAPI
66
  from fastmcp.server.proxy import FastMCPProxy
67
  logger = get_logger(__name__)
68
 
@@ -1082,24 +1082,59 @@ class FastMCP(Generic[LifespanResultT]):
1082
 
1083
  @classmethod
1084
  def from_openapi(
1085
- cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
 
 
 
 
 
1086
  ) -> FastMCPOpenAPI:
1087
  """
1088
  Create a FastMCP server from an OpenAPI specification.
1089
  """
1090
- from .openapi import FastMCPOpenAPI
 
 
 
 
 
 
 
 
 
 
 
 
1091
 
1092
- return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
 
 
 
 
 
1093
 
1094
  @classmethod
1095
  def from_fastapi(
1096
- cls, app: Any, name: str | None = None, **settings: Any
 
 
 
 
 
1097
  ) -> FastMCPOpenAPI:
1098
  """
1099
  Create a FastMCP server from a FastAPI application.
1100
  """
1101
 
1102
- from .openapi import FastMCPOpenAPI
 
 
 
 
 
 
 
 
1103
 
1104
  client = httpx.AsyncClient(
1105
  transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
@@ -1108,7 +1143,11 @@ class FastMCP(Generic[LifespanResultT]):
1108
  name = name or app.title
1109
 
1110
  return FastMCPOpenAPI(
1111
- openapi_spec=app.openapi(), client=client, name=name, **settings
 
 
 
 
1112
  )
1113
 
1114
  @classmethod
 
62
  if TYPE_CHECKING:
63
  from fastmcp.client import Client
64
  from fastmcp.client.transports import ClientTransport
65
+ from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
66
  from fastmcp.server.proxy import FastMCPProxy
67
  logger = get_logger(__name__)
68
 
 
1082
 
1083
  @classmethod
1084
  def from_openapi(
1085
+ cls,
1086
+ openapi_spec: dict[str, Any],
1087
+ client: httpx.AsyncClient,
1088
+ route_maps: list[RouteMap] | None = None,
1089
+ all_routes_as_tools: bool = False,
1090
+ **settings: Any,
1091
  ) -> FastMCPOpenAPI:
1092
  """
1093
  Create a FastMCP server from an OpenAPI specification.
1094
  """
1095
+ from .openapi import FastMCPOpenAPI, RouteMap, RouteType
1096
+
1097
+ if all_routes_as_tools and route_maps:
1098
+ raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
1099
+
1100
+ elif all_routes_as_tools:
1101
+ route_maps = [
1102
+ RouteMap(
1103
+ methods="*",
1104
+ pattern=r".*",
1105
+ route_type=RouteType.TOOL,
1106
+ )
1107
+ ]
1108
 
1109
+ return FastMCPOpenAPI(
1110
+ openapi_spec=openapi_spec,
1111
+ client=client,
1112
+ route_maps=route_maps,
1113
+ **settings,
1114
+ )
1115
 
1116
  @classmethod
1117
  def from_fastapi(
1118
+ cls,
1119
+ app: Any,
1120
+ name: str | None = None,
1121
+ route_maps: list[RouteMap] | None = None,
1122
+ all_routes_as_tools: bool = False,
1123
+ **settings: Any,
1124
  ) -> FastMCPOpenAPI:
1125
  """
1126
  Create a FastMCP server from a FastAPI application.
1127
  """
1128
 
1129
+ from .openapi import FastMCPOpenAPI, RouteMap, RouteType
1130
+
1131
+ if all_routes_as_tools and route_maps:
1132
+ raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
1133
+
1134
+ elif all_routes_as_tools:
1135
+ route_maps = [
1136
+ RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
1137
+ ]
1138
 
1139
  client = httpx.AsyncClient(
1140
  transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
 
1143
  name = name or app.title
1144
 
1145
  return FastMCPOpenAPI(
1146
+ openapi_spec=app.openapi(),
1147
+ client=client,
1148
+ name=name,
1149
+ route_maps=route_maps,
1150
+ **settings,
1151
  )
1152
 
1153
  @classmethod
src/fastmcp/utilities/tests.py CHANGED
@@ -71,7 +71,7 @@ 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[[str, int], None], *args
75
  ) -> Generator[str, None, None]:
76
  """
77
  Context manager that runs a Starlette app in a separate process and returns the
@@ -109,7 +109,11 @@ def run_server_in_process(
109
 
110
  yield f"http://{host}:{port}"
111
 
112
- proc.kill()
113
- proc.join(timeout=2)
114
  if proc.is_alive():
115
- raise RuntimeError("Server process failed to terminate")
 
 
 
 
 
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
 
109
 
110
  yield f"http://{host}:{port}"
111
 
112
+ proc.terminate()
113
+ proc.join(timeout=5)
114
  if proc.is_alive():
115
+ # If it's still alive, then force kill it
116
+ proc.kill()
117
+ proc.join(timeout=2)
118
+ if proc.is_alive():
119
+ raise RuntimeError("Server process failed to terminate even after kill")
tests/contrib/test_bulk_tool_caller.py CHANGED
@@ -85,7 +85,6 @@ ERROR_TOOL_NAME = "error_tool"
85
  NO_RETURN_TOOL_NAME = "no_return_tool"
86
 
87
 
88
- @pytest.mark.asyncio
89
  async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
90
  """Test single successful call via call_tool_bulk using echo_tool."""
91
  tool_arguments = [{"arg1": "value1"}]
@@ -98,7 +97,6 @@ async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
98
  assert result == expected_result
99
 
100
 
101
- @pytest.mark.asyncio
102
  async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
103
  """Test multiple successful calls via call_tool_bulk using echo_tool."""
104
  tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}]
@@ -110,7 +108,6 @@ async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller)
110
  assert results == expected_results
111
 
112
 
113
- @pytest.mark.asyncio
114
  async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
115
  """Test call_tool_bulk stops on first error using error_tool."""
116
  tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}]
@@ -125,7 +122,6 @@ async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
125
  assert result == expected_result
126
 
127
 
128
- @pytest.mark.asyncio
129
  async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
130
  """Test call_tool_bulk continues on error using error_tool and echo_tool."""
131
  tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}]
@@ -148,7 +144,6 @@ async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
148
  assert success_result == expected_success_result
149
 
150
 
151
- @pytest.mark.asyncio
152
  async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
153
  """Test single successful call via call_tools_bulk using echo_tool."""
154
  tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})]
@@ -161,7 +156,6 @@ async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
161
  assert result == expected_result
162
 
163
 
164
- @pytest.mark.asyncio
165
  async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
166
  """Test multiple successful calls via call_tools_bulk with different tools."""
167
  tool_calls = [
@@ -181,7 +175,6 @@ async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller
181
  assert results == expected_results
182
 
183
 
184
- @pytest.mark.asyncio
185
  async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
186
  """Test call_tools_bulk stops on first error using error_tool."""
187
  tool_calls = [
@@ -199,7 +192,6 @@ async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
199
  assert result == expected_result
200
 
201
 
202
- @pytest.mark.asyncio
203
  async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller):
204
  """Test call_tools_bulk continues on error using error_tool and echo_tool."""
205
  tool_calls = [
 
85
  NO_RETURN_TOOL_NAME = "no_return_tool"
86
 
87
 
 
88
  async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
89
  """Test single successful call via call_tool_bulk using echo_tool."""
90
  tool_arguments = [{"arg1": "value1"}]
 
97
  assert result == expected_result
98
 
99
 
 
100
  async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
101
  """Test multiple successful calls via call_tool_bulk using echo_tool."""
102
  tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}]
 
108
  assert results == expected_results
109
 
110
 
 
111
  async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
112
  """Test call_tool_bulk stops on first error using error_tool."""
113
  tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}]
 
122
  assert result == expected_result
123
 
124
 
 
125
  async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
126
  """Test call_tool_bulk continues on error using error_tool and echo_tool."""
127
  tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}]
 
144
  assert success_result == expected_success_result
145
 
146
 
 
147
  async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
148
  """Test single successful call via call_tools_bulk using echo_tool."""
149
  tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})]
 
156
  assert result == expected_result
157
 
158
 
 
159
  async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
160
  """Test multiple successful calls via call_tools_bulk with different tools."""
161
  tool_calls = [
 
175
  assert results == expected_results
176
 
177
 
 
178
  async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
179
  """Test call_tools_bulk stops on first error using error_tool."""
180
  tool_calls = [
 
192
  assert result == expected_result
193
 
194
 
 
195
  async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller):
196
  """Test call_tools_bulk continues on error using error_tool and echo_tool."""
197
  tool_calls = [
tests/server/test_auth_integration.py CHANGED
@@ -341,7 +341,6 @@ async def tokens(test_client, registered_client, auth_code, pkce_challenge, requ
341
 
342
 
343
  class TestAuthEndpoints:
344
- @pytest.mark.anyio
345
  async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
346
  """Test the OAuth 2.0 metadata endpoint."""
347
  print("Sending request to metadata endpoint")
@@ -370,7 +369,6 @@ class TestAuthEndpoints:
370
  ]
371
  assert metadata["service_documentation"] == "https://docs.example.com/"
372
 
373
- @pytest.mark.anyio
374
  async def test_token_validation_error(self, test_client: httpx.AsyncClient):
375
  """Test token endpoint error - validation error."""
376
  # Missing required fields
@@ -387,7 +385,6 @@ class TestAuthEndpoints:
387
  "error_description" in error_response
388
  ) # Contains validation error messages
389
 
390
- @pytest.mark.anyio
391
  async def test_token_invalid_auth_code(
392
  self, test_client, registered_client, pkce_challenge
393
  ):
@@ -414,7 +411,6 @@ class TestAuthEndpoints:
414
  "authorization code does not exist" in error_response["error_description"]
415
  )
416
 
417
- @pytest.mark.anyio
418
  async def test_token_expired_auth_code(
419
  self,
420
  test_client,
@@ -459,7 +455,6 @@ class TestAuthEndpoints:
459
  "authorization code has expired" in error_response["error_description"]
460
  )
461
 
462
- @pytest.mark.anyio
463
  @pytest.mark.parametrize(
464
  "registered_client",
465
  [
@@ -494,7 +489,6 @@ class TestAuthEndpoints:
494
  assert error_response["error"] == "invalid_request"
495
  assert "redirect_uri did not match" in error_response["error_description"]
496
 
497
- @pytest.mark.anyio
498
  async def test_token_code_verifier_mismatch(
499
  self, test_client, registered_client, auth_code
500
  ):
@@ -517,7 +511,6 @@ class TestAuthEndpoints:
517
  assert error_response["error"] == "invalid_grant"
518
  assert "incorrect code_verifier" in error_response["error_description"]
519
 
520
- @pytest.mark.anyio
521
  async def test_token_invalid_refresh_token(self, test_client, registered_client):
522
  """Test token endpoint error - refresh token does not exist."""
523
  # Try to use a non-existent refresh token
@@ -535,7 +528,6 @@ class TestAuthEndpoints:
535
  assert error_response["error"] == "invalid_grant"
536
  assert "refresh token does not exist" in error_response["error_description"]
537
 
538
- @pytest.mark.anyio
539
  async def test_token_expired_refresh_token(
540
  self,
541
  test_client,
@@ -586,7 +578,6 @@ class TestAuthEndpoints:
586
  assert error_response["error"] == "invalid_grant"
587
  assert "refresh token has expired" in error_response["error_description"]
588
 
589
- @pytest.mark.anyio
590
  async def test_token_invalid_scope(
591
  self, test_client, registered_client, auth_code, pkce_challenge
592
  ):
@@ -624,7 +615,6 @@ class TestAuthEndpoints:
624
  assert error_response["error"] == "invalid_scope"
625
  assert "cannot request scope" in error_response["error_description"]
626
 
627
- @pytest.mark.anyio
628
  async def test_client_registration(
629
  self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
630
  ):
@@ -652,7 +642,6 @@ class TestAuthEndpoints:
652
  # client_info["client_id"]
653
  # ) is not None
654
 
655
- @pytest.mark.anyio
656
  async def test_client_registration_missing_required_fields(
657
  self, test_client: httpx.AsyncClient
658
  ):
@@ -673,7 +662,6 @@ class TestAuthEndpoints:
673
  assert error_data["error"] == "invalid_client_metadata"
674
  assert error_data["error_description"] == "redirect_uris: Field required"
675
 
676
- @pytest.mark.anyio
677
  async def test_client_registration_invalid_uri(
678
  self, test_client: httpx.AsyncClient
679
  ):
@@ -696,7 +684,6 @@ class TestAuthEndpoints:
696
  "redirect_uris.0: Input should be a valid URL, relative URL without a base"
697
  )
698
 
699
- @pytest.mark.anyio
700
  async def test_client_registration_empty_redirect_uris(
701
  self, test_client: httpx.AsyncClient
702
  ):
@@ -719,7 +706,6 @@ class TestAuthEndpoints:
719
  == "redirect_uris: List should have at least 1 item after validation, not 0"
720
  )
721
 
722
- @pytest.mark.anyio
723
  async def test_authorize_form_post(
724
  self,
725
  test_client: httpx.AsyncClient,
@@ -763,7 +749,6 @@ class TestAuthEndpoints:
763
  assert "code" in query_params
764
  assert query_params["state"][0] == "test_form_state"
765
 
766
- @pytest.mark.anyio
767
  async def test_authorization_get(
768
  self,
769
  test_client: httpx.AsyncClient,
@@ -878,7 +863,6 @@ class TestAuthEndpoints:
878
  is None
879
  )
880
 
881
- @pytest.mark.anyio
882
  async def test_revoke_invalid_token(self, test_client, registered_client):
883
  """Test revoking an invalid token."""
884
  response = await test_client.post(
@@ -892,7 +876,6 @@ class TestAuthEndpoints:
892
  # per RFC, this should return 200 even if the token is invalid
893
  assert response.status_code == 200
894
 
895
- @pytest.mark.anyio
896
  async def test_revoke_with_malformed_token(self, test_client, registered_client):
897
  response = await test_client.post(
898
  "/revoke",
@@ -908,7 +891,6 @@ class TestAuthEndpoints:
908
  assert error_response["error"] == "invalid_request"
909
  assert "token_type_hint" in error_response["error_description"]
910
 
911
- @pytest.mark.anyio
912
  async def test_client_registration_disallowed_scopes(
913
  self, test_client: httpx.AsyncClient
914
  ):
@@ -930,7 +912,6 @@ class TestAuthEndpoints:
930
  assert "scope" in error_data["error_description"]
931
  assert "admin" in error_data["error_description"]
932
 
933
- @pytest.mark.anyio
934
  async def test_client_registration_default_scopes(
935
  self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
936
  ):
@@ -959,7 +940,6 @@ class TestAuthEndpoints:
959
  # Check that default scopes were applied
960
  assert registered_client.scope == "read write"
961
 
962
- @pytest.mark.anyio
963
  async def test_client_registration_invalid_grant_type(
964
  self, test_client: httpx.AsyncClient
965
  ):
@@ -986,7 +966,6 @@ class TestAuthEndpoints:
986
  class TestAuthorizeEndpointErrors:
987
  """Test error handling in the OAuth authorization endpoint."""
988
 
989
- @pytest.mark.anyio
990
  async def test_authorize_missing_client_id(
991
  self, test_client: httpx.AsyncClient, pkce_challenge
992
  ):
@@ -1012,7 +991,6 @@ class TestAuthorizeEndpointErrors:
1012
  # The response should include an error message about missing client_id
1013
  assert "client_id" in response.text.lower()
1014
 
1015
- @pytest.mark.anyio
1016
  async def test_authorize_invalid_client_id(
1017
  self, test_client: httpx.AsyncClient, pkce_challenge
1018
  ):
@@ -1038,7 +1016,6 @@ class TestAuthorizeEndpointErrors:
1038
  # The response should include an error message about invalid client_id
1039
  assert "client" in response.text.lower()
1040
 
1041
- @pytest.mark.anyio
1042
  async def test_authorize_missing_redirect_uri(
1043
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1044
  ):
@@ -1064,7 +1041,6 @@ class TestAuthorizeEndpointErrors:
1064
  redirect_url = response.headers["location"]
1065
  assert redirect_url.startswith("https://client.example.com/callback")
1066
 
1067
- @pytest.mark.anyio
1068
  async def test_authorize_invalid_redirect_uri(
1069
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1070
  ):
@@ -1092,7 +1068,6 @@ class TestAuthorizeEndpointErrors:
1092
  # The response should include an error message about redirect_uri mismatch
1093
  assert "redirect" in response.text.lower()
1094
 
1095
- @pytest.mark.anyio
1096
  @pytest.mark.parametrize(
1097
  "registered_client",
1098
  [
@@ -1130,7 +1105,6 @@ class TestAuthorizeEndpointErrors:
1130
  # The response should include an error message about missing redirect_uri
1131
  assert "redirect_uri" in response.text.lower()
1132
 
1133
- @pytest.mark.anyio
1134
  async def test_authorize_unsupported_response_type(
1135
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1136
  ):
@@ -1164,7 +1138,6 @@ class TestAuthorizeEndpointErrors:
1164
  assert "state" in query_params
1165
  assert query_params["state"][0] == "test_state"
1166
 
1167
- @pytest.mark.anyio
1168
  async def test_authorize_missing_response_type(
1169
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1170
  ):
@@ -1197,7 +1170,6 @@ class TestAuthorizeEndpointErrors:
1197
  assert "state" in query_params
1198
  assert query_params["state"][0] == "test_state"
1199
 
1200
- @pytest.mark.anyio
1201
  async def test_authorize_missing_pkce_challenge(
1202
  self, test_client: httpx.AsyncClient, registered_client
1203
  ):
@@ -1228,7 +1200,6 @@ class TestAuthorizeEndpointErrors:
1228
  assert "state" in query_params
1229
  assert query_params["state"][0] == "test_state"
1230
 
1231
- @pytest.mark.anyio
1232
  async def test_authorize_invalid_scope(
1233
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1234
  ):
 
341
 
342
 
343
  class TestAuthEndpoints:
 
344
  async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
345
  """Test the OAuth 2.0 metadata endpoint."""
346
  print("Sending request to metadata endpoint")
 
369
  ]
370
  assert metadata["service_documentation"] == "https://docs.example.com/"
371
 
 
372
  async def test_token_validation_error(self, test_client: httpx.AsyncClient):
373
  """Test token endpoint error - validation error."""
374
  # Missing required fields
 
385
  "error_description" in error_response
386
  ) # Contains validation error messages
387
 
 
388
  async def test_token_invalid_auth_code(
389
  self, test_client, registered_client, pkce_challenge
390
  ):
 
411
  "authorization code does not exist" in error_response["error_description"]
412
  )
413
 
 
414
  async def test_token_expired_auth_code(
415
  self,
416
  test_client,
 
455
  "authorization code has expired" in error_response["error_description"]
456
  )
457
 
 
458
  @pytest.mark.parametrize(
459
  "registered_client",
460
  [
 
489
  assert error_response["error"] == "invalid_request"
490
  assert "redirect_uri did not match" in error_response["error_description"]
491
 
 
492
  async def test_token_code_verifier_mismatch(
493
  self, test_client, registered_client, auth_code
494
  ):
 
511
  assert error_response["error"] == "invalid_grant"
512
  assert "incorrect code_verifier" in error_response["error_description"]
513
 
 
514
  async def test_token_invalid_refresh_token(self, test_client, registered_client):
515
  """Test token endpoint error - refresh token does not exist."""
516
  # Try to use a non-existent refresh token
 
528
  assert error_response["error"] == "invalid_grant"
529
  assert "refresh token does not exist" in error_response["error_description"]
530
 
 
531
  async def test_token_expired_refresh_token(
532
  self,
533
  test_client,
 
578
  assert error_response["error"] == "invalid_grant"
579
  assert "refresh token has expired" in error_response["error_description"]
580
 
 
581
  async def test_token_invalid_scope(
582
  self, test_client, registered_client, auth_code, pkce_challenge
583
  ):
 
615
  assert error_response["error"] == "invalid_scope"
616
  assert "cannot request scope" in error_response["error_description"]
617
 
 
618
  async def test_client_registration(
619
  self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
620
  ):
 
642
  # client_info["client_id"]
643
  # ) is not None
644
 
 
645
  async def test_client_registration_missing_required_fields(
646
  self, test_client: httpx.AsyncClient
647
  ):
 
662
  assert error_data["error"] == "invalid_client_metadata"
663
  assert error_data["error_description"] == "redirect_uris: Field required"
664
 
 
665
  async def test_client_registration_invalid_uri(
666
  self, test_client: httpx.AsyncClient
667
  ):
 
684
  "redirect_uris.0: Input should be a valid URL, relative URL without a base"
685
  )
686
 
 
687
  async def test_client_registration_empty_redirect_uris(
688
  self, test_client: httpx.AsyncClient
689
  ):
 
706
  == "redirect_uris: List should have at least 1 item after validation, not 0"
707
  )
708
 
 
709
  async def test_authorize_form_post(
710
  self,
711
  test_client: httpx.AsyncClient,
 
749
  assert "code" in query_params
750
  assert query_params["state"][0] == "test_form_state"
751
 
 
752
  async def test_authorization_get(
753
  self,
754
  test_client: httpx.AsyncClient,
 
863
  is None
864
  )
865
 
 
866
  async def test_revoke_invalid_token(self, test_client, registered_client):
867
  """Test revoking an invalid token."""
868
  response = await test_client.post(
 
876
  # per RFC, this should return 200 even if the token is invalid
877
  assert response.status_code == 200
878
 
 
879
  async def test_revoke_with_malformed_token(self, test_client, registered_client):
880
  response = await test_client.post(
881
  "/revoke",
 
891
  assert error_response["error"] == "invalid_request"
892
  assert "token_type_hint" in error_response["error_description"]
893
 
 
894
  async def test_client_registration_disallowed_scopes(
895
  self, test_client: httpx.AsyncClient
896
  ):
 
912
  assert "scope" in error_data["error_description"]
913
  assert "admin" in error_data["error_description"]
914
 
 
915
  async def test_client_registration_default_scopes(
916
  self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
917
  ):
 
940
  # Check that default scopes were applied
941
  assert registered_client.scope == "read write"
942
 
 
943
  async def test_client_registration_invalid_grant_type(
944
  self, test_client: httpx.AsyncClient
945
  ):
 
966
  class TestAuthorizeEndpointErrors:
967
  """Test error handling in the OAuth authorization endpoint."""
968
 
 
969
  async def test_authorize_missing_client_id(
970
  self, test_client: httpx.AsyncClient, pkce_challenge
971
  ):
 
991
  # The response should include an error message about missing client_id
992
  assert "client_id" in response.text.lower()
993
 
 
994
  async def test_authorize_invalid_client_id(
995
  self, test_client: httpx.AsyncClient, pkce_challenge
996
  ):
 
1016
  # The response should include an error message about invalid client_id
1017
  assert "client" in response.text.lower()
1018
 
 
1019
  async def test_authorize_missing_redirect_uri(
1020
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1021
  ):
 
1041
  redirect_url = response.headers["location"]
1042
  assert redirect_url.startswith("https://client.example.com/callback")
1043
 
 
1044
  async def test_authorize_invalid_redirect_uri(
1045
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1046
  ):
 
1068
  # The response should include an error message about redirect_uri mismatch
1069
  assert "redirect" in response.text.lower()
1070
 
 
1071
  @pytest.mark.parametrize(
1072
  "registered_client",
1073
  [
 
1105
  # The response should include an error message about missing redirect_uri
1106
  assert "redirect_uri" in response.text.lower()
1107
 
 
1108
  async def test_authorize_unsupported_response_type(
1109
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1110
  ):
 
1138
  assert "state" in query_params
1139
  assert query_params["state"][0] == "test_state"
1140
 
 
1141
  async def test_authorize_missing_response_type(
1142
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1143
  ):
 
1170
  assert "state" in query_params
1171
  assert query_params["state"][0] == "test_state"
1172
 
 
1173
  async def test_authorize_missing_pkce_challenge(
1174
  self, test_client: httpx.AsyncClient, registered_client
1175
  ):
 
1200
  assert "state" in query_params
1201
  assert query_params["state"][0] == "test_state"
1202
 
 
1203
  async def test_authorize_invalid_scope(
1204
  self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
1205
  ):
tests/server/test_http_middleware.py CHANGED
@@ -4,7 +4,6 @@ from collections.abc import Callable
4
  from typing import Any
5
 
6
  import httpx
7
- import pytest
8
  from httpx import ASGITransport
9
  from starlette.middleware import Middleware
10
  from starlette.middleware.base import BaseHTTPMiddleware
@@ -51,7 +50,6 @@ async def endpoint_handler(request: Request):
51
  return JSONResponse({"message": "Hello, world!"})
52
 
53
 
54
- @pytest.mark.asyncio
55
  async def test_sse_app_with_custom_middleware():
56
  """Test that custom middleware works with SSE app."""
57
  server = FastMCP(name="TestServer")
@@ -82,7 +80,6 @@ async def test_sse_app_with_custom_middleware():
82
  assert response.headers["X-Custom-Header"] == "test-value"
83
 
84
 
85
- @pytest.mark.asyncio
86
  async def test_streamable_http_app_with_custom_middleware():
87
  """Test that custom middleware works with StreamableHTTP app."""
88
  server = FastMCP(name="TestServer")
@@ -113,7 +110,6 @@ async def test_streamable_http_app_with_custom_middleware():
113
  assert response.headers["X-Custom-Header"] == "test-value"
114
 
115
 
116
- @pytest.mark.asyncio
117
  async def test_create_sse_app_with_custom_middleware():
118
  """Test that custom middleware works with create_sse_app function."""
119
  server = FastMCP(name="TestServer")
@@ -149,7 +145,6 @@ async def test_create_sse_app_with_custom_middleware():
149
  assert data["state"]["modified_by"] == "middleware"
150
 
151
 
152
- @pytest.mark.asyncio
153
  async def test_create_streamable_http_app_with_custom_middleware():
154
  """Test that custom middleware works with create_streamable_http_app function."""
155
  server = FastMCP(name="TestServer")
@@ -184,7 +179,6 @@ async def test_create_streamable_http_app_with_custom_middleware():
184
  assert data["state"]["modified_by"] == "middleware"
185
 
186
 
187
- @pytest.mark.asyncio
188
  async def test_multiple_middleware_ordering():
189
  """Test that multiple middleware are applied in the correct order."""
190
  server = FastMCP(name="TestServer")
 
4
  from typing import Any
5
 
6
  import httpx
 
7
  from httpx import ASGITransport
8
  from starlette.middleware import Middleware
9
  from starlette.middleware.base import BaseHTTPMiddleware
 
50
  return JSONResponse({"message": "Hello, world!"})
51
 
52
 
 
53
  async def test_sse_app_with_custom_middleware():
54
  """Test that custom middleware works with SSE app."""
55
  server = FastMCP(name="TestServer")
 
80
  assert response.headers["X-Custom-Header"] == "test-value"
81
 
82
 
 
83
  async def test_streamable_http_app_with_custom_middleware():
84
  """Test that custom middleware works with StreamableHTTP app."""
85
  server = FastMCP(name="TestServer")
 
110
  assert response.headers["X-Custom-Header"] == "test-value"
111
 
112
 
 
113
  async def test_create_sse_app_with_custom_middleware():
114
  """Test that custom middleware works with create_sse_app function."""
115
  server = FastMCP(name="TestServer")
 
145
  assert data["state"]["modified_by"] == "middleware"
146
 
147
 
 
148
  async def test_create_streamable_http_app_with_custom_middleware():
149
  """Test that custom middleware works with create_streamable_http_app function."""
150
  server = FastMCP(name="TestServer")
 
179
  assert data["state"]["modified_by"] == "middleware"
180
 
181
 
 
182
  async def test_multiple_middleware_ordering():
183
  """Test that multiple middleware are applied in the correct order."""
184
  server = FastMCP(name="TestServer")
tests/server/test_lifespan.py CHANGED
@@ -1,10 +1,15 @@
1
  """Tests for lifespan functionality in both low-level and FastMCP servers."""
2
 
 
 
 
3
  from collections.abc import AsyncIterator
4
  from contextlib import asynccontextmanager
 
5
 
6
  import anyio
7
- import pytest
 
8
  from mcp.server.lowlevel.server import NotificationOptions, Server
9
  from mcp.server.models import InitializationOptions
10
  from mcp.shared.message import SessionMessage
@@ -17,11 +22,13 @@ from mcp.types import (
17
  JSONRPCRequest,
18
  )
19
  from pydantic import TypeAdapter
 
 
20
 
21
  from fastmcp import Context, FastMCP
 
22
 
23
 
24
- @pytest.mark.anyio
25
  async def test_lowlevel_server_lifespan():
26
  """Test that lifespan works in low-level server."""
27
 
@@ -132,7 +139,6 @@ async def test_lowlevel_server_lifespan():
132
  tg.cancel_scope.cancel()
133
 
134
 
135
- @pytest.mark.anyio
136
  async def test_fastmcp_server_lifespan():
137
  """Test that lifespan works in FastMCP server."""
138
 
@@ -234,3 +240,157 @@ async def test_fastmcp_server_lifespan():
234
 
235
  # Cancel server task
236
  tg.cancel_scope.cancel()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
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
 
 
139
  tg.cancel_scope.cancel()
140
 
141
 
 
142
  async def test_fastmcp_server_lifespan():
143
  """Test that lifespan works in FastMCP server."""
144
 
 
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_openapi.py CHANGED
@@ -1890,3 +1890,287 @@ class TestEnumHandling:
1890
  assert "enum" in enum_def
1891
  assert enum_def["enum"] == ["foo", "bar", "baz"]
1892
  assert enum_def["type"] == "string"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1890
  assert "enum" in enum_def
1891
  assert enum_def["enum"] == ["foo", "bar", "baz"]
1892
  assert enum_def["type"] == "string"
1893
+
1894
+
1895
+ class TestRouteMapWildcard:
1896
+ """Tests for wildcard RouteMap methods functionality."""
1897
+
1898
+ @pytest.fixture
1899
+ def basic_openapi_spec(self) -> dict:
1900
+ """Create a minimal OpenAPI spec with different HTTP methods."""
1901
+ return {
1902
+ "openapi": "3.1.0",
1903
+ "info": {"title": "Test API", "version": "1.0.0"},
1904
+ "paths": {
1905
+ "/users": {
1906
+ "get": {
1907
+ "operationId": "getUsers",
1908
+ "responses": {"200": {"description": "Success"}},
1909
+ },
1910
+ "post": {
1911
+ "operationId": "createUser",
1912
+ "responses": {"201": {"description": "Created"}},
1913
+ },
1914
+ },
1915
+ "/posts": {
1916
+ "get": {
1917
+ "operationId": "getPosts",
1918
+ "responses": {"200": {"description": "Success"}},
1919
+ },
1920
+ "post": {
1921
+ "operationId": "createPost",
1922
+ "responses": {"201": {"description": "Created"}},
1923
+ },
1924
+ },
1925
+ },
1926
+ }
1927
+
1928
+ @pytest.fixture
1929
+ async def mock_basic_client(self) -> httpx.AsyncClient:
1930
+ """Create a simple mock client."""
1931
+
1932
+ async def _responder(request):
1933
+ return httpx.Response(200, json={"status": "ok"})
1934
+
1935
+ transport = httpx.MockTransport(_responder)
1936
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
1937
+
1938
+ async def test_wildcard_matches_all_methods(
1939
+ self, basic_openapi_spec, mock_basic_client
1940
+ ):
1941
+ """Test that a RouteMap with methods='*' matches all HTTP methods."""
1942
+ # Create a single route map with wildcard method
1943
+ route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)]
1944
+
1945
+ mcp = FastMCPOpenAPI(
1946
+ openapi_spec=basic_openapi_spec,
1947
+ client=mock_basic_client,
1948
+ route_maps=route_maps,
1949
+ )
1950
+
1951
+ # All operations should be mapped to tools
1952
+ tools = mcp._tool_manager.list_tools()
1953
+ tool_names = {tool.name for tool in tools}
1954
+
1955
+ # Check that all operations were mapped as tools
1956
+ expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
1957
+ assert tool_names == expected_tools
1958
+
1959
+ # No resources or templates should be created
1960
+ resources = mcp._resource_manager.get_resources()
1961
+ templates = mcp._resource_manager.get_templates()
1962
+ assert len(resources) == 0
1963
+ assert len(templates) == 0
1964
+
1965
+ async def test_priority_specific_over_wildcard(
1966
+ self, basic_openapi_spec, mock_basic_client
1967
+ ):
1968
+ """Test that specific method maps take priority over wildcard."""
1969
+ # Create route maps with specific method first, then wildcard
1970
+ route_maps = [
1971
+ # GET operations should be mapped to resources
1972
+ RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
1973
+ # All other operations should be mapped to tools
1974
+ RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
1975
+ ]
1976
+
1977
+ mcp = FastMCPOpenAPI(
1978
+ openapi_spec=basic_openapi_spec,
1979
+ client=mock_basic_client,
1980
+ route_maps=route_maps,
1981
+ )
1982
+
1983
+ # Check GET operations went to resources
1984
+ resources = mcp._resource_manager.get_resources()
1985
+ resource_names = {r.name for r in resources.values()}
1986
+ assert "getUsers" in resource_names
1987
+ assert "getPosts" in resource_names
1988
+ assert len(resources) == 2
1989
+
1990
+ # Check other operations went to tools
1991
+ tools = mcp._tool_manager.list_tools()
1992
+ tool_names = {tool.name for tool in tools}
1993
+ assert "createUser" in tool_names
1994
+ assert "createPost" in tool_names
1995
+ assert len(tools) == 2
1996
+
1997
+ async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client):
1998
+ """Test that when wildcard is first, it matches everything."""
1999
+ # Create route maps with wildcard first, then specific methods
2000
+ route_maps = [
2001
+ # Wildcard first matches everything
2002
+ RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
2003
+ # This should never be reached
2004
+ RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
2005
+ ]
2006
+
2007
+ mcp = FastMCPOpenAPI(
2008
+ openapi_spec=basic_openapi_spec,
2009
+ client=mock_basic_client,
2010
+ route_maps=route_maps,
2011
+ )
2012
+
2013
+ # All operations should be tools
2014
+ tools = mcp._tool_manager.list_tools()
2015
+ assert len(tools) == 4
2016
+
2017
+ # No resources should be created
2018
+ resources = mcp._resource_manager.get_resources()
2019
+ assert len(resources) == 0
2020
+
2021
+ async def test_wildcard_with_specific_paths(
2022
+ self, basic_openapi_spec, mock_basic_client
2023
+ ):
2024
+ """Test wildcard methods combined with specific path patterns."""
2025
+ route_maps = [
2026
+ # All methods on /users path -> Resources
2027
+ RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE),
2028
+ # All methods on /posts path -> Tools
2029
+ RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL),
2030
+ ]
2031
+
2032
+ mcp = FastMCPOpenAPI(
2033
+ openapi_spec=basic_openapi_spec,
2034
+ client=mock_basic_client,
2035
+ route_maps=route_maps,
2036
+ )
2037
+
2038
+ # Check /users operations went to resources
2039
+ resources = mcp._resource_manager.get_resources()
2040
+ resource_names = {r.name for r in resources.values()}
2041
+ assert "getUsers" in resource_names
2042
+ assert "createUser" in resource_names
2043
+ assert len(resources) == 2
2044
+
2045
+ # Check /posts operations went to tools
2046
+ tools = mcp._tool_manager.list_tools()
2047
+ tool_names = {tool.name for tool in tools}
2048
+ assert "getPosts" in tool_names
2049
+ assert "createPost" in tool_names
2050
+ assert len(tools) == 2
2051
+
2052
+
2053
+ class TestAllRoutesAsTools:
2054
+ """Tests for the all_routes_as_tools parameter in FastMCP class methods."""
2055
+
2056
+ @pytest.fixture
2057
+ def simple_api_spec(self) -> dict:
2058
+ """A simple OpenAPI spec with both GET and POST methods."""
2059
+ return {
2060
+ "openapi": "3.1.0",
2061
+ "info": {"title": "Test API", "version": "1.0.0"},
2062
+ "paths": {
2063
+ "/items": {
2064
+ "get": {
2065
+ "operationId": "getItems",
2066
+ "responses": {"200": {"description": "Success"}},
2067
+ },
2068
+ "post": {
2069
+ "operationId": "createItem",
2070
+ "responses": {"201": {"description": "Created"}},
2071
+ },
2072
+ },
2073
+ },
2074
+ }
2075
+
2076
+ @pytest.fixture
2077
+ async def mock_client(self) -> httpx.AsyncClient:
2078
+ """Simple mock client for testing."""
2079
+
2080
+ async def _responder(request):
2081
+ return httpx.Response(200, json={"result": "ok"})
2082
+
2083
+ transport = httpx.MockTransport(_responder)
2084
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
2085
+
2086
+ async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client):
2087
+ """Test FastMCP.from_openapi with all_routes_as_tools=True."""
2088
+ # Create server with all routes as tools
2089
+ server = FastMCP.from_openapi(
2090
+ openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True
2091
+ )
2092
+
2093
+ # All operations (GET and POST) should be mapped to tools
2094
+ tools = server._tool_manager.list_tools()
2095
+ tool_names = {t.name for t in tools}
2096
+
2097
+ assert "getItems" in tool_names
2098
+ assert "createItem" in tool_names
2099
+ assert len(tools) == 2
2100
+
2101
+ # No resources or templates should be created
2102
+ resources = server._resource_manager.get_resources()
2103
+ templates = server._resource_manager.get_templates()
2104
+ assert len(resources) == 0
2105
+ assert len(templates) == 0
2106
+
2107
+ async def test_from_openapi_all_routes_as_tools_conflicting_args(
2108
+ self, simple_api_spec, mock_client
2109
+ ):
2110
+ """Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided."""
2111
+ # Try to create server with conflicting args
2112
+ with pytest.raises(
2113
+ ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
2114
+ ):
2115
+ FastMCP.from_openapi(
2116
+ openapi_spec=simple_api_spec,
2117
+ client=mock_client,
2118
+ all_routes_as_tools=True,
2119
+ route_maps=[
2120
+ RouteMap(
2121
+ methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
2122
+ )
2123
+ ],
2124
+ )
2125
+
2126
+ async def test_from_fastapi_all_routes_as_tools(self):
2127
+ """Test FastMCP.from_fastapi with all_routes_as_tools=True."""
2128
+ # Create a simple FastAPI app
2129
+ app = FastAPI(title="Test FastAPI")
2130
+
2131
+ @app.get("/items")
2132
+ async def get_items():
2133
+ return [{"id": 1, "name": "Item 1"}]
2134
+
2135
+ @app.post("/items")
2136
+ async def create_item(item: dict):
2137
+ return {"id": 2, **item}
2138
+
2139
+ # Create server with all routes as tools
2140
+ server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True)
2141
+
2142
+ # Both GET and POST operations should be mapped to tools
2143
+ tools = server._tool_manager.list_tools()
2144
+
2145
+ # Get tool names from the generated operation IDs
2146
+ tool_names = {t.name for t in tools}
2147
+
2148
+ # Check that both routes were mapped to tools
2149
+ # The exact names depend on FastAPI's operation ID generation
2150
+ assert len(tools) == 2
2151
+ assert any("get" in name.lower() for name in tool_names)
2152
+ assert any("post" in name.lower() for name in tool_names)
2153
+
2154
+ # No resources or templates should be created
2155
+ resources = server._resource_manager.get_resources()
2156
+ templates = server._resource_manager.get_templates()
2157
+ assert len(resources) == 0
2158
+ assert len(templates) == 0
2159
+
2160
+ async def test_from_fastapi_all_routes_as_tools_conflicting_args(self):
2161
+ """Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided."""
2162
+ app = FastAPI(title="Test FastAPI")
2163
+
2164
+ # Try to create server with conflicting args
2165
+ with pytest.raises(
2166
+ ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
2167
+ ):
2168
+ FastMCP.from_fastapi(
2169
+ app=app,
2170
+ all_routes_as_tools=True,
2171
+ route_maps=[
2172
+ RouteMap(
2173
+ methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
2174
+ )
2175
+ ],
2176
+ )
tests/server/test_openapi_path_parameters.py CHANGED
@@ -87,7 +87,6 @@ async def test_fastmcp_from_openapi(array_path_spec, mock_client):
87
  assert "test-operation" in tool_names
88
 
89
 
90
- @pytest.mark.asyncio
91
  async def test_array_path_parameter_handling(mock_client):
92
  """Test how array path parameters are handled."""
93
  # Create a simple route with array path parameter
@@ -158,7 +157,6 @@ async def test_array_path_parameter_handling(mock_client):
158
  )
159
 
160
 
161
- @pytest.mark.asyncio
162
  async def test_integration_array_path_parameter(array_path_spec, mock_client):
163
  """Integration test for array path parameters."""
164
  # Create FastMCP from the spec
@@ -192,7 +190,6 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client):
192
  )
193
 
194
 
195
- @pytest.mark.asyncio
196
  async def test_complex_nested_array_path_parameter(mock_client):
197
  """Test handling of complex nested array path parameters."""
198
  # Create a route with a path parameter that contains nested objects in an array
@@ -262,7 +259,6 @@ async def test_complex_nested_array_path_parameter(mock_client):
262
  assert "{" not in called_url, "The URL should not contain Python object syntax"
263
 
264
 
265
- @pytest.mark.asyncio
266
  async def test_array_query_param_with_fastapi():
267
  """Test array query parameters using FastAPI and FastMCP.from_fastapi integration."""
268
  # Create a FastAPI app with a route that has an array query parameter
@@ -323,7 +319,6 @@ async def test_array_query_param_with_fastapi():
323
  assert result_data == {"selected": ["monday", "tuesday"]}
324
 
325
 
326
- @pytest.mark.asyncio
327
  async def test_array_query_parameter_format(mock_client):
328
  """Test that array query parameters are formatted as comma-separated values when explode=False."""
329
  # Create a route with array query parameter
@@ -394,7 +389,6 @@ async def test_array_query_parameter_format(mock_client):
394
  )
395
 
396
 
397
- @pytest.mark.asyncio
398
  async def test_array_query_parameter_exploded_format(mock_client):
399
  """Test that array query parameters are formatted as separate parameters when explode=True."""
400
  # Create a route with array query parameter with explode=True (default)
 
87
  assert "test-operation" in tool_names
88
 
89
 
 
90
  async def test_array_path_parameter_handling(mock_client):
91
  """Test how array path parameters are handled."""
92
  # Create a simple route with array path parameter
 
157
  )
158
 
159
 
 
160
  async def test_integration_array_path_parameter(array_path_spec, mock_client):
161
  """Integration test for array path parameters."""
162
  # Create FastMCP from the spec
 
190
  )
191
 
192
 
 
193
  async def test_complex_nested_array_path_parameter(mock_client):
194
  """Test handling of complex nested array path parameters."""
195
  # Create a route with a path parameter that contains nested objects in an array
 
259
  assert "{" not in called_url, "The URL should not contain Python object syntax"
260
 
261
 
 
262
  async def test_array_query_param_with_fastapi():
263
  """Test array query parameters using FastAPI and FastMCP.from_fastapi integration."""
264
  # Create a FastAPI app with a route that has an array query parameter
 
319
  assert result_data == {"selected": ["monday", "tuesday"]}
320
 
321
 
 
322
  async def test_array_query_parameter_format(mock_client):
323
  """Test that array query parameters are formatted as comma-separated values when explode=False."""
324
  # Create a route with array query parameter
 
389
  )
390
 
391
 
 
392
  async def test_array_query_parameter_exploded_format(mock_client):
393
  """Test that array query parameters are formatted as separate parameters when explode=True."""
394
  # Create a route with array query parameter with explode=True (default)
tests/test_deprecated.py CHANGED
@@ -40,7 +40,6 @@ def test_streamable_http_app_deprecation_warning():
40
  assert isinstance(app, Starlette)
41
 
42
 
43
- @pytest.mark.asyncio
44
  async def test_run_sse_async_deprecation_warning():
45
  """Test that run_sse_async raises a deprecation warning."""
46
  server = FastMCP("TestServer")
@@ -58,7 +57,6 @@ async def test_run_sse_async_deprecation_warning():
58
  assert call_kwargs.get("transport") == "sse"
59
 
60
 
61
- @pytest.mark.asyncio
62
  async def test_run_streamable_http_async_deprecation_warning():
63
  """Test that run_streamable_http_async raises a deprecation warning."""
64
  server = FastMCP("TestServer")
 
40
  assert isinstance(app, Starlette)
41
 
42
 
 
43
  async def test_run_sse_async_deprecation_warning():
44
  """Test that run_sse_async raises a deprecation warning."""
45
  server = FastMCP("TestServer")
 
57
  assert call_kwargs.get("transport") == "sse"
58
 
59
 
 
60
  async def test_run_streamable_http_async_deprecation_warning():
61
  """Test that run_streamable_http_async raises a deprecation warning."""
62
  server = FastMCP("TestServer")
tests/test_examples.py CHANGED
@@ -1,6 +1,5 @@
1
  """Tests for example servers"""
2
 
3
- import pytest
4
  from mcp.types import (
5
  PromptMessage,
6
  TextContent,
@@ -11,7 +10,6 @@ from pydantic import AnyUrl
11
  from fastmcp import Client
12
 
13
 
14
- @pytest.mark.anyio
15
  async def test_simple_echo():
16
  """Test the simple echo server"""
17
  from examples.simple_echo import mcp
@@ -23,7 +21,6 @@ async def test_simple_echo():
23
  assert result[0].text == "hello"
24
 
25
 
26
- @pytest.mark.anyio
27
  async def test_complex_inputs():
28
  """Test the complex inputs server"""
29
  from examples.complex_inputs import mcp
@@ -38,7 +35,6 @@ async def test_complex_inputs():
38
  assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]'
39
 
40
 
41
- @pytest.mark.anyio
42
  async def test_desktop(monkeypatch):
43
  """Test the desktop server"""
44
  from examples.desktop import mcp
@@ -58,7 +54,6 @@ async def test_desktop(monkeypatch):
58
  assert result[0].text == "Hello, rooter12!"
59
 
60
 
61
- @pytest.mark.anyio
62
  async def test_echo():
63
  """Test the echo server"""
64
  from examples.echo import mcp
 
1
  """Tests for example servers"""
2
 
 
3
  from mcp.types import (
4
  PromptMessage,
5
  TextContent,
 
10
  from fastmcp import Client
11
 
12
 
 
13
  async def test_simple_echo():
14
  """Test the simple echo server"""
15
  from examples.simple_echo import mcp
 
21
  assert result[0].text == "hello"
22
 
23
 
 
24
  async def test_complex_inputs():
25
  """Test the complex inputs server"""
26
  from examples.complex_inputs import mcp
 
35
  assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]'
36
 
37
 
 
38
  async def test_desktop(monkeypatch):
39
  """Test the desktop server"""
40
  from examples.desktop import mcp
 
54
  assert result[0].text == "Hello, rooter12!"
55
 
56
 
 
57
  async def test_echo():
58
  """Test the echo server"""
59
  from examples.echo import mcp