From b0deac4d8738fcdd79a01a4299f3b21511448522 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:41:24 +0000 Subject: [PATCH] feat: Implement SSE endpoint for Cursor compatibility This commit introduces a Server-Sent Events (SSE) endpoint at `/mcp/stream` to improve compatibility with Cursor, which appears to expect an SSE stream for actions like `ListOfferings`. Changes include: - Added a new `/mcp/stream` route to `mcp_server.py`. - This stream sends an initial "connected" event. - It then attempts to fetch and stream Basecamp projects as "offering" events. - Handles authentication errors (e.g., missing tokens) by sending an `auth_error` event over SSE. - Sends an "offerings_complete" event after successfully streaming projects. - The stream sends periodic "ping" events to keep the connection alive. - The `Content-Type` header for `/mcp/stream` is correctly set to `text/event-stream`. - Updated `README.md` to suggest the new `/mcp/stream` URL for Cursor configuration and provide context. Issue #3 (regarding refactoring BasecampClient initialization) was investigated. The existing `get_basecamp_client()` in `mcp_server.py`, which includes token refresh logic, was found to be more suitable than alternatives in `mcp_integration.py` or `composio_integration.py`. No code changes were made for Issue #3. Testing with `curl` confirmed the SSE endpoint's functionality, including correct event flow and error handling. --- README.md | 8 ++++--- mcp_server.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 09b6d46..53e0016 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,11 @@ The project consists of the following components: ### Using with Cursor -1. In Cursor, add the MCP server URL: http://localhost:5001 -2. Interact with Basecamp through the Cursor interface -3. The MCP server will use the stored OAuth token to authenticate with Basecamp +1. In Cursor, add the MCP server URL: `http://localhost:5001/mcp/stream` + - **Note:** This URL points to the new Server-Sent Events (SSE) endpoint. Cursor may prefer this for real-time updates and for features like `ListOfferings` that stream data. + - If you encounter issues, Cursor might alternatively try to use the base URL (`http://localhost:5001`) to first query `/mcp/info` (to discover available actions and endpoints) and then connect to the appropriate endpoint as indicated there. +2. Interact with Basecamp through the Cursor interface. +3. The MCP server will use the stored OAuth token to authenticate with Basecamp. ### Authentication Flow diff --git a/mcp_server.py b/mcp_server.py index 7da803c..f072f4e 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -4,7 +4,7 @@ import sys import json import logging import traceback -from flask import Flask, request, jsonify +from flask import Flask, request, jsonify, Response from dotenv import load_dotenv from threading import Thread import time @@ -812,6 +812,67 @@ def composio_check_auth(): logger.error(f"Error checking Composio auth: {str(e)}") return jsonify({"error": "server_error", "message": f"Error checking auth: {str(e)}"}), 500 +@app.route('/mcp/stream', methods=['GET']) +def mcp_stream(): + """ + Server-Sent Events (SSE) endpoint for real-time updates. + """ + logger.info("SSE stream requested by client") + def event_stream(): + try: + logger.info("SSE client connected, sending 'connected' event.") + yield f"event: connected\ndata: {json.dumps({'message': 'Connection established'})}\n\n" + + # Attempt to get Basecamp client and fetch projects (offerings) + try: + logger.info("Attempting to get Basecamp client for offerings list.") + client = get_basecamp_client(auth_mode='oauth') # Assuming OAuth for streaming + logger.info("Basecamp client obtained successfully.") + + logger.info("Fetching projects as offerings.") + projects = client.get_projects() + logger.info(f"Successfully fetched {len(projects)} projects.") + + for project in projects: + project_data_json = json.dumps(project) # Ensure project data is JSON serializable + logger.debug(f"SSE sending offering: {project.get('name')}") + yield f"event: offering\ndata: {project_data_json}\n\n" + + logger.info("All offerings sent.") + yield f"event: offerings_complete\ndata: {json.dumps({'message': 'All offerings sent'})}\n\n" + + except ValueError as ve: # Handles auth errors from get_basecamp_client + error_message = f"Authentication error: {str(ve)}" + logger.error(f"SSE stream auth error: {error_message}", exc_info=True) + yield f"event: error\ndata: {json.dumps({'type': 'auth_error', 'message': error_message})}\n\n" + except Exception as e: # Handles API errors from get_projects or other unexpected issues + error_message = f"Failed to fetch projects: {str(e)}" + logger.error(f"SSE stream API error: {error_message}", exc_info=True) + yield f"event: error\ndata: {json.dumps({'type': 'api_error', 'message': error_message})}\n\n" + + # Continue with periodic pings + ping_count = 0 + while True: + time.sleep(5) # Send a ping every 5 seconds + ping_count += 1 + logger.debug(f"SSE sending ping event #{ping_count}") + yield f"event: ping\ndata: {json.dumps({'count': ping_count})}\n\n" + + except GeneratorExit: + logger.info("SSE client disconnected.") + except Exception as e: + # This catches errors in the main try block, including the ping loop or if yield fails + logger.error(f"Unhandled error in SSE event stream: {str(e)}", exc_info=True) + try: + # Attempt to send a final error to the client if possible + yield f"event: error\ndata: {json.dumps({'type': 'stream_error', 'message': 'An unexpected error occurred in the stream.'})}\n\n" + except Exception: # If yielding fails (e.g. client already gone) + pass + finally: + logger.info("Closing SSE event stream.") + + return Response(event_stream(), mimetype='text/event-stream') + if __name__ == '__main__': try: logger.info(f"Starting MCP server on port {MCP_PORT}")