From 6a7e4e25f32de13b148d1865ee397b185aba6a0c Mon Sep 17 00:00:00 2001 From: "Matt C. Wilson" Date: Thu, 15 May 2025 21:06:40 -0400 Subject: [PATCH 1/8] doc(asgi): Change custom route example to PlainTextResponse --- docs/deployment/asgi.mdx | 10 +++++----- docs/deployment/running-server.mdx | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/deployment/asgi.mdx b/docs/deployment/asgi.mdx index f676ff68a..fe613fd1b 100644 --- a/docs/deployment/asgi.mdx +++ b/docs/deployment/asgi.mdx @@ -43,7 +43,7 @@ http_app = mcp.http_app() sse_app = mcp.http_app(transport="sse") ``` -Both approaches return a Starlette application that can be integrated with other ASGI-compatible web frameworks. +Both approaches return a Starlette application that can be integrated with other ASGI-compatible web frameworks. The MCP server's endpoint is mounted at the root path `/mcp` for Streamable HTTP transport, and `/sse` for SSE transport, though you can change these paths by passing a `path` argument to the `http_app()` method: @@ -199,13 +199,13 @@ In addition to adding your FastMCP server to an existing ASGI app, you can also ```python from fastmcp import FastMCP from starlette.requests import Request -from starlette.responses import JSONResponse +from starlette.responses import PlainTextResponse mcp = FastMCP("MyServer") @mcp.custom_route("/health", methods=["GET"]) -async def health_check(request: Request) -> JSONResponse: - return JSONResponse({"status": "healthy"}) +async def health_check(request: Request) -> PlainTextResponse: + return PlainTextResponse("OK") ``` -These routes will be included in the FastMCP app when mounted in your web application. \ No newline at end of file +These routes will be included in the FastMCP app when mounted in your web application. \ No newline at end of file diff --git a/docs/deployment/running-server.mdx b/docs/deployment/running-server.mdx index 516846ae4..64f31d134 100644 --- a/docs/deployment/running-server.mdx +++ b/docs/deployment/running-server.mdx @@ -29,7 +29,7 @@ def hello(name: str) -> str: if __name__ == "__main__": mcp.run() ``` -You can now run this MCP server by executing `python my_server.py`. +You can now run this MCP server by executing `python my_server.py`. MCP servers can be run with a variety of different transport options, depending on your application's requirements. The `run()` method can take a `transport` argument and other transport-specific keyword arguments to configure how the server operates. @@ -260,13 +260,13 @@ You can also add custom web routes to your FastMCP server, which will be exposed ```python from fastmcp import FastMCP from starlette.requests import Request -from starlette.responses import JSONResponse +from starlette.responses import PlainTextResponse mcp = FastMCP("MyServer") @mcp.custom_route("/health", methods=["GET"]) -async def health_check(request: Request) -> JSONResponse: - return JSONResponse({"status": "healthy"}) +async def health_check(request: Request) -> PlainTextResponse: + return PlainTextResponse("OK")return JSONResponse({"status": "healthy"}) if __name__ == "__main__": mcp.run() From 0519e8d9ee59fd5411e75bb6aa8fdf293eb250b5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 15 May 2025 21:39:25 -0400 Subject: [PATCH 2/8] Update docs/deployment/running-server.mdx Co-authored-by: nate nowack --- docs/deployment/running-server.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment/running-server.mdx b/docs/deployment/running-server.mdx index 64f31d134..58f9852bf 100644 --- a/docs/deployment/running-server.mdx +++ b/docs/deployment/running-server.mdx @@ -266,7 +266,7 @@ mcp = FastMCP("MyServer") @mcp.custom_route("/health", methods=["GET"]) async def health_check(request: Request) -> PlainTextResponse: - return PlainTextResponse("OK")return JSONResponse({"status": "healthy"}) + return PlainTextResponse("OK") if __name__ == "__main__": mcp.run() From 6ee03286d0c6c18768617fee377182511ca8d6ad Mon Sep 17 00:00:00 2001 From: Mai Nakagawa Date: Fri, 16 May 2025 11:22:55 +0900 Subject: [PATCH 3/8] Fix import statements --- docs/getting-started/quickstart.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 1425edd72..13a6bbae5 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -72,7 +72,7 @@ There are a few things to note here: In order to run the server with Python, we need to add a `run` statement to the `__main__` block of the server file. ```python my_server.py {9-10} -from fastmcp import FastMCP, Client +from fastmcp import FastMCP mcp = FastMCP("My MCP Server") @@ -99,6 +99,7 @@ Now that the server can be executed with `python my_server.py`, we can interact In a new file, create a client and point it at the server file: ```python my_client.py +import asyncio from fastmcp import Client client = Client("my_server.py") From c3e9bdb49e41ffb78566b9babc11d933ba06c8bc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 16 May 2025 10:51:48 -0400 Subject: [PATCH 4/8] Add lifespan property to app --- docs/deployment/asgi.mdx | 6 +++--- src/fastmcp/server/http.py | 16 +++++++++++----- src/fastmcp/server/server.py | 15 ++++++++------- tests/client/test_streamable_http.py | 2 +- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/deployment/asgi.mdx b/docs/deployment/asgi.mdx index fe613fd1b..6fc5624fb 100644 --- a/docs/deployment/asgi.mdx +++ b/docs/deployment/asgi.mdx @@ -124,7 +124,7 @@ app = Starlette( Mount("/mcp-server", app=mcp_app), # Add other routes as needed ], - lifespan=mcp_app.router.lifespan_context, + lifespan=mcp_app.lifespan, ) ``` @@ -154,7 +154,7 @@ mcp_app = mcp.http_app(path='/mcp') inner_app = Starlette(routes=[Mount("/inner", app=mcp_app)]) app = Starlette( routes=[Mount("/outer", app=inner_app)], - lifespan=mcp_app.router.lifespan_context, + lifespan=mcp_app.lifespan, ) ``` @@ -181,7 +181,7 @@ mcp = FastMCP("MyServer") mcp_app = mcp.http_app(path='/mcp') # Create a FastAPI app and mount the MCP server -app = FastAPI(lifespan=mcp_app.router.lifespan_context) +app = FastAPI(lifespan=mcp_app.lifespan) app.mount("/mcp-server", mcp_app) ``` diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 8dbeacfee..2437e85f8 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -27,7 +27,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request from starlette.responses import Response from starlette.routing import BaseRoute, Mount, Route -from starlette.types import Receive, Scope, Send +from starlette.types import Lifespan, Receive, Scope, Send from fastmcp.utilities.logging import get_logger @@ -43,6 +43,12 @@ _current_http_request: ContextVar[Request | None] = ContextVar( ) +class StarletteWithLifespan(Starlette): + @property + def lifespan(self) -> Lifespan: + return self.router.lifespan_context + + @contextmanager def set_http_request(request: Request) -> Generator[Request, None, None]: token = _current_http_request.set(request) @@ -122,7 +128,7 @@ def create_base_app( middleware: list[Middleware], debug: bool = False, lifespan: Callable | None = None, -) -> Starlette: +) -> StarletteWithLifespan: """Create a base Starlette app with common middleware and routes. Args: @@ -137,7 +143,7 @@ def create_base_app( # Always add RequestContextMiddleware as the outermost middleware middleware.append(Middleware(RequestContextMiddleware)) - return Starlette( + return StarletteWithLifespan( routes=routes, middleware=middleware, debug=debug, @@ -157,7 +163,7 @@ def create_sse_app( debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None, -) -> Starlette: +) -> StarletteWithLifespan: """Return an instance of the SSE server app. Args: @@ -262,7 +268,7 @@ def create_streamable_http_app( debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None, -) -> Starlette: +) -> StarletteWithLifespan: """Return an instance of the StreamableHTTP server app. Args: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1d520bae5..c6aaeedba 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -35,7 +35,6 @@ from mcp.types import Resource as MCPResource from mcp.types import ResourceTemplate as MCPResourceTemplate from mcp.types import Tool as MCPTool from pydantic import AnyUrl -from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.requests import Request from starlette.responses import Response @@ -48,7 +47,11 @@ from fastmcp.prompts import Prompt, PromptManager from fastmcp.prompts.prompt import PromptResult from fastmcp.resources import Resource, ResourceManager from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.http import create_sse_app +from fastmcp.server.http import ( + StarletteWithLifespan, + create_sse_app, + create_streamable_http_app, +) from fastmcp.tools import ToolManager from fastmcp.tools.tool import Tool from fastmcp.utilities.cache import TimedCache @@ -59,7 +62,6 @@ if TYPE_CHECKING: from fastmcp.client import Client from fastmcp.server.openapi import FastMCPOpenAPI from fastmcp.server.proxy import FastMCPProxy - logger = get_logger(__name__) DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] @@ -806,7 +808,7 @@ class FastMCP(Generic[LifespanResultT]): path: str | None = None, message_path: str | None = None, middleware: list[Middleware] | None = None, - ) -> Starlette: + ) -> StarletteWithLifespan: """ Create a Starlette app for the SSE server. @@ -837,7 +839,7 @@ class FastMCP(Generic[LifespanResultT]): self, path: str | None = None, middleware: list[Middleware] | None = None, - ) -> Starlette: + ) -> StarletteWithLifespan: """ Create a Starlette app for the StreamableHTTP server. @@ -858,7 +860,7 @@ class FastMCP(Generic[LifespanResultT]): path: str | None = None, middleware: list[Middleware] | None = None, transport: Literal["streamable-http", "sse"] = "streamable-http", - ) -> Starlette: + ) -> StarletteWithLifespan: """Create a Starlette app using the specified HTTP transport. Args: @@ -869,7 +871,6 @@ class FastMCP(Generic[LifespanResultT]): Returns: A Starlette application configured with the specified transport """ - from fastmcp.server.http import create_streamable_http_app if transport == "streamable-http": return create_streamable_http_app( diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 9b1528c5d..be860950e 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -119,7 +119,7 @@ def run_nested_server(host: str, port: int) -> None: mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)]) mount2 = Starlette( routes=[Mount("/nest-outer", app=mount)], - lifespan=mcp_app.router.lifespan_context, + lifespan=mcp_app.lifespan, ) server = uvicorn.Server( config=uvicorn.Config( From 76ca57a04db3b0b85b99ead37d41f21e51c01034 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 16 May 2025 11:06:43 -0400 Subject: [PATCH 5/8] Add labeler --- .github/labeler.yml | 29 +++++++++++++++++++++++++++++ .github/workflows/labeler.yml | 12 ++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 .github/labeler.yml create mode 100644 .github/workflows/labeler.yml diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..f7e6703b7 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,29 @@ +documentation: + - changed-files: + - any-glob-to-any-file: "docs/**" + +example: + - changed-files: + - any-glob-to-any-file: + - "examples/**" + +tests: + - changed-files: + - any-glob-to-any-file: "tests/**" + +"component: HTTP": + - changed-files: + - any-glob-to-any-file: "src/fastmcp/server/http.py" + +"component: client": + - changed-files: + - any-glob-to-any-file: "src/fastmcp/client/**" + +"contrib": + - changed-files: + - any-glob-to-any-file: "src/fastmcp/contrib/**" + +"component: openapi": + - changed-files: + - any-glob-to-any-file: + - "src/**/*openapi*.py" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..52474c6a6 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,12 @@ +name: "Pull Request Labeler" +on: + - pull_request_target + +jobs: + labeler: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 From a9c8969c1249a1d3c25ebc10c7d312d2f2a69b25 Mon Sep 17 00:00:00 2001 From: davenpi Date: Fri, 16 May 2025 15:58:25 -0400 Subject: [PATCH 6/8] Increase timeout in flaky test for reliability --- tests/client/test_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 1d8c3187d..d88a7c53d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -512,11 +512,11 @@ class TestErrorHandling: class TestTimeout: async def test_timeout(self, fastmcp_server: FastMCP): async with Client( - transport=FastMCPTransport(fastmcp_server), timeout=0.01 + transport=FastMCPTransport(fastmcp_server), timeout=0.05 ) as client: with pytest.raises( McpError, - match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds", + match="Timed out while waiting for response to ClientRequest. Waited 0.05 seconds", ): await client.call_tool("sleep", {"seconds": 0.1}) From c9648389c41fecfd6d1962f00db6c934fa111093 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 11:38:40 -0400 Subject: [PATCH 7/8] Store server instance as fastmcp_server --- docs/deployment/asgi.mdx | 3 +++ src/fastmcp/server/http.py | 12 ++++++++++-- tests/server/test_app_state.py | 27 +++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/server/test_app_state.py diff --git a/docs/deployment/asgi.mdx b/docs/deployment/asgi.mdx index 6fc5624fb..ace7ee136 100644 --- a/docs/deployment/asgi.mdx +++ b/docs/deployment/asgi.mdx @@ -45,6 +45,9 @@ sse_app = mcp.http_app(transport="sse") Both approaches return a Starlette application that can be integrated with other ASGI-compatible web frameworks. +The returned app stores the `FastMCP` instance on `app.state.fastmcp_server`, so you +can access it from custom middleware or routes via `request.app.state.fastmcp_server`. + The MCP server's endpoint is mounted at the root path `/mcp` for Streamable HTTP transport, and `/sse` for SSE transport, though you can change these paths by passing a `path` argument to the `http_app()` method: ```python diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 2437e85f8..d718d610a 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -247,11 +247,15 @@ def create_sse_app( server_middleware.extend(middleware) # Create and return the app - return create_base_app( + app = create_base_app( routes=server_routes, middleware=server_middleware, debug=debug, ) + # Store the FastMCP server instance on the Starlette app state + app.state.fastmcp_server = server + + return app def create_streamable_http_app( @@ -344,9 +348,13 @@ def create_streamable_http_app( yield # Create and return the app with lifespan - return create_base_app( + app = create_base_app( routes=server_routes, middleware=server_middleware, debug=debug, lifespan=lifespan, ) + # Store the FastMCP server instance on the Starlette app state + app.state.fastmcp_server = server + + return app diff --git a/tests/server/test_app_state.py b/tests/server/test_app_state.py new file mode 100644 index 000000000..dddb625c9 --- /dev/null +++ b/tests/server/test_app_state.py @@ -0,0 +1,27 @@ +import pytest +from fastmcp.server import FastMCP +from fastmcp.server.http import create_sse_app, create_streamable_http_app + + +def test_http_app_sets_mcp_server_state(): + server = FastMCP(name="StateTest") + app = server.http_app() + assert app.state.fastmcp_server is server + + +def test_http_app_sse_sets_mcp_server_state(): + server = FastMCP(name="StateTest") + app = server.http_app(transport="sse") + assert app.state.fastmcp_server is server + + +def test_create_streamable_http_app_sets_state(): + server = FastMCP(name="StateTest") + app = create_streamable_http_app(server, "/mcp") + assert app.state.fastmcp_server is server + + +def test_create_sse_app_sets_state(): + server = FastMCP(name="StateTest") + app = create_sse_app(server, message_path="/message", sse_path="/sse") + assert app.state.fastmcp_server is server From e76a076da5ee1fb73f48843dd8911c72c7f28b28 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 11:47:00 -0400 Subject: [PATCH 8/8] Fix static checks --- tests/server/test_app_state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/server/test_app_state.py b/tests/server/test_app_state.py index dddb625c9..609089400 100644 --- a/tests/server/test_app_state.py +++ b/tests/server/test_app_state.py @@ -1,4 +1,3 @@ -import pytest from fastmcp.server import FastMCP from fastmcp.server.http import create_sse_app, create_streamable_http_app