diff --git a/PROBLEM.md b/PROBLEM.md new file mode 100644 index 0000000..495b3ff --- /dev/null +++ b/PROBLEM.md @@ -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 diff --git a/src/monarch_mcp_custom/server.py b/src/monarch_mcp_custom/server.py index 4f16212..2ee7ad7 100644 --- a/src/monarch_mcp_custom/server.py +++ b/src/monarch_mcp_custom/server.py @@ -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)