Skip to content

Commit b685fcc

Browse files
committed
Fix stream resource leaks and upgrade Starlette
- Add docstring for custom_route method in FastMCP server - Fix stream resource leaks in SSE transport and streaming ASGI response - Upgrade Starlette to 0.46.0+ to remove multipart deprecation warning - Remove python-multipart dependency which is now included in Starlette
1 parent 8c251c9 commit b685fcc

File tree

5 files changed

+41
-33
lines changed

5 files changed

+41
-33
lines changed

pyproject.toml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ dependencies = [
2626
"httpx>=0.27",
2727
"httpx-sse>=0.4",
2828
"pydantic>=2.7.2,<3.0.0",
29-
"starlette>=0.27",
29+
"starlette>=0.46",
3030
"sse-starlette>=1.6.1",
3131
"pydantic-settings>=2.5.2",
3232
"uvicorn>=0.23.1",
@@ -110,12 +110,8 @@ mcp = { workspace = true }
110110
xfail_strict = true
111111
filterwarnings = [
112112
"error",
113-
# this is a long-standing issue with fastmcp, which is just now being exercised by tests
114-
"ignore:Unclosed:ResourceWarning",
115113
# This should be fixed on Uvicorn's side.
116114
"ignore::DeprecationWarning:websockets",
117115
"ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning",
118116
"ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel",
119-
# this is a problem in starlette
120-
"ignore:Please use `import python_multipart` instead.:PendingDeprecationWarning",
121117
]

src/mcp/server/fastmcp/server.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,25 @@ def custom_route(
490490
name: str | None = None,
491491
include_in_schema: bool = True,
492492
):
493+
"""Decorator to register a custom HTTP route on the FastMCP server.
494+
495+
Allows adding arbitrary HTTP endpoints outside the standard MCP protocol,
496+
which can be useful for OAuth callbacks, health checks, or admin APIs.
497+
The handler function must be an async function that accepts a Starlette
498+
Request and returns a Response.
499+
500+
Args:
501+
path: URL path for the route (e.g., "/oauth/callback")
502+
methods: List of HTTP methods to support (e.g., ["GET", "POST"])
503+
name: Optional name for the route (used for URL reversing)
504+
include_in_schema: Whether to include in OpenAPI schema, defaults to True
505+
506+
Example:
507+
@server.custom_route("/health", methods=["GET"])
508+
async def health_check(request: Request) -> Response:
509+
return JSONResponse({"status": "ok"})
510+
"""
511+
493512
def decorator(
494513
func: Callable[[Request], Awaitable[Response]],
495514
) -> Callable[[Request], Awaitable[Response]]:

src/mcp/server/sse.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,17 @@ async def sse_writer():
120120
}
121121
)
122122

123-
async with anyio.create_task_group() as tg:
124-
response = EventSourceResponse(
125-
content=sse_stream_reader, data_sender_callable=sse_writer
126-
)
127-
logger.debug("Starting SSE response task")
128-
tg.start_soon(response, scope, receive, send)
129-
130-
logger.debug("Yielding read and write streams")
131-
yield (read_stream, write_stream, response)
123+
# Ensure all streams are properly closed
124+
async with read_stream, write_stream, read_stream_writer, sse_stream_reader:
125+
async with anyio.create_task_group() as tg:
126+
response = EventSourceResponse(
127+
content=sse_stream_reader, data_sender_callable=sse_writer
128+
)
129+
logger.debug("Starting SSE response task")
130+
tg.start_soon(response, scope, receive, send)
131+
132+
logger.debug("Yielding read and write streams")
133+
yield (read_stream, write_stream, response)
132134

133135
async def handle_post_message(
134136
self, scope: Scope, receive: Receive, send: Send

src/mcp/server/streaming_asgi_transport.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ async def process_messages() -> None:
173173
# Ensure events are set even if there's an error
174174
initial_response_ready.set()
175175
response_complete.set()
176+
await content_send_channel.aclose()
176177

177178
# Create tasks for running the app and processing messages
178179
self.task_group.start_soon(run_app)
@@ -205,5 +206,8 @@ def __init__(
205206
self.receive_channel = receive_channel
206207

207208
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
208-
async for chunk in self.receive_channel:
209-
yield chunk
209+
try:
210+
async for chunk in self.receive_channel:
211+
yield chunk
212+
finally:
213+
await self.receive_channel.aclose()

uv.lock

Lines changed: 4 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)