From 750c6bf005712c7fee6df038146adbf1a3160bde Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 1 May 2025 16:29:06 -0400 Subject: [PATCH 1/2] Add starlette request to context --- src/fastmcp/server/context.py | 15 +++++++++++++- src/fastmcp/server/server.py | 37 +++++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index ebb912615..6f5a70c52 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -13,8 +13,9 @@ from mcp.types import ( SamplingMessage, TextContent, ) -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from pydantic.networks import AnyUrl +from starlette.requests import Request from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger @@ -58,17 +59,22 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): _request_context: RequestContext[ServerSessionT, LifespanContextT] | None _fastmcp: FastMCP | None + _request: Request | None + + model_config = ConfigDict(arbitrary_types_allowed=True) def __init__( self, *, request_context: RequestContext[ServerSessionT, LifespanContextT] | None = None, fastmcp: FastMCP | None = None, + request: Request | None = None, **kwargs: Any, ): super().__init__(**kwargs) self._request_context = request_context self._fastmcp = fastmcp + self._request = request @property def fastmcp(self) -> FastMCP: @@ -84,6 +90,13 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): raise ValueError("Context is not available outside of a request") return self._request_context + @property + def request(self) -> Request: + """Access to the underlying request.""" + if self._request is None: + raise ValueError("Context is not available outside of a request") + return self._request + async def report_progress( self, progress: float, total: float | None = None ) -> None: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index d838570ae..4336ccb66 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -9,6 +9,7 @@ from contextlib import ( AsyncExitStack, asynccontextmanager, ) +from contextvars import ContextVar from functools import partial from typing import TYPE_CHECKING, Any, Generic, Literal @@ -61,6 +62,25 @@ logger = get_logger(__name__) NOT_FOUND = object() +_current_starlette_request: ContextVar[Request | None] = ContextVar( + "starlette_request", + default=None, +) + + +@asynccontextmanager +async def starlette_request_context(request: Request): + token = _current_starlette_request.set(request) + try: + yield + finally: + _current_starlette_request.reset(token) + + +def get_current_starlette_request() -> Request | None: + return _current_starlette_request.get() + + class MountedServer: def __init__( self, @@ -291,7 +311,11 @@ class FastMCP(Generic[LifespanResultT]): request_context = None from fastmcp.server.context import Context - return Context(request_context=request_context, fastmcp=self) + return Context( + request_context=request_context, + fastmcp=self, + request=get_current_starlette_request(), + ) async def get_tools(self) -> dict[str, Tool]: """Get all registered tools, indexed by registered key.""" @@ -760,11 +784,12 @@ class FastMCP(Generic[LifespanResultT]): request.receive, request._send, # type: ignore[reportPrivateUsage] ) as streams: - await self._mcp_server.run( - streams[0], - streams[1], - self._mcp_server.create_initialization_options(), - ) + async with starlette_request_context(request): + await self._mcp_server.run( + streams[0], + streams[1], + self._mcp_server.create_initialization_options(), + ) return Starlette( debug=self.settings.debug, From b672468d4f2a0fdb89ab978fa959977e3f8d7f58 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 2 May 2025 17:16:43 -0400 Subject: [PATCH 2/2] Add support for starlette request --- docs/servers/context.mdx | 20 ++++++++++++++++ src/fastmcp/server/context.py | 18 +++++++------- src/fastmcp/server/server.py | 32 ++++--------------------- src/fastmcp/utilities/http.py | 44 +++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 37 deletions(-) create mode 100644 src/fastmcp/utilities/http.py diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index 203045184..e6554cb7d 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -269,11 +269,31 @@ async def advanced_tool(ctx: Context) -> str: return f"Server: {server_name}" ``` +For web applications, you can access the underlying HTTP request: + +```python +@mcp.tool() +async def handle_web_request(ctx: Context) -> dict: + """Access HTTP request information from the Starlette request.""" + request = ctx.get_starlette_request() + + # Access HTTP headers, query parameters, etc. + user_agent = request.headers.get("user-agent", "Unknown") + client_ip = request.client.host if request.client else "Unknown" + + return { + "user_agent": user_agent, + "client_ip": client_ip, + "path": request.url.path, + } +``` + **Advanced Properties:** - **`ctx.fastmcp -> FastMCP`**: Access the server instance the context belongs to - **`ctx.session`**: Access the raw `mcp.server.session.ServerSession` object - **`ctx.request_context`**: Access the raw `mcp.shared.context.RequestContext` object +- **`ctx.get_starlette_request() -> Request`**: Access the active Starlette request object (when running with a web server) Direct use of `session` or `request_context` requires understanding the low-level MCP Python SDK and may be less stable than using the methods provided directly on the `Context` object. diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 6f5a70c52..34dd3ea36 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -18,6 +18,7 @@ from pydantic.networks import AnyUrl from starlette.requests import Request from fastmcp.server.server import FastMCP +from fastmcp.utilities.http import get_current_starlette_request from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -59,7 +60,6 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): _request_context: RequestContext[ServerSessionT, LifespanContextT] | None _fastmcp: FastMCP | None - _request: Request | None model_config = ConfigDict(arbitrary_types_allowed=True) @@ -68,13 +68,11 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): *, request_context: RequestContext[ServerSessionT, LifespanContextT] | None = None, fastmcp: FastMCP | None = None, - request: Request | None = None, **kwargs: Any, ): super().__init__(**kwargs) self._request_context = request_context self._fastmcp = fastmcp - self._request = request @property def fastmcp(self) -> FastMCP: @@ -90,13 +88,6 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): raise ValueError("Context is not available outside of a request") return self._request_context - @property - def request(self) -> Request: - """Access to the underlying request.""" - if self._request is None: - raise ValueError("Context is not available outside of a request") - return self._request - async def report_progress( self, progress: float, total: float | None = None ) -> None: @@ -235,3 +226,10 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): ) return result.content + + def get_starlette_request(self) -> Request: + """Get the active starlette request.""" + request = get_current_starlette_request() + if request is None: + raise ValueError("Request is not available outside a Starlette request") + return request diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index b61f1e11b..59cb6f097 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -9,7 +9,6 @@ from contextlib import ( AsyncExitStack, asynccontextmanager, ) -from contextvars import ContextVar from functools import partial from typing import TYPE_CHECKING, Any, Generic, Literal @@ -60,6 +59,7 @@ from fastmcp.resources.template import ResourceTemplate from fastmcp.tools import ToolManager from fastmcp.tools.tool import Tool from fastmcp.utilities.decorators import DecoratedFunction +from fastmcp.utilities.http import RequestMiddleware from fastmcp.utilities.logging import configure_logging, get_logger if TYPE_CHECKING: @@ -73,25 +73,6 @@ logger = get_logger(__name__) NOT_FOUND = object() -_current_starlette_request: ContextVar[Request | None] = ContextVar( - "starlette_request", - default=None, -) - - -@asynccontextmanager -async def starlette_request_context(request: Request): - token = _current_starlette_request.set(request) - try: - yield - finally: - _current_starlette_request.reset(token) - - -def get_current_starlette_request() -> Request | None: - return _current_starlette_request.get() - - class MountedServer: def __init__( self, @@ -333,11 +314,7 @@ class FastMCP(Generic[LifespanResultT]): request_context = None from fastmcp.server.context import Context - return Context( - request_context=request_context, - fastmcp=self, - request=get_current_starlette_request(), - ) + return Context(request_context=request_context, fastmcp=self) async def get_tools(self) -> dict[str, Tool]: """Get all registered tools, indexed by registered key.""" @@ -846,10 +823,11 @@ class FastMCP(Generic[LifespanResultT]): log_level: str | None = None, ) -> None: """Run the server using SSE transport.""" - starlette_app = self.sse_app() + app = self.sse_app() + app = RequestMiddleware(app) config = uvicorn.Config( - starlette_app, + app, host=host or self.settings.host, port=port or self.settings.port, log_level=log_level or self.settings.log_level.lower(), diff --git a/src/fastmcp/utilities/http.py b/src/fastmcp/utilities/http.py new file mode 100644 index 000000000..31f6d404e --- /dev/null +++ b/src/fastmcp/utilities/http.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from contextlib import ( + asynccontextmanager, +) +from contextvars import ContextVar + +from starlette.requests import Request + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +_current_starlette_request: ContextVar[Request | None] = ContextVar( + "starlette_request", + default=None, +) + + +@asynccontextmanager +async def starlette_request_context(request: Request): + token = _current_starlette_request.set(request) + try: + yield + finally: + _current_starlette_request.reset(token) + + +def get_current_starlette_request() -> Request | None: + return _current_starlette_request.get() + + +class RequestMiddleware: + """ + Middleware that stores each request in a ContextVar + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + async with starlette_request_context(Request(scope)): + await self.app(scope, receive, send)