From bdc3e395e11c39919865c1e1d0d9f2f4a8888dc2 Mon Sep 17 00:00:00 2001 From: Alexander Seeliger Date: Sun, 15 Jun 2025 17:47:09 +0200 Subject: [PATCH 1/5] fix: missing related_request_id for report_progress --- src/fastmcp/server/context.py | 1 + tests/client/test_streamable_http.py | 34 ++- .../client/test_streamable_http_stateless.py | 195 ++++++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 tests/client/test_streamable_http_stateless.py diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 74af8be83..145b127fe 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -122,6 +122,7 @@ class Context: progress=progress, total=total, message=message, + related_request_id=self.request_id, ) async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 11f4b4e33..bcbf9feee 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -6,9 +6,11 @@ from collections.abc import AsyncGenerator import pytest import uvicorn from mcp import McpError +from mcp.types import TextContent from starlette.applications import Starlette from starlette.routing import Mount +from fastmcp import Context from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.dependencies import get_http_request @@ -22,7 +24,7 @@ def fastmcp_server(): # Add a tool @server.tool - def greet(name: str) -> str: + async def greet(name: str) -> str: """Greet someone by name.""" return f"Hello, {name}!" @@ -38,6 +40,12 @@ def fastmcp_server(): await asyncio.sleep(seconds) return f"Slept for {seconds} seconds" + @server.tool + async def greet_with_progress(name: str, ctx: Context) -> str: + """Report progress for a greeting.""" + await ctx.report_progress(0.5, 1.0, "Greeting in progress") + return f"Hello, {name}!" + # Add a resource @server.resource(uri="data://users") async def get_users(): @@ -95,6 +103,14 @@ async def streamable_http_server() -> AsyncGenerator[str, None]: yield f"{url}/mcp" +PROGRESS_MESSAGES = [] + +async def progress_handler( + progress: float, total: float | None, message: str | None +) -> None: + PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message)) + + async def test_ping(streamable_http_server: str): """Test pinging the server.""" async with Client( @@ -117,6 +133,22 @@ async def test_http_headers(streamable_http_server: str): assert json_result["x-demo-header"] == "ABC" +async def test_greet_with_progress_tool(streamable_http_server: str): + """Test calling the greet tool.""" + async with Client( + transport=StreamableHttpTransport(streamable_http_server), progress_handler=progress_handler + ) as client: + result = await client.call_tool("greet_with_progress", {"name": "Alice"}) + + assert isinstance(result, list) + assert isinstance(result[0], TextContent) + assert result[0].text == "Hello, Alice!" + + assert PROGRESS_MESSAGES == [ + dict(progress=0.5, total=1.0, message="Greeting in progress"), + ] + + async def test_nested_streamable_http_server_resolves_correctly(): # tests patch for # https://github.com/modelcontextprotocol/python-sdk/pull/659 diff --git a/tests/client/test_streamable_http_stateless.py b/tests/client/test_streamable_http_stateless.py new file mode 100644 index 000000000..99d6c0e7e --- /dev/null +++ b/tests/client/test_streamable_http_stateless.py @@ -0,0 +1,195 @@ +import asyncio +import json +import sys +from collections.abc import AsyncGenerator + +import pytest +import uvicorn +from mcp import McpError +from mcp.types import TextContent +from starlette.applications import Starlette +from starlette.routing import Mount + +from fastmcp import Context +from fastmcp.client import Client +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.server.dependencies import get_http_request +from fastmcp.server.server import FastMCP +from fastmcp.utilities.tests import run_server_in_process + + +def fastmcp_server(): + """Fixture that creates a FastMCP server with tools, resources, and prompts.""" + server = FastMCP("TestServer") + server.settings.stateless_http = True + + # Add a tool + @server.tool + async def greet(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + # Add a second tool + @server.tool + def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + @server.tool + async def sleep(seconds: float) -> str: + """Sleep for a given number of seconds.""" + await asyncio.sleep(seconds) + return f"Slept for {seconds} seconds" + + @server.tool + async def greet_with_progress(name: str, ctx: Context) -> str: + """Report progress for a greeting.""" + await ctx.report_progress(0.5, 1.0, "Greeting in progress") + return f"Hello, {name}!" + + # Add a resource + @server.resource(uri="data://users") + async def get_users(): + return ["Alice", "Bob", "Charlie"] + + # Add a resource template + @server.resource(uri="data://user/{user_id}") + async def get_user(user_id: str): + return {"id": user_id, "name": f"User {user_id}", "active": True} + + @server.resource(uri="request://headers") + async def get_headers() -> dict[str, str]: + request = get_http_request() + + return dict(request.headers) + + # Add a prompt + @server.prompt + def welcome(name: str) -> str: + """Example greeting prompt.""" + return f"Welcome to FastMCP, {name}!" + + return server + + +def run_server(host: str, port: int, **kwargs) -> None: + fastmcp_server().run(host=host, port=port, **kwargs) + + +def run_nested_server(host: str, port: int) -> None: + mcp_app = fastmcp_server().http_app(path="/final/mcp") + + mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)]) + mount2 = Starlette( + routes=[Mount("/nest-outer", app=mount)], + lifespan=mcp_app.lifespan, + ) + server = uvicorn.Server( + config=uvicorn.Config( + app=mount2, + host=host, + port=port, + log_level="error", + lifespan="on", + ) + ) + server.run() + + +@pytest.fixture() +async def streamable_http_server() -> AsyncGenerator[str, None]: + with run_server_in_process(run_server, transport="streamable-http") as url: + async with Client(transport=StreamableHttpTransport(f"{url}/mcp")) as client: + assert await client.ping() + yield f"{url}/mcp" + + +PROGRESS_MESSAGES = [] + +async def progress_handler( + progress: float, total: float | None, message: str | None +) -> None: + PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message)) + + +async def test_ping(streamable_http_server: str): + """Test pinging the server.""" + async with Client( + transport=StreamableHttpTransport(streamable_http_server) + ) as client: + result = await client.ping() + assert result is True + + +async def test_http_headers(streamable_http_server: str): + """Test getting HTTP headers from the server.""" + async with Client( + transport=StreamableHttpTransport( + streamable_http_server, headers={"X-DEMO-HEADER": "ABC"} + ) + ) as client: + raw_result = await client.read_resource("request://headers") + json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined] + assert "x-demo-header" in json_result + assert json_result["x-demo-header"] == "ABC" + + +async def test_greet_with_progress_tool(streamable_http_server: str): + """Test calling the greet tool.""" + async with Client( + transport=StreamableHttpTransport(streamable_http_server), progress_handler=progress_handler + ) as client: + result = await client.call_tool("greet_with_progress", {"name": "Alice"}) + + assert isinstance(result, list) + assert isinstance(result[0], TextContent) + assert result[0].text == "Hello, Alice!" + + assert PROGRESS_MESSAGES == [ + dict(progress=0.5, total=1.0, message="Greeting in progress"), + ] + + +async def test_nested_streamable_http_server_resolves_correctly(): + # tests patch for + # https://github.com/modelcontextprotocol/python-sdk/pull/659 + + with run_server_in_process(run_nested_server) as url: + async with Client( + transport=StreamableHttpTransport(f"{url}/nest-outer/nest-inner/final/mcp") + ) as client: + result = await client.ping() + assert result is True + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", +) +class TestTimeout: + async def test_timeout(self, streamable_http_server: str): + # note this transport behaves differently than others and raises + # McpError from the *client* context + with pytest.raises(McpError, match="Timed out"): + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + timeout=0.1, + ) as client: + await client.call_tool("sleep", {"seconds": 0.2}) + + async def test_timeout_tool_call(self, streamable_http_server: str): + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + ) as client: + with pytest.raises(McpError): + await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1) + + async def test_timeout_tool_call_overrides_client_timeout( + self, streamable_http_server: str + ): + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + timeout=2, + ) as client: + with pytest.raises(McpError): + await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1) From 92d3cffa534fa2377b10056841da16c7b133ac12 Mon Sep 17 00:00:00 2001 From: Alexander Seeliger Date: Sun, 15 Jun 2025 17:52:13 +0200 Subject: [PATCH 2/5] chore: remove other tests --- .../client/test_streamable_http_stateless.py | 135 ------------------ 1 file changed, 135 deletions(-) diff --git a/tests/client/test_streamable_http_stateless.py b/tests/client/test_streamable_http_stateless.py index 99d6c0e7e..fd55c752e 100644 --- a/tests/client/test_streamable_http_stateless.py +++ b/tests/client/test_streamable_http_stateless.py @@ -1,19 +1,11 @@ -import asyncio -import json -import sys from collections.abc import AsyncGenerator import pytest -import uvicorn -from mcp import McpError from mcp.types import TextContent -from starlette.applications import Starlette -from starlette.routing import Mount from fastmcp import Context from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.server.dependencies import get_http_request from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process @@ -23,52 +15,12 @@ def fastmcp_server(): server = FastMCP("TestServer") server.settings.stateless_http = True - # Add a tool - @server.tool - async def greet(name: str) -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - # Add a second tool - @server.tool - def add(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - @server.tool - async def sleep(seconds: float) -> str: - """Sleep for a given number of seconds.""" - await asyncio.sleep(seconds) - return f"Slept for {seconds} seconds" - @server.tool async def greet_with_progress(name: str, ctx: Context) -> str: """Report progress for a greeting.""" await ctx.report_progress(0.5, 1.0, "Greeting in progress") return f"Hello, {name}!" - # Add a resource - @server.resource(uri="data://users") - async def get_users(): - return ["Alice", "Bob", "Charlie"] - - # Add a resource template - @server.resource(uri="data://user/{user_id}") - async def get_user(user_id: str): - return {"id": user_id, "name": f"User {user_id}", "active": True} - - @server.resource(uri="request://headers") - async def get_headers() -> dict[str, str]: - request = get_http_request() - - return dict(request.headers) - - # Add a prompt - @server.prompt - def welcome(name: str) -> str: - """Example greeting prompt.""" - return f"Welcome to FastMCP, {name}!" - return server @@ -76,26 +28,6 @@ def run_server(host: str, port: int, **kwargs) -> None: fastmcp_server().run(host=host, port=port, **kwargs) -def run_nested_server(host: str, port: int) -> None: - mcp_app = fastmcp_server().http_app(path="/final/mcp") - - mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)]) - mount2 = Starlette( - routes=[Mount("/nest-outer", app=mount)], - lifespan=mcp_app.lifespan, - ) - server = uvicorn.Server( - config=uvicorn.Config( - app=mount2, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - - @pytest.fixture() async def streamable_http_server() -> AsyncGenerator[str, None]: with run_server_in_process(run_server, transport="streamable-http") as url: @@ -112,28 +44,6 @@ async def progress_handler( PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message)) -async def test_ping(streamable_http_server: str): - """Test pinging the server.""" - async with Client( - transport=StreamableHttpTransport(streamable_http_server) - ) as client: - result = await client.ping() - assert result is True - - -async def test_http_headers(streamable_http_server: str): - """Test getting HTTP headers from the server.""" - async with Client( - transport=StreamableHttpTransport( - streamable_http_server, headers={"X-DEMO-HEADER": "ABC"} - ) - ) as client: - raw_result = await client.read_resource("request://headers") - json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined] - assert "x-demo-header" in json_result - assert json_result["x-demo-header"] == "ABC" - - async def test_greet_with_progress_tool(streamable_http_server: str): """Test calling the greet tool.""" async with Client( @@ -148,48 +58,3 @@ async def test_greet_with_progress_tool(streamable_http_server: str): assert PROGRESS_MESSAGES == [ dict(progress=0.5, total=1.0, message="Greeting in progress"), ] - - -async def test_nested_streamable_http_server_resolves_correctly(): - # tests patch for - # https://github.com/modelcontextprotocol/python-sdk/pull/659 - - with run_server_in_process(run_nested_server) as url: - async with Client( - transport=StreamableHttpTransport(f"{url}/nest-outer/nest-inner/final/mcp") - ) as client: - result = await client.ping() - assert result is True - - -@pytest.mark.skipif( - sys.platform == "win32", - reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", -) -class TestTimeout: - async def test_timeout(self, streamable_http_server: str): - # note this transport behaves differently than others and raises - # McpError from the *client* context - with pytest.raises(McpError, match="Timed out"): - async with Client( - transport=StreamableHttpTransport(streamable_http_server), - timeout=0.1, - ) as client: - await client.call_tool("sleep", {"seconds": 0.2}) - - async def test_timeout_tool_call(self, streamable_http_server: str): - async with Client( - transport=StreamableHttpTransport(streamable_http_server), - ) as client: - with pytest.raises(McpError): - await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1) - - async def test_timeout_tool_call_overrides_client_timeout( - self, streamable_http_server: str - ): - async with Client( - transport=StreamableHttpTransport(streamable_http_server), - timeout=2, - ) as client: - with pytest.raises(McpError): - await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1) From 64bf0a4112858305e87f836cacd646a062c025a8 Mon Sep 17 00:00:00 2001 From: Alexander Seeliger Date: Sun, 15 Jun 2025 18:11:43 +0200 Subject: [PATCH 3/5] chore: clean up tests --- tests/client/test_streamable_http.py | 26 ++++---- .../client/test_streamable_http_stateless.py | 60 ------------------- 2 files changed, 11 insertions(+), 75 deletions(-) delete mode 100644 tests/client/test_streamable_http_stateless.py diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index bcbf9feee..237316313 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -2,6 +2,7 @@ import asyncio import json import sys from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock import pytest import uvicorn @@ -71,8 +72,10 @@ def fastmcp_server(): return server -def run_server(host: str, port: int, **kwargs) -> None: - fastmcp_server().run(host=host, port=port, **kwargs) +def run_server(host: str, port: int, stateless_http: bool = False, **kwargs) -> None: + server = fastmcp_server() + server.settings.stateless_http = stateless_http + server.run(host=host, port=port, **kwargs) def run_nested_server(host: str, port: int) -> None: @@ -96,21 +99,13 @@ def run_nested_server(host: str, port: int) -> None: @pytest.fixture() -async def streamable_http_server() -> AsyncGenerator[str, None]: - with run_server_in_process(run_server, transport="streamable-http") as url: +async def streamable_http_server(stateless_http: bool = False) -> AsyncGenerator[str, None]: + with run_server_in_process(run_server, stateless_http=stateless_http, transport="streamable-http") as url: async with Client(transport=StreamableHttpTransport(f"{url}/mcp")) as client: assert await client.ping() yield f"{url}/mcp" -PROGRESS_MESSAGES = [] - -async def progress_handler( - progress: float, total: float | None, message: str | None -) -> None: - PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message)) - - async def test_ping(streamable_http_server: str): """Test pinging the server.""" async with Client( @@ -133,8 +128,11 @@ async def test_http_headers(streamable_http_server: str): assert json_result["x-demo-header"] == "ABC" +@pytest.mark.parametrize("streamable_http_server", [True, False], indirect=True) async def test_greet_with_progress_tool(streamable_http_server: str): """Test calling the greet tool.""" + progress_handler = AsyncMock(return_value=None) + async with Client( transport=StreamableHttpTransport(streamable_http_server), progress_handler=progress_handler ) as client: @@ -144,9 +142,7 @@ async def test_greet_with_progress_tool(streamable_http_server: str): assert isinstance(result[0], TextContent) assert result[0].text == "Hello, Alice!" - assert PROGRESS_MESSAGES == [ - dict(progress=0.5, total=1.0, message="Greeting in progress"), - ] + progress_handler.assert_called_once_with(0.5, 1.0, "Greeting in progress") async def test_nested_streamable_http_server_resolves_correctly(): diff --git a/tests/client/test_streamable_http_stateless.py b/tests/client/test_streamable_http_stateless.py deleted file mode 100644 index fd55c752e..000000000 --- a/tests/client/test_streamable_http_stateless.py +++ /dev/null @@ -1,60 +0,0 @@ -from collections.abc import AsyncGenerator - -import pytest -from mcp.types import TextContent - -from fastmcp import Context -from fastmcp.client import Client -from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.server.server import FastMCP -from fastmcp.utilities.tests import run_server_in_process - - -def fastmcp_server(): - """Fixture that creates a FastMCP server with tools, resources, and prompts.""" - server = FastMCP("TestServer") - server.settings.stateless_http = True - - @server.tool - async def greet_with_progress(name: str, ctx: Context) -> str: - """Report progress for a greeting.""" - await ctx.report_progress(0.5, 1.0, "Greeting in progress") - return f"Hello, {name}!" - - return server - - -def run_server(host: str, port: int, **kwargs) -> None: - fastmcp_server().run(host=host, port=port, **kwargs) - - -@pytest.fixture() -async def streamable_http_server() -> AsyncGenerator[str, None]: - with run_server_in_process(run_server, transport="streamable-http") as url: - async with Client(transport=StreamableHttpTransport(f"{url}/mcp")) as client: - assert await client.ping() - yield f"{url}/mcp" - - -PROGRESS_MESSAGES = [] - -async def progress_handler( - progress: float, total: float | None, message: str | None -) -> None: - PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message)) - - -async def test_greet_with_progress_tool(streamable_http_server: str): - """Test calling the greet tool.""" - async with Client( - transport=StreamableHttpTransport(streamable_http_server), progress_handler=progress_handler - ) as client: - result = await client.call_tool("greet_with_progress", {"name": "Alice"}) - - assert isinstance(result, list) - assert isinstance(result[0], TextContent) - assert result[0].text == "Hello, Alice!" - - assert PROGRESS_MESSAGES == [ - dict(progress=0.5, total=1.0, message="Greeting in progress"), - ] From 68567e5b5940ae07f6023d424a5f0568ea6a48d2 Mon Sep 17 00:00:00 2001 From: Alexander Seeliger Date: Sun, 15 Jun 2025 18:12:14 +0200 Subject: [PATCH 4/5] chore: revert change --- tests/client/test_streamable_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 237316313..8024f258f 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -25,7 +25,7 @@ def fastmcp_server(): # Add a tool @server.tool - async def greet(name: str) -> str: + def greet(name: str) -> str: """Greet someone by name.""" return f"Hello, {name}!" From ec24a07060b0c0c12c38c9fd6443dd7519ab1e51 Mon Sep 17 00:00:00 2001 From: Alexander Seeliger Date: Sun, 15 Jun 2025 18:25:55 +0200 Subject: [PATCH 5/5] chore: fix format --- tests/client/test_streamable_http.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 8024f258f..7e95e27b2 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -99,8 +99,12 @@ def run_nested_server(host: str, port: int) -> None: @pytest.fixture() -async def streamable_http_server(stateless_http: bool = False) -> AsyncGenerator[str, None]: - with run_server_in_process(run_server, stateless_http=stateless_http, transport="streamable-http") as url: +async def streamable_http_server( + stateless_http: bool = False, +) -> AsyncGenerator[str, None]: + with run_server_in_process( + run_server, stateless_http=stateless_http, transport="streamable-http" + ) as url: async with Client(transport=StreamableHttpTransport(f"{url}/mcp")) as client: assert await client.ping() yield f"{url}/mcp" @@ -134,7 +138,8 @@ async def test_greet_with_progress_tool(streamable_http_server: str): progress_handler = AsyncMock(return_value=None) async with Client( - transport=StreamableHttpTransport(streamable_http_server), progress_handler=progress_handler + transport=StreamableHttpTransport(streamable_http_server), + progress_handler=progress_handler, ) as client: result = await client.call_tool("greet_with_progress", {"name": "Alice"})