Skip to content

Add mount_path support for proper SSE endpoint routing with multiple FastMCP servers #540

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

tim-watcha
Copy link
Contributor

Add mount_path capability to FastMCP for proper SSE endpoint handling when mounted under sub-paths

Motivation and Context

When multiple FastMCP servers are mounted under different sub-paths in a single Starlette application, the session URIs were being generated incorrectly. FastMCP would create session URIs that didn't include the mount path, causing clients to send messages to the wrong endpoint. This change fixes the issue by
adding a mount_path setting that properly normalizes URIs.

How Has This Been Tested?

  • Added unit tests for the path normalization function
  • Added unit tests for SSE app creation with different mount paths
  • Tested with a real application mounting multiple FastMCP servers under different paths
  • Manually verified session URI generation and message routing

Breaking Changes

None. This is backwards compatible with existing code. The new mount_path setting defaults to "/".

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The issue occurs because the SseServerTransport class creates a session URI without considering that the server might be mounted under a sub-path. The fix adds a mount_path setting to FastMCP and implements a path normalization helper method that combines the mount path with the message path to ensure correct
URI generation.

This solution maintains clean separation between FastMCP and SseServerTransport without requiring changes to the SseServerTransport class itself.

This commit adds a mount_path attribute to FastMCP settings to support
properly constructing SSE endpoints when servers are mounted under
sub-paths. The _normalize_path helper method combines mount paths with
endpoints to ensure correct session_uri generation.

This fixes issues when running multiple FastMCP servers under different
path prefixes in a single Starlette application.
Tests to verify proper handling of mount paths when creating SSE applications.
Ensures path normalization works correctly for different path combinations.
This commit adds documentation for mounting multiple FastMCP servers
under different paths, showing how to configure mount_path for each
server instance.
Format code to comply with line length limit (88 characters).
@richardhundt
Copy link

I wish Starlette allowed some reflection on where a route was mounted. This kind of handshake could benefit from that, and I'm guessing the Starlette folks never thought it would be needed. That mount point duplication in settings just seems superfluous, but looking at the Starlette internals, I couldn't see a way of getting at those values either. Perhaps asking them nicely to add this capability would work.

@tim-watcha
Copy link
Contributor Author

tim-watcha commented Apr 20, 2025

I wish Starlette allowed some reflection on where a route was mounted. This kind of handshake could benefit from that, and I'm guessing the Starlette folks never thought it would be needed. That mount point duplication in settings just seems superfluous, but looking at the Starlette internals, I couldn't see a way of getting at those values either. Perhaps asking them nicely to add this capability would work.
Here's the comment content in English:

@richardhundt I agree that Starlette's lack of reflection on mount points is a bit unfortunate. While we wait for that capability, I was thinking of adding a syntactic sugar method that could help avoid the duplication:

def get_mount(self, mount_path: Optional[str] = None) -> Mount:
    """
    Configure mount_path and return a Mount object for Starlette routing.
    
    Args:
        mount_path: Optional override for the mount_path setting
    
    Returns:
        Starlette Mount object configured for this FastMCP server
    """
    # Set mount_path setting
    if mount_path:
        self.settings.mount_path = mount_path
    
    # Return configured Mount object
    return Mount(self.settings.mount_path, app=self.sse_app())

This would allow for more concise code:

# Current approach
github_mcp.settings.mount_path = "/github"
routes = [Mount("/github", app=github_mcp.sse_app())]

# Proposed approach
routes = [github_mcp.get_mount("/github")]

What do you think? This could help prevent mount path mismatches and simplify the API.

@richardhundt
Copy link

richardhundt commented Apr 20, 2025

Looks like this will all be moot soon. The updated specification reduces all communication to just a single endpoint supporting both SSE and transactional (request/response) style HTTP interactions (controlled by the Accept header). It complicates the clients a bit because they need to distinguish SSE streaming from transactional responses, but that's manageable and solves all of these routing issues.

https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http

@xorcus
Copy link

xorcus commented Apr 23, 2025

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.

@theobjectivedad
Copy link

+1 to this ... being able to set a base path would unblock my use case.

@tim-watcha tim-watcha force-pushed the fix/sse-server-message-path branch from 215cad3 to dfdf077 Compare April 28, 2025 00:11
tim-watcha and others added 2 commits April 28, 2025 09:11
- Add mount_path parameter to sse_app method for direct path configuration
- Update run_sse_async and run methods to support mount_path parameter
- Update README.md with examples for different mounting methods
- Add tests for new parameter functionality
@tim-watcha
Copy link
Contributor Author

I've implemented a solution for this issue by adding a mount_path parameter to the sse_app method, similar to what you suggested with base_path. This change gives
users multiple options for configuring the mount path:

  1. Using settings.mount_path for persistent configuration
  2. Passing the path directly to sse_app(mount_path="...") for ad-hoc mounting
  3. Passing to run(transport="sse", mount_path="...") for direct execution

The implementation also properly handles path normalization through the existing _normalize_path method to ensure paths are correctly combined.

Your example of Mount('/mcp', mcp.sse_app(base_path='/mcp')) now works with our implementation as Mount('/mcp', mcp.sse_app('/mcp')).

All tests have been updated and are passing, and the README has been expanded with examples showing the different approaches. @xorcus

1bd89ff

tim-watcha and others added 5 commits April 28, 2025 09:39
Updated `normalized_endpoint` to `normalized_message_endpoint` to improve code readability and better reflect its purpose. This change ensures the variable name aligns with its role in managing message path normalization.
Copy link

@dwayn dwayn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great to me and provides a nice way to mount sse mcp servers on a path that is not just on the root path.

This is fundamentally needed to support multi-server gateway implementations.

Even with http+sse transport being replaced by streamable http, there are many implementations that will likely continue to use sse for a while so being able to still support them in gateway implementations without awful hacks is kind of important.

At a minimum, if the preference is to not muddy the FastMCP settings for a transport that is going away, being able to inject mount path using sse_app(mount_path="/foo/bar") would be super helpful. Otherwise, any of us that are trying to build generalized gateways end up with hacky messes.

@dwayn
Copy link

dwayn commented May 7, 2025

For anyone else wanting a fix for this problem, I wrote a middleware that works around it by rewriting the endpoint url in the initial message that is sent back by the server upon connection to SSE endpoint.

Source here: https://github.com/dwayn/fastmcp-mount
It is also uploaded to PyPi: https://pypi.org/project/fastmcp-mount/

Copy link
Contributor

@ihrpr ihrpr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you!

Just a few small things and it's good to merge, we'll also need to incorporate this into streamable http

@ihrpr ihrpr added this to the 2025-03-26 spec release milestone May 7, 2025
Co-authored-by: ihrpr <inna.hrpr@gmail.com>
tim-watcha and others added 5 commits May 7, 2025 17:51
Co-authored-by: ihrpr <inna.hrpr@gmail.com>
Co-authored-by: ihrpr <inna.hrpr@gmail.com>
Co-authored-by: ihrpr <inna.hrpr@gmail.com>
- Change mount_path parameter type from 'str = None' to 'str | None = None'
- Fix line length violation by splitting parameters onto separate lines
- Ensure proper typing for optional parameters

This commit fixes the pyright type error and ruff formatting issues while
maintaining the original functionality of the mount_path parameter.
@tim-watcha tim-watcha force-pushed the fix/sse-server-message-path branch from ab8f478 to 0366f68 Compare May 7, 2025 09:44
Copy link
Contributor

@ihrpr ihrpr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you

@tim-watcha
Copy link
Contributor Author

sse_app(mount_path="/foo/bar") is already implemented here 😄 @dwayn

@elizabetht
Copy link

Hi @tim-watcha I am following your example as in README. But can't find the apps running at the different mount paths. If my endpoint was /cars/v1/sse, I am not seeing the route being found in this approach - getting 404 not found for /cars/v1/sse

if __name__ == "__main__":
  routes = []
  for endpoint, mcp_app in mcp_servers.items():
    routes.append(Mount(endpoint, mcp_app.sse_app(mount_path=endpoint)))

  main_app=Starlette(routes=routes)
  uvicorn.run(main_app, host="0.0.0.0", port=8000)

@tim-watcha
Copy link
Contributor Author

tim-watcha commented May 10, 2025

Hi @tim-watcha I am following your example as in README. But can't find the apps running at the different mount paths. If my endpoint was /cars/v1/sse, I am not seeing the route being found in this approach - getting 404 not found for /cars/v1/sse

if __name__ == "__main__":
  routes = []
  for endpoint, mcp_app in mcp_servers.items():
    routes.append(Mount(endpoint, mcp_app.sse_app(mount_path=endpoint)))

  main_app=Starlette(routes=routes)
  uvicorn.run(main_app, host="0.0.0.0", port=8000)
  1. Which client did you use?
  2. If the endpoint was /cars/v1/sse, the client should connect via /cars/v1/sse/sse and /cars/v1/sse/messages
    @elizabetht

@lalith-b
Copy link

@tim-watcha, so the url routing after this merge if it was set to /cars/v1 it resolves to /cars/v1/sse and /cars/v1/messages will be handled as routes,

however, If there's no extra / towards the end of messages route|url I'm seeing a 307 redirect /cars/v1/messages/ will solve the redirect, why is this happening does this solve the trailing slash and make it consistent with queryStrings for sessionIds ?

@dwayn
Copy link

dwayn commented May 13, 2025

sse_app(mount_path="/foo/bar") is already implemented here 😄 @dwayn

So, in my digging into all of this (and finding several hacky methods to solving this) I ended up learning about how Starlette ASGI apps/routers/middleware/etc are supposed to handle being mounted at an arbitrary path, and the expected behavior is that when a request comes in, it should use scope['root_path'] as a prefix to any url it generates that is based on its own relative paths.
Either that or the client is going to have to figure out what the relative base path is, but that could get weird if the sse_path and messages_path are custom configured with distinctive sub paths, eg: sse_path="/foo/sse" and messages_path="/bar/messages/". Client would know that it connected to /some/path/to/foo/sse but would not have the context based on endpoint=/bar/messages/?sess.... to connect to /some/path/to/bar/messages/", the best guess it could come up with is /some/path/to/foo/bar/messages/..., so this is why the server, when constructing the endpoint should combine root_pathfrom the incoming request scope withself.messages_path` to create a complete absolute path for the client to connect to the endpoint.

So, in the fastmcp-mount middleware, it does exactly that. The fastmcp app should just be configured as if it is mounted on root, the the mount middleware i wrote will intercept the endpoint message from the server and prefix it with the root_path it is supposed to have, no need to tell it what the mount path is at all.
For example with the middleware I wrote to fix the behavior, this works:

mcp_server = FastMCP(title="My Mounted Server")

@mcp_server.tool()
def my_tool(param: str) -> str:
    return f"Processed: {param}"

sse_app = mcp_server.sse_app()

app = FastAPI(title="Main API")
app.mount("/mcp/my-server", app=MountFastMCP(app=sse_app), name="my_mcp_server")

// Since the prefixing is dynamically added, you can mount the same app in multiple places and it is fine
app.mount("/admin/tooling/mcp/my-server", app=MountFastMCP(app=sse_app), name="admin_my_mcp_server")

The real fix to the mcp library (and this is going to be needed for the Streamable HTTP implementation for when it tells the client to connect to an SSE session) is to use the scope['root_path'] of the incoming request from the client to prefix any urls that are generated for the client to connect to.
This should be able to be completely automatically handled in such a way that if i wanted to mount the same exact FastMCP.sse_app() Starlette app on two different places in an API it should just work like above.

@jlowin
Copy link
Contributor

jlowin commented May 13, 2025

@dwayn good news - the strategy you describe was implemented in #659

@dwayn
Copy link

dwayn commented May 15, 2025

@dwayn good news - the strategy you describe was implemented in #659

@jlowin This is awesome news! I just checked out the PR and it looks great, this is the true fix that was needed for it.

I think the only other concern is to ensure that the same handling is applied to the Streamable HTTP server transport implementation to ensure that the URLs handed out for SSE connections get their dynamic prefixes as well.
Not sure who is working on that implementation, but definitely worth pointing out to ensure it doesn't get missed, since this fix to SSE is coming in now, shortly before it is deprecated, and I would bet the transport implementation is probably close to finished.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants