fix: use correct Starlette mount pattern for MCP SSE routing
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s
All checks were successful
Build and Push Monarch MCP Docker Image / build (push) Successful in 8s
The previous implementation added health route directly to mcp_app and returned it, which broke MCP's internal /mcp endpoint routing. Now matches the working pattern from komodo-mcp-custom: - Mount mcp_app at / inside a parent Starlette app - Pass lifespan=mcp_app.lifespan for proper task group init - Health check is a separate route in the parent app
This commit is contained in:
45
PROBLEM.md
Normal file
45
PROBLEM.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# PROBLEM: Monarch MCP Server SSE Routing and Handshake Failures
|
||||
|
||||
## Issue Description
|
||||
The Monarch MCP server, implemented using `FastMCP` and `Starlette`, is experiencing persistent `404 Not Found`, `TypeError`, or `400 Bad Request` errors when AI agent clients attempt to establish an SSE connection at `http://docker.local.ben.io:8070/mcp`. While the `/health` endpoint is functioning correctly, the MCP handshake fails to complete or routes to non-existent endpoints.
|
||||
|
||||
## Root Cause Analysis (Suspected)
|
||||
The primary issue stems from the complexity of mounting a `FastMCP` HTTP application within a `Starlette` wrapper.
|
||||
1. **Endpoint Overlap:** `FastMCP.http_app()` generates its own internal routing (e.g., `/mcp` or `/sse`). When this app is mounted at `/mcp` in a parent `Starlette` app, the resulting path becomes `/mcp/mcp`, leading to `404` errors on the expected `/mcp` path.
|
||||
2. **ASGI Signature Mismatches:** Manual attempts to bridge the `SseServerTransport` with Starlette routes led to `TypeError: 'NoneType' object is not callable` or missing positional arguments (`receive`, `send`), indicating that the custom endpoint wrappers were not correctly following the ASGI/Starlette interface.
|
||||
3. **Transport Defaults:** `FastMCP` default transport ("streamable-http") expects specific headers and session IDs that differ from standard MCP SSE implementations, causing `400 Bad Request: Missing session ID` when using generic tools like `curl`.
|
||||
|
||||
## Troubleshooting Steps Taken
|
||||
1. **Refactored Auth:** Removed `keyring` from the Docker runtime to prevent headless environment crashes; moved to `MONARCH_TOKEN` environment variable.
|
||||
2. **Infrastructure Validation:** Verified port `8070` is unique on the `local.ben.io` server.
|
||||
3. **Internal Logic Debugging:**
|
||||
- Attempted mounting `FastMCP` at root (`/`) and sub-paths (`/mcp`).
|
||||
- Inspected `FastMCP` source code to identify internal attributes (`_mcp_server`, `_lifespan`).
|
||||
- Switched from manual `SseServerTransport` handling to `mcp.http_app()`.
|
||||
4. **Endpoint Verification:**
|
||||
- Confirmed `/health` returns `200 OK`.
|
||||
- Tested `/mcp` with `Accept: text/event-stream`, which yielded `400 Bad Request` or `406 Not Acceptable` depending on the mount point.
|
||||
5. **Session Exploration:** Investigated OpenCode session storage (`~/.local/share/opencode/storage/session/`) to understand how clients identify and connect to projects.
|
||||
|
||||
## Current State
|
||||
The server is running in a Komodo stack, healthy on port `8070`, and visible at `https://monarch-mcp.ext.ben.io`. However, the handshake remains broken.
|
||||
|
||||
## Resolution (Applied)
|
||||
The fix was to match the working pattern from `komodo-mcp-custom`:
|
||||
|
||||
```python
|
||||
def create_app():
|
||||
mcp_app = mcp.http_app()
|
||||
routes = [
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Mount("/", app=mcp_app), # MCP handles /mcp endpoint internally
|
||||
]
|
||||
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
|
||||
|
||||
app = create_app()
|
||||
```
|
||||
|
||||
Key changes:
|
||||
1. **Mount at root:** `Mount("/", app=mcp_app)` lets FastMCP handle `/mcp` internally
|
||||
2. **Pass lifespan:** `lifespan=mcp_app.lifespan` ensures proper task group initialization
|
||||
3. **Separate health route:** `/health` is a standalone route in the parent Starlette app
|
||||
@@ -5,17 +5,13 @@ Monarch Money MCP Server - Custom SSE Implementation.
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastmcp import FastMCP
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route, Mount
|
||||
from mcp.server.sse import SseServerTransport
|
||||
import uvicorn
|
||||
|
||||
from monarch_mcp_custom.auth import get_authenticated_client
|
||||
|
||||
@@ -166,35 +162,36 @@ async def refresh_accounts() -> str:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
# --- Health Check ---
|
||||
# --- Health Check Endpoint ---
|
||||
|
||||
|
||||
async def health_check(request):
|
||||
"""Simple health check endpoint."""
|
||||
return JSONResponse({"status": "ok", "timestamp": datetime.now().isoformat()})
|
||||
async def health(request):
|
||||
"""Health check endpoint for Docker."""
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
# --- ASGI App Setup ---
|
||||
# --- ASGI Application ---
|
||||
|
||||
|
||||
def create_app() -> Starlette:
|
||||
"""Create the Starlette application with health check and MCP."""
|
||||
def create_app():
|
||||
"""Create the ASGI application with health check and MCP routes."""
|
||||
mcp_app = mcp.http_app()
|
||||
|
||||
# Add health check route directly to the MCP app
|
||||
mcp_app.add_route("/health", health_check, methods=["GET"])
|
||||
# Wrapper app: /health is standalone, everything else goes to MCP
|
||||
# IMPORTANT: Must pass mcp_app.lifespan for task group initialization
|
||||
routes = [
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Mount("/", app=mcp_app), # MCP handles /mcp endpoint
|
||||
]
|
||||
|
||||
return mcp_app
|
||||
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
|
||||
|
||||
|
||||
# Create the app instance for uvicorn
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for running the server."""
|
||||
port = int(os.getenv("PORT", 8000))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
Reference in New Issue
Block a user