python-sdk icon indicating copy to clipboard operation
python-sdk copied to clipboard

sse_app() ignores mount prefix, resulting in 404 from client

Open allieus opened this issue 9 months ago • 11 comments

Describe the bug

When mounting sse_app() from FastMCP with a URL prefix using Starlette’s Mount, the SSE stream still returns the default /messages/ endpoint without the prefix. This causes the MCP client to resolve an incorrect URL (e.g., /messages/ instead of /mcp/messages/), resulting in a 404 error.

To Reproduce

Steps to reproduce the behavior:

  1. Define an MCP server as below:
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.routing import Mount

mcp = FastMCP(
    # sse_path="/sse",  # default
    # message_path="/messages/",  # default
)


application = Starlette(
    routes=[
        Mount("/mcp", app=mcp.sse_app()),  # HERE !!!
    ]
)
  1. Start the server and navigate to http://127.0.0.1:8000/mcp/sse in your browser
  2. Observe the response in the browser. You will receive an SSE event like:
event: endpoint
data: /messages/?session_id=...

At this point, the MCP client performs a urljoin operation between the SSE URL (http://127.0.0.1:8000/mcp/sse) and the endpoint path (/messages/). This causes the resolved endpoint URL to become http://127.0.0.1:8000/messages/.

  1. The client tries to connect to http://127.0.0.1:8000/messages/, which results in a 404.

Expected behavior

The SSE stream should return the correct full path reflecting the prefix, e.g.:

data: /mcp/messages/?session_id=...

This would allow the client to connect to the actual valid message endpoint.

Screenshots If applicable, add screenshots to help explain your problem.

Desktop (please complete the following information):

  • OS: macOS
  • Browser : Arc
  • Version : 1.87.1 (60573)

Smartphone (please complete the following information):

  • Device: N/A
  • OS: N/A
  • Browser : N/A
  • Version : N/A

Additional context

This behavior seems to originate from hardcoded endpoint generation inside sse_app():

https://github.com/modelcontextprotocol/python-sdk/blob/c2ca8e03e046908935d089a2ceed4e80b0c29a24/src/mcp/server/sse.py#L98

It would be great to have support for specifying a prefix in sse_app() or for the prefix to be auto-detected from the ASGI scope.

Thank you!

allieus avatar Apr 02 '25 16:04 allieus

This might be related with #386

JorgeRuizITCL avatar Apr 03 '25 07:04 JorgeRuizITCL

This might be related with #386

The clients shouldn't be patching the endpoints. The server should just give it the right endpoint during the handshake.

richardhundt avatar Apr 06 '25 15:04 richardhundt

is there any workaround?

application = Starlette( routes=[ Mount("/", app=mcp.sse_app()), # HERE !!! ] )

can't get the routes either?

what's the standard way of linking a fastapi/starlette app with mcp? i need some custom routes

earonesty avatar Apr 10 '25 01:04 earonesty

This is all going to change once streamable HTTP lands, but my work-around for now is to just update /etc/hosts (MacOS/Linux) or Hosts File Editor (Windows) to add two hostnames backend and mcp. Then just serve MCP/SSE on only the mcp hostname.

Something like:

# entrypoint.py
from starlette.applications import Starlette
from starlette.routing import Mount, Host

from django.core.asgi import get_asgi_application
django_http_app = get_asgi_application()

from backend.server import mcp_app

routes = []

application = Starlette(routes=routes)

application.router.routes.append(Host('backend', app=django_http_app))
application.router.routes.append(Host('mcp', app=mcp_app.sse_app()))

pztrick avatar Apr 10 '25 18:04 pztrick

There is in issue currently with message routing for non-root mounts. It seems to be ignored by sse_app. Here is a workaround.

As FastAPI is a subclass of Starlette, this works just as well for FastAPI. This is just a slight modification of the sse_app method

def register_mcp_router(
    starlette_app: Starlette,
    mcp_server: FastMCP,
    base_path: str,
):
    sse = SseServerTransport(f"{base_path}/messages/")

    async def handle_sse(request: Request) -> None:
        async with sse.connect_sse(
            request.scope,
            request.receive,
            request._send,  # noqa: SLF001
        ) as (read_stream, write_stream):
            await mcp_server._mcp_server.run(
                read_stream,
                write_stream,
                mcp_server._mcp_server.create_initialization_options(),
            )

    starlette_app.add_route(f"{base_path}/sse", handle_sse)
    starlette_app.mount(f"{base_path}/messages/", sse.handle_post_message)

Other issues/PRs raising the same bug

  • https://github.com/modelcontextprotocol/python-sdk/issues/464
  • https://github.com/modelcontextprotocol/python-sdk/issues/490
  • https://github.com/modelcontextprotocol/python-sdk/pull/386

MarkDunne avatar Apr 11 '25 09:04 MarkDunne

I ran into this also and am trying the above. A complete example would be really helpful.

In my example the MCP server is written within an openfaas function mounted at http://127.0.0.1:8080/function/mcp - then /sse and /messages are sub-paths. The path of /function/mcp is stripped by the openfaas gateway before the request hits the HTTP server.

I still seem to get the same errors from the client with the above workaround

from fastmcp import Client
from fastmcp.client.transports import (
    SSETransport
)
import os
from dotenv import load_dotenv
import asyncio
import httpx

load_dotenv()

API_KEY = os.getenv('API_KEY')

async def main():
    base_url = "http://127.0.0.1:8080/function/mcp/sse"
    # Connect to a server over SSE (common for web-based MCP servers)
    transport = SSETransport(
        f"{base_url}"
    )

    async with Client(transport) as client:
        await client.ping()
        print(await client.call_tool("list_functions"))

asyncio.run(main())
Error in post_writer: Client error '404 Not Found' for url 'http://127.0.0.1:8080/messages/?session_id=7054b5ed39574cb28470f6308868c6a8'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404

In other words, the server needs to be mounted at / to work... since all paths are stripped off before /function/mcp - but it also needs to reply back as if it were mounted at /function/mcp since the client is unable to understand relative paths.

alexellis avatar Apr 14 '25 12:04 alexellis

Have a potential fix. https://github.com/modelcontextprotocol/python-sdk/pull/524

rkondra-eightfold avatar Apr 16 '25 04:04 rkondra-eightfold

I’m experiencing a very similar issue, but in my case, the URL prefix is not defined inside the application code using Mount. Instead, my MCP service is deployed behind an external reverse proxy that uses a multi-level path to distinguish between services.

For example, my SSE server is mounted at /sse, but it's exposed publicly at: https://****-mcp-server/my/sse

In this case, /my is part of a multi-level URL path added by the reverse proxy, which forwards requests to the root of my MCP server.

When I configure the MCP client to use this kind of multi-level path, I encounter a 404 error.

anchen-007 avatar Apr 22 '25 05:04 anchen-007

https://github.com/modelcontextprotocol/python-sdk/pull/540 is the latest on this

rkondra-eightfold avatar Apr 22 '25 05:04 rkondra-eightfold

I just hit the same problem. Could you please consider allowing for the base_path to be supplied? Example modification in python-sdk/src/mcp/server/fastmcp/server.py:

    def sse_app(self, base_path: str = '') -> Starlette:
        """Return an instance of the SSE server app."""
        sse = SseServerTransport(f'{base_path}{self.settings.message_path}')

The original code looks like this:

    def sse_app(self) -> Starlette:
        """Return an instance of the SSE server app."""
        sse = SseServerTransport(self.settings.message_path)

        async def handle_sse(request: Request) -> None:
            async with sse.connect_sse(
                request.scope,
                request.receive,
                request._send,  # type: ignore[reportPrivateUsage]
            ) as streams:
                await self._mcp_server.run(
                    streams[0],
                    streams[1],
                    self._mcp_server.create_initialization_options(),
                )

        return Starlette(
            debug=self.settings.debug,
            routes=[
                Route(self.settings.sse_path, endpoint=handle_sse),
                Mount(self.settings.message_path, app=sse.handle_post_message),
            ],
        )

Exposing the base_path as suggested would allow for mounting with Starlette as follows:

    Mount('/mcp', mcp.sse_app(base_path='/mcp')),

It is a bit redundant, but it works.

xorcus avatar Apr 23 '25 13:04 xorcus

I’m experiencing a very similar issue, but in my case, the URL prefix is not defined inside the application code using Mount. Instead, my MCP service is deployed behind an external reverse proxy that uses a multi-level path to distinguish between services.

For example, my SSE server is mounted at /sse, but it's exposed publicly at: https://****-mcp-server/my/sse

In this case, /my is part of a multi-level URL path added by the reverse proxy, which forwards requests to the root of my MCP server.

When I configure the MCP client to use this kind of multi-level path, I encounter a 404 error.

I have the same issue, and I think this should be resolved in client side. Open a feature request for it: https://github.com/modelcontextprotocol/python-sdk/issues/795

lizzzcai avatar May 23 '25 16:05 lizzzcai

It's still not clear what is supposed to happen from the docs

app = Starlette(
    routes=[
        Mount('/search', app=mcp.sse_app()),
    ]
)

When I do this http://localhost:8000/search/sse seems to work but http://localhost:8000/search gives 404

Is that intended?

vikigenius avatar Jul 01 '25 04:07 vikigenius