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 diff --git a/docs/deployment/asgi.mdx b/docs/deployment/asgi.mdx index f676ff68a..ace7ee136 100644 --- a/docs/deployment/asgi.mdx +++ b/docs/deployment/asgi.mdx @@ -43,7 +43,10 @@ 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 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: @@ -124,7 +127,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 +157,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 +184,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) ``` @@ -199,13 +202,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..58f9852bf 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") if __name__ == "__main__": mcp.run() 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") diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 8dbeacfee..d718d610a 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: @@ -241,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( @@ -262,7 +272,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: @@ -338,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/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_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}) 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( diff --git a/tests/server/test_app_state.py b/tests/server/test_app_state.py new file mode 100644 index 000000000..609089400 --- /dev/null +++ b/tests/server/test_app_state.py @@ -0,0 +1,26 @@ +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