-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Description
Is there an existing issue for this?
- I have searched the existing issues
Is your feature request related to a problem? Please describe.
I would like to set up an MCP server using Sanic. The MCP server has two main APIs: /sse and /message.
-
/sse: follows the SSE protocol
-
/message: a regular streaming HTTP endpoint
However, there is a unique feature with the /message API: it accepts a request but returns twice
-
first: it returns a response to the current /message request
-
second: it triggers an SSE event to be sent to the /sse connection that shares the same session ID
This is what I am working on.
However, I'm encountering an issue. I want to mount the MCP server as an ASGI sub-application within Sanic's routes. Since Sanic doesn't provide a mount method like FastAPI or Starlette, I need to manually pass the ASGI scope, receive, and send to the MCP server. While the /sse API works as expected, the /message API is throwing an exception.
#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import signal
import uvicorn
from sanic import Sanic
from sanic.request import Request
from sanic.log import logger
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
app = Sanic(__name__)
mcp = FastMCP("Sample MCP Server")
sse = SseServerTransport("/messages/")
@mcp.tool()
def hello_world(name: str) -> str:
"""
Given a person's name, output a greeting
input:
name: str -> a person's name
output:
str -> a greeting
"""
return f"hello, {name}!"
@app.route("/messages/", methods=["POST"], stream=True)
async def mcp_messages_handle(request: Request):
asgi_scope = request.app._asgi_app.transport.scope # type: ignore
asgi_receive = request.app._asgi_app.transport._receive # type: ignore
asgi_send = request.app._asgi_app.transport._send # type: ignore
await sse.handle_post_message(asgi_scope, asgi_receive, asgi_send)
@app.route("/sse", methods=["GET"])
async def mcp_sse_handle(request: Request):
asgi_scope = request.app._asgi_app.transport.scope # type: ignore
asgi_receive = request.app._asgi_app.transport._receive # type: ignore
asgi_send = request.app._asgi_app.transport._send # type: ignore
async with sse.connect_sse(asgi_scope, asgi_receive, asgi_send) as (
read_stream,
write_stream,
):
# Run the MCP server with the established streams
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options()
)
def stopServer(signal, frame):
logger.info("Stopping Sanic Server...")
app.stop()
logger.info("Stoped Sanic Server!")
if __name__ == "__main__":
logger.info("Starting Sanic Server...")
signal.signal(signal.SIGINT, lambda signal, frame: stopServer(signal, frame))
signal.signal(signal.SIGTERM, lambda signal, frame: stopServer(signal, frame))
app.config.host = "0.0.0.0"
app.config.port = "8080"
logger.debug(app.config)
uvicorn.run("main:app", port=8080, log_level="info")
The above is the method to run a sub-application using Sanic, but the /messages API encounters an exception.
INFO: 127.0.0.1:52400 - "POST /messages/?session_id=1c24c44a046d4f3486126ed3af87eeb3 HTTP/1.1" 202 Accepted
{'type': 'http.response.body', 'body': b'Accepted'} # It can be observed that the first request is returned successfully.
Main 2025-04-28 11:12:48 +0800 ERROR: Exception occurred while handling uri: 'http://127.0.0.1:8080/messages/?session_id=1c24c44a046d4f3486126ed3af87eeb3'
Traceback (most recent call last):
File "handle_request", line 147, in handle_request
signal_router (Optional[SignalRouter]): The signal router to
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<2 lines>...
use for the application. Defaults to `None`.
^^^^^
sanic.exceptions.ServerError: Invalid response type None (need HTTPResponse)
ERROR Exception occurred while handling uri: 'http://127.0.0.1:8080/messages/?session_id=1c24c44a046d4f3486126ed3af87eeb3'
# However, an exception occurs when the second response is returned.
{'type': 'http.response.start', 'status': 500, 'headers': <generator object BaseHTTPResponse.processed_headers.<locals>.<genexpr> at 0x7f3f9b4a7880>}
Main 2025-04-28 11:12:48 +0800 ERROR: Exception occurred while handling uri: 'http://127.0.0.1:8080/messages/?session_id=1c24c44a046d4f3486126ed3af87eeb3'
Traceback (most recent call last):
File "handle_request", line 147, in handle_request
signal_router (Optional[SignalRouter]): The signal router to
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<2 lines>...
use for the application. Defaults to `None`.
^^^^^
sanic.exceptions.ServerError: Invalid response type None (need HTTPResponse)
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/usr/local/lib/python3.13/site-packages/sanic/asgi.py", line 258, in __call__
await self.sanic_app.handle_request(self.request)
File "handle_request", line 156, in handle_request
Defaults to `False`.
^^^^^^^^^^^^^^^^^^^^
log_config (Optional[Dict[str, Any]]): The logging configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
to use for the application. Defaults to `None`.
File "handle_exception", line 125, in handle_exception
restrictions as a Python module name, however, it can contain
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.13/site-packages/sanic/response/types.py", line 119, in send
await self.stream.send(
...<2 lines>...
)
File "/usr/local/lib/python3.13/site-packages/sanic/asgi.py", line 231, in send
await self.transport.send(
...<5 lines>...
)
File "/usr/local/lib/python3.13/site-packages/sanic/models/asgi.py", line 97, in send
await self._send(data)
File "/usr/local/lib/python3.13/site-packages/uvicorn/protocols/http/httptools_impl.py", line 555, in send
raise RuntimeError(msg % message_type)
RuntimeError: Unexpected ASGI message 'http.response.start' sent, after response already completed.
If Sanic provided a route mount method like FastAPI, it would be much easier to implement this.
app = Starlette(
debug=self.settings.debug,
routes=[
Route("/sse", endpoint=mcp_sse_handle),
Mount("/messages/", app=sse.handle_post_message),
],
)
uvicorn.run("main:app", port=8080, log_level="info")
Describe the solution you'd like
The app._asgi_app.create() method initializes Sanic as an ASGI application. Based on this method, is it possible to add a mount method to initialize a sub-ASGI application?
Additional context
There is an implementation of the mount method in Starlette.