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

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:
Ben
2025-12-24 05:10:32 +00:00
parent 65c79efc60
commit 1210cbf6d2
2 changed files with 64 additions and 22 deletions

45
PROBLEM.md Normal file
View 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

View File

@@ -5,17 +5,13 @@ Monarch Money MCP Server - Custom SSE Implementation.
import os import os
import logging import logging
import json import json
import asyncio from typing import Optional, Any
from typing import Optional, List, Dict, Any
from datetime import datetime
from dotenv import load_dotenv from dotenv import load_dotenv
from fastmcp import FastMCP from fastmcp import FastMCP
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.routing import Route, Mount from starlette.routing import Route, Mount
from mcp.server.sse import SseServerTransport
import uvicorn
from monarch_mcp_custom.auth import get_authenticated_client from monarch_mcp_custom.auth import get_authenticated_client
@@ -166,35 +162,36 @@ async def refresh_accounts() -> str:
return f"Error: {str(e)}" return f"Error: {str(e)}"
# --- Health Check --- # --- Health Check Endpoint ---
async def health_check(request): async def health(request):
"""Simple health check endpoint.""" """Health check endpoint for Docker."""
return JSONResponse({"status": "ok", "timestamp": datetime.now().isoformat()}) return JSONResponse({"status": "ok"})
# --- ASGI App Setup --- # --- ASGI Application ---
def create_app() -> Starlette: def create_app():
"""Create the Starlette application with health check and MCP.""" """Create the ASGI application with health check and MCP routes."""
mcp_app = mcp.http_app() mcp_app = mcp.http_app()
# Add health check route directly to the MCP app # Wrapper app: /health is standalone, everything else goes to MCP
mcp_app.add_route("/health", health_check, methods=["GET"]) # 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() 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__": if __name__ == "__main__":
main() import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)