zzstoatzz commited on
Commit
57eddfb
·
1 Parent(s): d0a1792

rm slop markers

Browse files
.github/workflows/run-integration-tests.yml DELETED
@@ -1,50 +0,0 @@
1
- name: Run integration tests
2
-
3
- env:
4
- # enable colored output
5
- PY_COLORS: 1
6
-
7
- on:
8
- push:
9
- branches: ["main"]
10
- paths:
11
- - "src/**"
12
- - "integration_tests/**"
13
- - "uv.lock"
14
- - "pyproject.toml"
15
- - ".github/workflows/**"
16
-
17
- # run on all pull requests because these checks are required and will block merges otherwise
18
- pull_request:
19
-
20
- workflow_dispatch:
21
-
22
- permissions:
23
- contents: read
24
-
25
- jobs:
26
- run_tests:
27
- name: "Run tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}"
28
- runs-on: ${{ matrix.os }}
29
- strategy:
30
- matrix:
31
- os: [ubuntu-latest, windows-latest]
32
- python-version: ["3.10"]
33
- fail-fast: false
34
- timeout-minutes: 5
35
-
36
- steps:
37
- - uses: actions/checkout@v4
38
-
39
- - name: Install uv
40
- uses: astral-sh/setup-uv@v5
41
- with:
42
- enable-cache: true
43
- cache-dependency-glob: "uv.lock"
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 integration_tests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integration_tests/test_repro_518_output.py DELETED
@@ -1,89 +0,0 @@
1
- import os
2
- import subprocess
3
- import sys
4
- import tempfile
5
- import time
6
-
7
- import httpx
8
- import pytest
9
-
10
- PYTHON_EXE = sys.executable
11
-
12
- SERVER_CODE = """
13
- import uvicorn
14
- from fastapi import FastAPI
15
- from fastmcp import FastMCP
16
-
17
- mcp = FastMCP()
18
-
19
- @mcp.tool("dummy_tool", "A simple dummy tool for the test server")
20
- def add(a: int, b: int) -> int:
21
- return a + b
22
-
23
- app = FastAPI() # Intentionally no lifespan=mcp.lifespan
24
- app.mount("/", mcp.http_app(transport="streamable-http"))
25
-
26
- if __name__ == "__main__":
27
- uvicorn.run(app, host="0.0.0.0", port=8080, log_config=None)
28
- """
29
-
30
-
31
- @pytest.mark.timeout(20)
32
- def test_server_shows_informative_error_on_stderr():
33
- """
34
- Runs a minimal FastMCP+FastAPI server (that omits lifespan wiring)
35
- as a subprocess, triggers the error via an HTTP request, and then checks
36
- if the server's stderr contains the specific informative error message for issue #518.
37
- """
38
- process = None
39
- captured_stderr = ""
40
-
41
- with tempfile.NamedTemporaryFile(
42
- mode="w", suffix=".py", delete=False
43
- ) as tmp_script:
44
- tmp_script.write(SERVER_CODE)
45
- tmp_script_path = tmp_script.name
46
-
47
- try:
48
- process = subprocess.Popen(
49
- [PYTHON_EXE, "-u", tmp_script_path],
50
- stderr=subprocess.PIPE,
51
- stdout=subprocess.PIPE,
52
- text=True,
53
- universal_newlines=True,
54
- )
55
-
56
- time.sleep(3)
57
-
58
- if process.poll() is None:
59
- try:
60
- with httpx.Client(timeout=5.0) as client:
61
- # The mounted FastMCP app is at root, its internal default path is /mcp
62
- client.get("http://localhost:8080/mcp/")
63
- except httpx.RequestError:
64
- pass
65
- time.sleep(1)
66
-
67
- finally:
68
- if process:
69
- if process.poll() is None:
70
- process.terminate()
71
- try:
72
- _, captured_stderr = process.communicate(timeout=10)
73
- except subprocess.TimeoutExpired:
74
- process.kill()
75
- _, captured_stderr = process.communicate()
76
-
77
- if os.path.exists(tmp_script_path):
78
- os.unlink(tmp_script_path)
79
-
80
- assert captured_stderr is not None, "stderr should have been captured"
81
- normalized_stderr = captured_stderr.replace("\r\n", "\n").replace("\r", "\n")
82
-
83
- assert (
84
- "FastMCP's StreamableHTTPSessionManager task group was not initialized"
85
- in normalized_stderr
86
- )
87
- assert "lifespan=mcp_app.lifespan" in normalized_stderr
88
- assert "gofastmcp.com/deployment/asgi" in normalized_stderr
89
- assert "Original error: Task group is not initialized" in normalized_stderr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_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