Skip to content

Commit 45a5cd8

Browse files
author
chiliu
committed
add unit test for Streamable HTTP Trailing Slash Compatibility
1 parent 898fc88 commit 45a5cd8

File tree

2 files changed

+137
-120
lines changed

2 files changed

+137
-120
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Test FastMCP streamable_http_app mounts both /mcp and /mcp/ automatically."""
2+
3+
import pytest
4+
from starlette.testclient import TestClient
5+
from mcp.server.fastmcp import FastMCP
6+
7+
@pytest.fixture
8+
def fastmcp_app():
9+
mcp = FastMCP(name="TestServer")
10+
app = mcp.streamable_http_app()
11+
return app
12+
13+
def test_streamable_http_mount_dual_paths(fastmcp_app):
14+
client = TestClient(fastmcp_app)
15+
for path in ["/mcp", "/mcp/"]:
16+
# Should return 406 because Accept header is missing, but proves route exists
17+
resp = client.post(path, json={"jsonrpc": "2.0", "method": "initialize", "id": 1})
18+
assert resp.status_code in (400, 406) # 406 Not Acceptable or 400 Bad Request
19+
# Optionally, test GET as well
20+
resp_get = client.get(path)
21+
assert resp_get.status_code in (400, 406, 405) # 405 if GET not allowed

tests/shared/test_streamable_http.py

Lines changed: 116 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -598,141 +598,137 @@ def test_session_termination(basic_server, basic_server_url):
598598

599599
def test_response(basic_server, basic_server_url):
600600
"""Test response handling for a valid request."""
601-
mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"]
602-
for mcp_url in mcp_urls:
603-
response = requests.post(
604-
mcp_url,
605-
headers={
606-
"Accept": "application/json, text/event-stream",
607-
"Content-Type": "application/json",
608-
},
609-
json=INIT_REQUEST,
610-
)
611-
assert response.status_code == 200
612-
613-
# Now terminate the session
614-
session_id = response.headers.get(MCP_SESSION_ID_HEADER)
615-
616-
# Try to use the terminated session
617-
tools_response = requests.post(
618-
mcp_url,
619-
headers={
620-
"Accept": "application/json, text/event-stream",
621-
"Content-Type": "application/json",
622-
MCP_SESSION_ID_HEADER: session_id, # Use the session ID we got earlier
623-
},
624-
json={"jsonrpc": "2.0", "method": "tools/list", "id": "tools-1"},
625-
stream=True,
626-
)
627-
assert tools_response.status_code == 200
628-
assert tools_response.headers.get("Content-Type") == "text/event-stream"
601+
mcp_url = f"{basic_server_url}/mcp"
602+
response = requests.post(
603+
mcp_url,
604+
headers={
605+
"Accept": "application/json, text/event-stream",
606+
"Content-Type": "application/json",
607+
},
608+
json=INIT_REQUEST,
609+
)
610+
assert response.status_code == 200
611+
612+
# Now terminate the session
613+
session_id = response.headers.get(MCP_SESSION_ID_HEADER)
614+
615+
# Try to use the terminated session
616+
tools_response = requests.post(
617+
mcp_url,
618+
headers={
619+
"Accept": "application/json, text/event-stream",
620+
"Content-Type": "application/json",
621+
MCP_SESSION_ID_HEADER: session_id, # Use the session ID we got earlier
622+
},
623+
json={"jsonrpc": "2.0", "method": "tools/list", "id": "tools-1"},
624+
stream=True,
625+
)
626+
assert tools_response.status_code == 200
627+
assert tools_response.headers.get("Content-Type") == "text/event-stream"
629628

630629

631630
def test_json_response(json_response_server, json_server_url):
632631
"""Test response handling when is_json_response_enabled is True."""
633-
mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"]
634-
for mcp_url in mcp_urls:
635-
response = requests.post(
636-
mcp_url,
637-
headers={
638-
"Accept": "application/json, text/event-stream",
639-
"Content-Type": "application/json",
640-
},
641-
json=INIT_REQUEST,
642-
)
643-
assert response.status_code == 200
644-
assert response.headers.get("Content-Type") == "application/json"
632+
mcp_url = f"{json_server_url}/mcp"
633+
response = requests.post(
634+
mcp_url,
635+
headers={
636+
"Accept": "application/json, text/event-stream",
637+
"Content-Type": "application/json",
638+
},
639+
json=INIT_REQUEST,
640+
)
641+
assert response.status_code == 200
642+
assert response.headers.get("Content-Type") == "application/json"
645643

646644

647645
def test_get_sse_stream(basic_server, basic_server_url):
648646
"""Test establishing an SSE stream via GET request."""
649647
# First, we need to initialize a session
650-
mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"]
651-
for mcp_url in mcp_urls:
652-
init_response = requests.post(
653-
mcp_url,
654-
headers={
655-
"Accept": "application/json, text/event-stream",
656-
"Content-Type": "application/json",
657-
},
658-
json=INIT_REQUEST,
659-
)
660-
assert init_response.status_code == 200
661-
662-
# Get the session ID
663-
session_id = init_response.headers.get(MCP_SESSION_ID_HEADER)
664-
assert session_id is not None
665-
666-
# Now attempt to establish an SSE stream via GET
667-
get_response = requests.get(
668-
mcp_url,
669-
headers={
670-
"Accept": "text/event-stream",
671-
MCP_SESSION_ID_HEADER: session_id,
672-
},
673-
stream=True,
674-
)
648+
mcp_url = f"{basic_server_url}/mcp"
649+
init_response = requests.post(
650+
mcp_url,
651+
headers={
652+
"Accept": "application/json, text/event-stream",
653+
"Content-Type": "application/json",
654+
},
655+
json=INIT_REQUEST,
656+
)
657+
assert init_response.status_code == 200
675658

676-
# Verify we got a successful response with the right content type
677-
assert get_response.status_code == 200
678-
assert get_response.headers.get("Content-Type") == "text/event-stream"
679-
680-
# Test that a second GET request gets rejected (only one stream allowed)
681-
second_get = requests.get(
682-
mcp_url,
683-
headers={
684-
"Accept": "text/event-stream",
685-
MCP_SESSION_ID_HEADER: session_id,
686-
},
687-
stream=True,
688-
)
659+
# Get the session ID
660+
session_id = init_response.headers.get(MCP_SESSION_ID_HEADER)
661+
assert session_id is not None
689662

690-
# Should get CONFLICT (409) since there's already a stream
691-
# Note: This might fail if the first stream fully closed before this runs,
692-
# but generally it should work in the test environment where it runs quickly
693-
assert second_get.status_code == 409
663+
# Now attempt to establish an SSE stream via GET
664+
get_response = requests.get(
665+
mcp_url,
666+
headers={
667+
"Accept": "text/event-stream",
668+
MCP_SESSION_ID_HEADER: session_id,
669+
},
670+
stream=True,
671+
)
672+
673+
# Verify we got a successful response with the right content type
674+
assert get_response.status_code == 200
675+
assert get_response.headers.get("Content-Type") == "text/event-stream"
676+
677+
# Test that a second GET request gets rejected (only one stream allowed)
678+
second_get = requests.get(
679+
mcp_url,
680+
headers={
681+
"Accept": "text/event-stream",
682+
MCP_SESSION_ID_HEADER: session_id,
683+
},
684+
stream=True,
685+
)
686+
687+
# Should get CONFLICT (409) since there's already a stream
688+
# Note: This might fail if the first stream fully closed before this runs,
689+
# but generally it should work in the test environment where it runs quickly
690+
assert second_get.status_code == 409
694691

695692

696693
def test_get_validation(basic_server, basic_server_url):
697694
"""Test validation for GET requests."""
698695
# First, we need to initialize a session
699-
mcp_urls = [f"{basic_server_url}/mcp", f"{basic_server_url}/mcp/"]
700-
for mcp_url in mcp_urls:
701-
init_response = requests.post(
702-
mcp_url,
703-
headers={
704-
"Accept": "application/json, text/event-stream",
705-
"Content-Type": "application/json",
706-
},
707-
json=INIT_REQUEST,
708-
)
709-
assert init_response.status_code == 200
710-
711-
# Get the session ID
712-
session_id = init_response.headers.get(MCP_SESSION_ID_HEADER)
713-
assert session_id is not None
714-
715-
# Test without Accept header
716-
response = requests.get(
717-
mcp_url,
718-
headers={
719-
MCP_SESSION_ID_HEADER: session_id,
720-
},
721-
stream=True,
722-
)
723-
assert response.status_code == 406
724-
assert "Not Acceptable" in response.text
725-
726-
# Test with wrong Accept header
727-
response = requests.get(
728-
mcp_url,
729-
headers={
730-
"Accept": "application/json",
731-
MCP_SESSION_ID_HEADER: session_id,
732-
},
733-
)
734-
assert response.status_code == 406
735-
assert "Not Acceptable" in response.text
696+
mcp_url = f"{basic_server_url}/mcp"
697+
init_response = requests.post(
698+
mcp_url,
699+
headers={
700+
"Accept": "application/json, text/event-stream",
701+
"Content-Type": "application/json",
702+
},
703+
json=INIT_REQUEST,
704+
)
705+
assert init_response.status_code == 200
706+
707+
# Get the session ID
708+
session_id = init_response.headers.get(MCP_SESSION_ID_HEADER)
709+
assert session_id is not None
710+
711+
# Test without Accept header
712+
response = requests.get(
713+
mcp_url,
714+
headers={
715+
MCP_SESSION_ID_HEADER: session_id,
716+
},
717+
stream=True,
718+
)
719+
assert response.status_code == 406
720+
assert "Not Acceptable" in response.text
721+
722+
# Test with wrong Accept header
723+
response = requests.get(
724+
mcp_url,
725+
headers={
726+
"Accept": "application/json",
727+
MCP_SESSION_ID_HEADER: session_id,
728+
},
729+
)
730+
assert response.status_code == 406
731+
assert "Not Acceptable" in response.text
736732

737733

738734
# Client-specific fixtures
@@ -1226,4 +1222,4 @@ async def sampling_callback(
12261222
assert (
12271223
captured_message_params.messages[0].content.text
12281224
== "Server needs client sampling"
1229-
)
1225+
)

0 commit comments

Comments
 (0)