Skip to content

Add base_path support for SSE endpoint subpath routing via nginx or other http middleware #415

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

Closed
wants to merge 7 commits into from

Conversation

LiangYang666
Copy link

@LiangYang666 LiangYang666 commented May 12, 2025

Description:

This PR introduces a base_path parameter to the SSE configuration in MCP, similar to the baseurl concept commonly found in other web applications. eg: base_url in jupyterbase_url in filebrowser or root_url in grafana

Background & Reasoning:

When using Nginx or other HTTP proxies to route requests to MCP, the endpoint paths can become inconsistent. For example, when routing /mcp to a specific MCP service, the SSE endpoint can be set as https://example.com/mcp/sse. However, the client currently receives a messages endpoint as https://example.com/messages/, which is incorrect. The correct endpoint should be https://example.com/mcp/messages/.

By introducing the mount_path parameter, users can set a base path (e.g., /mcp). This ensures that the SSE server correctly adjusts its internal endpoints to match the proxy configuration, providing accurate endpoint paths to clients.

Implementation Note:

Initially, the parameter was considered to be named baseurl, but to maintain consistency with another related repository that implemented a similar change, the parameter is named mount_path. PR

Example Usage:

from fastmcp import FastMCP

# Create a server instance
mcp = FastMCP(name="MyAssistantServer", host="0.0.0.0", base_path="/mcp")

@mcp.tool()
def multiply(a: float, b: float) -> float:
    """Multiplies two numbers."""
    return a * b

if __name__ == "__main__":
    mcp.run(transport="sse")

This change improves compatibility when deploying MCP behind reverse proxies or HTTP routers.

@jlowin
Copy link
Owner

jlowin commented May 12, 2025

How is this different from the existing path argument?

@jlowin
Copy link
Owner

jlowin commented May 12, 2025

Also please see #370, we explicitly reject the approach taken in mcp 540 as a flawed approach.

@LiangYang666
Copy link
Author

How is this different from the existing path argument?

You can take a look at my description, this is different from path.

@LiangYang666
Copy link
Author

Also please see #370, we explicitly reject the approach taken in mcp 540 as a flawed approach.

modelcontextprotocol/python-sdk#659 This does not solve the Nginx proxy issue. Using nginx for proxying is a common practice. Perhaps this configuration should be called base_url instead of mount_path, similar to the concept in Jupyter.

@jlowin
Copy link
Owner

jlowin commented May 12, 2025

The normalized path is passed into the SSE ServerTransport, I fail to see how this is any different than overriding the ASGI root path behavior that has already been implemented. We have tests for the exact situation you describe at https://github.com/jlowin/fastmcp/blob/main/tests/client/test_sse.py#L97-L128.

@LiangYang666
Copy link
Author

LiangYang666 commented May 13, 2025

The normalized path is passed into the SSE ServerTransport, I fail to see how this is any different than overriding the ASGI root path behavior that has already been implemented. We have tests for the exact situation you describe at https://github.com/jlowin/fastmcp/blob/main/tests/client/test_sse.py#L97-L128.

This is not the same as baseurl. When the server is mounted under the /mcp path and routed through Nginx to that path, the client would need to request http://example.com/mcp/mcp/sse to access the service, requiring two /mcp paths.

What we want to achieve is that when Nginx routes an MCP server to the /mcp path, the service can correctly connect to SSE and return the correct messages endpoint.

I understand your point about using ASGI for routing, but what I’m referring to is using Nginx or other HTTP middleware for path-based routing. This is a common requirement, often implemented by using Nginx to route subpaths and share the same domain. You can check out how baseurl is applied in Jupyter or root_url in Grafana for similar use cases.

@LiangYang666 LiangYang666 changed the title Add mount_path support for SSE endpoint subpath routing via nginx or other http middleware Add base_path support for SSE endpoint subpath routing via nginx or other http middleware May 13, 2025
@LiangYang666
Copy link
Author

LiangYang666 commented May 13, 2025

update:
This PR introduces a base_path parameter to the SSE configuration in MCP, similar to the baseurl concept commonly found in other web applications. eg: base_url in jupyterbase_url in filebrowser or root_url in grafana

@jlowin
Copy link
Owner

jlowin commented May 13, 2025

Hi @LiangYang666, thanks for highlighting the scenario of deploying behind a reverse proxy.

I've reviewed this PR and unfortunately it doesn't solve that problem and also introduces new bugs. In the current implementation, the base_path is used to prefix the message_path that SseServerTransport advertises during the SSE handshake. However, this normalized_message_endpoint is not subsequently used to mount the actual message handling route. As a result, the SseServerTransport advertises the wrong endpoint and if you run an integration test with a base_path specified, you will immediately get a 404 error from the client.

A solution would be to provide the normalized_message_endpoint to the message handling route. However, as I have indicated already, that is exactly identical to manually providing a prefixed message_path without the need for a base_path, and is therefore unecessary.

As such, there are two clear patterns that are already supported. The first is to mount the entire SSE app at a prefix like /mcp:

mcp = FastMCP()
sse_app = mcp.create_sse_app()
app = Starlette(routes=[Mount("/mcp", app=sse_app)])

Whether done with Starlette mount as above or through a different routing solution, any request to /mcp is now processed to the app correctly, including to /mcp/messages (which the app will receive as just /messages)

An alternative approach is

mcp = FastMCP()
app = mcp.create_sse_app(path='/mcp/sse', message_path='/mcp/messages')

This creates a similar outcome, except that now the app itself expects the /mcp/ prefix to be part of the request URL. This can be handled by configuring your proxy appropriately.

Lastly, regarding comparisons to Jupyter's baseurl or Grafana's root_url: Those configurations primarily inform the application itself how to generate all its internal and client-facing URLs when it's running under a prefix. ASGI's root_path (often set via proxy headers like X-Forwarded-Prefix and used by Starlette Mount and SseServerTransport) serves this exact purpose for the application framework and its components. It ensures that request routing and URL generation (including advertised paths) are correct when served under a subpath. This is what we have already implemented to avoid the need for additional and potentially confusing user-facing knobs.

If you have a new case that can not be handled by either of the above situations or through manually supplying e.g. a root_path to your application or uvicorn instance, then we can reconsider it as a bug report that can be tested. However, your current implementation is broken and, even if the base URL were passed to the messages mount to fix it, lacks clear motivation over why a third keyword argument is better than concatenating the message url manually.

@jlowin jlowin closed this May 13, 2025
@LiangYang666
Copy link
Author

LiangYang666 commented May 13, 2025

@jlowin You are incorrect. This does not resolve the issue of using Nginx for proxying. Introducing base_path will not cause the problem you mentioned because Nginx has already mounted the application to /mcp. When the client accesses https://example.com/mcp/sse, it will automatically be forwarded to the /sse path on the MCP server, not /mcp/sse. Therefore, adding the /mcp prefix to the returned messages endpoint will not cause any issues, as Nginx will forward /mcp/messages/ to the /messages/ path on the MCP server. This means that when the client accesses https://example.com/mcp/messages/, it is actually accessing /messages/ on the MCP server, not /mcp/messages/. This will not introduce any bugs — I have already deployed and tested it.

If I implement it the way you suggested, i.e., mcp.create_sse_app(path='/mcp/sse', message_path='/mcp/messages'), and use Nginx to forward the MCP server to the /mcp subpath under the example.com domain, then the client would need to access https://example.com/mcp/mcp/sse to reach /mcp/sse on the MCP server — note the two /mcp paths. However, the returned message path will still be /mcp/messages/, and when the client requests it, the URL will be https://example.com/mcp/messages/, which will result in a 404 error because two /mcp paths are required. The client would need to access https://example.com/mcp/mcp/messages/ to reach /mcp/messages/ on the MCP server.-- I have already deployed and tested it too !!!

Additionally, if it is not implemented the way I described, it will not be possible to use Nginx or similar tools to forward the MCP server.

@LiangYang666
Copy link
Author

However, I later tested using ASGI's root_path, and it works. Like this:

mcp = FastMCP()
sse_app = mcp.sse_app()

if __name__ == "__main__":
    uvicorn.run(sse_app, host="0.0.0.0", port=8000, root_path="/mcp")

Therefore, this can also resolve the issue. Thank you.

However, I still believe that root_path should be considered as a parameter for the MCP server. Please think about it.

@LiangYang666
Copy link
Author

@jlowin

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.

2 participants