Skip to content

Does Sanic support mounting sub-applications in ASGI mode? #3059

@Pateo-alandu

Description

@Pateo-alandu

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions