Skip to content

Client doesn't use bath path for SSE transport? #150

@alexellis

Description

@alexellis
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())

I'm running this command against a function deployed to OpenFaaS. It supports SSE, and is running the sample / demo for SSE transport with uvicorn/Starlette.

The connection starts - however the client immediately tries to access the wrong base path for the messages endpoint:

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

When I add debugging to the server, and use Cursor as a client instead, it still fails and I get:

2025/04/14 11:53:31 stdout: === SSE Event ===
2025/04/14 11:53:31 stdout: event: endpoint
2025/04/14 11:53:31 stdout: data: /messages/?session_id=fd599c32ea5d4f25ba434b9f13e291bf

Clearly, there is nothing mounted at /messages, it needs to respect the base path of "http://127.0.0.1:8080/function/mcp

#!/usr/bin/env python

from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.requests import Request
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse, Response, StreamingResponse
import json
import asyncio

import os
from function.handler import Handler

class LoggingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        print(f"\n=== Request ===")
        print(f"Path: {request.url.path}")
        print(f"Method: {request.method}")
        print(f"Headers: {dict(request.headers)}")
        
        response = await call_next(request)
        
        print(f"\n=== Response ===")
        print(f"Status: {response.status_code}")
        print(f"Headers: {dict(response.headers)}")
        
        # Log SSE events if it's an event stream
        if response.headers.get('content-type', '').startswith('text/event-stream'):
            print("\n=== SSE Stream Start ===")
            
            async def logged_streaming():
                async for chunk in response.body_iterator:
                    # Log each chunk of the SSE stream
                    try:
                        decoded = chunk.decode('utf-8')
                        if decoded.strip():  # Only log non-empty chunks
                            print(f"\n=== SSE Event ===\n{decoded}")
                    except Exception as e:
                        print(f"Error decoding SSE chunk: {e}")
                    yield chunk
            
            return StreamingResponse(
                logged_streaming(),
                status_code=response.status_code,
                headers=dict(response.headers),
                media_type=response.media_type
            )
            
        return response

async def handle_messages(request):
    session_id = request.query_params.get('session_id')
    if request.method == "GET":
        return JSONResponse({
            "status": "ok",
            "messages": [],
            "session_id": session_id
        })
    else:  # POST
        data = await request.json()
        print(f"\n=== Message POST Data ===\n{json.dumps(data, indent=2)}")
        return JSONResponse({
            "status": "ok",
            "session_id": session_id
        })

# Initialize FastMCP with our API name
mcp = FastMCP("OpenFaaS API")

# Initialize handler with mcp instance
h = Handler(mcp)

# Get SSE app once to reuse
sse_app = mcp.sse_app()

# Create Starlette app with both SSE and messages endpoints
app = Starlette(
    debug=True,
    middleware=[
        Middleware(LoggingMiddleware)
    ],
    routes=[
        Route('/sse', endpoint=sse_app, methods=['GET']),
        Route('/sse/', endpoint=sse_app, methods=['GET']),
        Route('/messages', endpoint=handle_messages, methods=['GET', 'POST']),
        Route('/messages/', endpoint=handle_messages, methods=['GET', 'POST'])
    ]
)

if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=5000)

I've tried mounting/routing at various paths and combinations. The messages route was a suggestion from Cursor, which I don't think is being used.

Is this an oversight? Do you need anything else from me for a fix?

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions