Skip to content

Commit 1099d6a

Browse files
authored
Merge branch 'main' into main
2 parents 3e8365b + d28a1a6 commit 1099d6a

File tree

3 files changed

+128
-7
lines changed

3 files changed

+128
-7
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,6 @@ async def sse_endpoint(request: Request) -> Response:
828828
def streamable_http_app(self) -> Starlette:
829829
"""Return an instance of the StreamableHTTP server app."""
830830
from starlette.middleware import Middleware
831-
from starlette.routing import Mount
832831

833832
# Create session manager on first call (lazy initialization)
834833
if self._session_manager is None:
@@ -841,8 +840,7 @@ def streamable_http_app(self) -> Starlette:
841840
)
842841

843842
# Create the ASGI handler
844-
async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
845-
await self.session_manager.handle_request(scope, receive, send)
843+
streamable_http_app = StreamableHTTPASGIApp(self._session_manager)
846844

847845
# Create routes
848846
routes: list[Route | Mount] = []
@@ -889,17 +887,17 @@ async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) ->
889887
)
890888

891889
routes.append(
892-
Mount(
890+
Route(
893891
self.settings.streamable_http_path,
894-
app=RequireAuthMiddleware(handle_streamable_http, required_scopes, resource_metadata_url),
892+
endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url),
895893
)
896894
)
897895
else:
898896
# Auth is disabled, no wrapper needed
899897
routes.append(
900-
Mount(
898+
Route(
901899
self.settings.streamable_http_path,
902-
app=handle_streamable_http,
900+
endpoint=streamable_http_app,
903901
)
904902
)
905903

@@ -972,6 +970,18 @@ async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -
972970
raise ValueError(str(e))
973971

974972

973+
class StreamableHTTPASGIApp:
974+
"""
975+
ASGI application for Streamable HTTP server transport.
976+
"""
977+
978+
def __init__(self, session_manager: StreamableHTTPSessionManager):
979+
self.session_manager = session_manager
980+
981+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
982+
await self.session_manager.handle_request(scope, receive, send)
983+
984+
975985
class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
976986
"""Context object providing access to MCP capabilities.
977987

tests/server/fastmcp/test_func_metadata.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,98 @@ def func_with_str_and_int(a: str, b: int):
454454
assert result["b"] == 123
455455

456456

457+
def test_str_annotation_preserves_json_string():
458+
"""
459+
Regression test for PR #1113: Ensure that when a parameter is annotated as str,
460+
valid JSON strings are NOT parsed into Python objects.
461+
462+
This test would fail before the fix (JSON string would be parsed to dict)
463+
and passes after the fix (JSON string remains as string).
464+
"""
465+
466+
def process_json_config(config: str, enabled: bool = True) -> str:
467+
"""Function that expects a JSON string as a string parameter."""
468+
# In real use, this function might validate or transform the JSON string
469+
# before parsing it, or pass it to another service as-is
470+
return f"Processing config: {config}"
471+
472+
meta = func_metadata(process_json_config)
473+
474+
# Test case 1: JSON object as string
475+
json_obj_str = '{"database": "postgres", "port": 5432}'
476+
result = meta.pre_parse_json({"config": json_obj_str, "enabled": True})
477+
478+
# The config parameter should remain as a string, NOT be parsed to a dict
479+
assert isinstance(result["config"], str)
480+
assert result["config"] == json_obj_str
481+
482+
# Test case 2: JSON array as string
483+
json_array_str = '["item1", "item2", "item3"]'
484+
result = meta.pre_parse_json({"config": json_array_str})
485+
486+
# Should remain as string
487+
assert isinstance(result["config"], str)
488+
assert result["config"] == json_array_str
489+
490+
# Test case 3: JSON string value (double-encoded)
491+
json_string_str = '"This is a JSON string"'
492+
result = meta.pre_parse_json({"config": json_string_str})
493+
494+
# Should remain as the original string with quotes
495+
assert isinstance(result["config"], str)
496+
assert result["config"] == json_string_str
497+
498+
# Test case 4: Complex nested JSON as string
499+
complex_json_str = '{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], "count": 2}'
500+
result = meta.pre_parse_json({"config": complex_json_str})
501+
502+
# Should remain as string
503+
assert isinstance(result["config"], str)
504+
assert result["config"] == complex_json_str
505+
506+
507+
@pytest.mark.anyio
508+
async def test_str_annotation_runtime_validation():
509+
"""
510+
Regression test for PR #1113: Test runtime validation with string parameters
511+
containing valid JSON to ensure they are passed as strings, not parsed objects.
512+
"""
513+
514+
def handle_json_payload(payload: str, strict_mode: bool = False) -> str:
515+
"""Function that processes a JSON payload as a string."""
516+
# This function expects to receive the raw JSON string
517+
# It might parse it later after validation or logging
518+
assert isinstance(payload, str), f"Expected str, got {type(payload)}"
519+
return f"Handled payload of length {len(payload)}"
520+
521+
meta = func_metadata(handle_json_payload)
522+
523+
# Test with a JSON object string
524+
json_payload = '{"action": "create", "resource": "user", "data": {"name": "Test User"}}'
525+
526+
result = await meta.call_fn_with_arg_validation(
527+
handle_json_payload,
528+
fn_is_async=False,
529+
arguments_to_validate={"payload": json_payload, "strict_mode": True},
530+
arguments_to_pass_directly=None,
531+
)
532+
533+
# The function should have received the string and returned successfully
534+
assert result == f"Handled payload of length {len(json_payload)}"
535+
536+
# Test with JSON array string
537+
json_array_payload = '["task1", "task2", "task3"]'
538+
539+
result = await meta.call_fn_with_arg_validation(
540+
handle_json_payload,
541+
fn_is_async=False,
542+
arguments_to_validate={"payload": json_array_payload},
543+
arguments_to_pass_directly=None,
544+
)
545+
546+
assert result == f"Handled payload of length {len(json_array_payload)}"
547+
548+
457549
# Tests for structured output functionality
458550

459551

tests/server/fastmcp/test_server.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,3 +1072,22 @@ def prompt_fn(name: str) -> str:
10721072
async with client_session(mcp._mcp_server) as client:
10731073
with pytest.raises(McpError, match="Missing required arguments"):
10741074
await client.get_prompt("prompt_fn")
1075+
1076+
1077+
def test_streamable_http_no_redirect() -> None:
1078+
"""Test that streamable HTTP routes are correctly configured."""
1079+
mcp = FastMCP()
1080+
app = mcp.streamable_http_app()
1081+
1082+
# Find routes by type - streamable_http_app creates Route objects, not Mount objects
1083+
streamable_routes = [
1084+
r
1085+
for r in app.routes
1086+
if isinstance(r, Route) and hasattr(r, "path") and r.path == mcp.settings.streamable_http_path
1087+
]
1088+
1089+
# Verify routes exist
1090+
assert len(streamable_routes) == 1, "Should have one streamable route"
1091+
1092+
# Verify path values
1093+
assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp"

0 commit comments

Comments
 (0)