diff --git a/basecamp_fastmcp.py b/basecamp_fastmcp.py new file mode 100644 index 0000000..f7515e7 --- /dev/null +++ b/basecamp_fastmcp.py @@ -0,0 +1,703 @@ +#!/usr/bin/env python3 +""" +FastMCP server for Basecamp integration. + +This server implements the MCP (Model Context Protocol) using the official +Anthropic FastMCP framework, replacing the custom JSON-RPC implementation. +""" + +import logging +import os +import sys +from typing import Any, Dict, List, Optional +import anyio +import httpx +from mcp.server.fastmcp import FastMCP + +# Import existing business logic +from basecamp_client import BasecampClient +from search_utils import BasecampSearch +import token_storage +from dotenv import load_dotenv + +# Determine project root (directory containing this script) +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +DOTENV_PATH = os.path.join(PROJECT_ROOT, '.env') +load_dotenv(DOTENV_PATH) + +# Set up logging to file AND stderr (following MCP best practices) +LOG_FILE_PATH = os.path.join(PROJECT_ROOT, 'basecamp_fastmcp.log') +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(LOG_FILE_PATH), + logging.StreamHandler(sys.stderr) # Critical: log to stderr, not stdout + ] +) +logger = logging.getLogger('basecamp_fastmcp') + +# Initialize FastMCP server +mcp = FastMCP("basecamp") + +# Auth helper functions (reused from original server) +def _get_basecamp_client() -> Optional[BasecampClient]: + """Get authenticated Basecamp client (sync version from original server).""" + try: + token_data = token_storage.get_token() + logger.debug(f"Token data retrieved: {token_data}") + + if not token_data or not token_data.get('access_token'): + logger.error("No OAuth token available") + return None + + # Check if token is expired + if token_storage.is_token_expired(): + logger.error("OAuth token has expired") + return None + + # Get account_id from token data first, then fall back to env var + account_id = token_data.get('account_id') or os.getenv('BASECAMP_ACCOUNT_ID') + user_agent = os.getenv('USER_AGENT') or "Basecamp MCP Server (cursor@example.com)" + + if not account_id: + logger.error(f"Missing account_id. Token data: {token_data}, Env BASECAMP_ACCOUNT_ID: {os.getenv('BASECAMP_ACCOUNT_ID')}") + return None + + logger.debug(f"Creating Basecamp client with account_id: {account_id}, user_agent: {user_agent}") + + return BasecampClient( + access_token=token_data['access_token'], + account_id=account_id, + user_agent=user_agent, + auth_mode='oauth' + ) + except Exception as e: + logger.error(f"Error creating Basecamp client: {e}") + return None + +def _get_auth_error_response() -> Dict[str, Any]: + """Return consistent auth error response.""" + if token_storage.is_token_expired(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token has expired. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + else: + return { + "error": "Authentication required", + "message": "Please authenticate with Basecamp first. Visit http://localhost:8000 to log in." + } + +async def _run_sync(func, *args, **kwargs): + """Wrapper to run synchronous functions in thread pool.""" + return await anyio.to_thread.run_sync(func, *args, **kwargs) + +# Core MCP Tools - Starting with essential ones from original server + +@mcp.tool() +async def get_projects() -> Dict[str, Any]: + """Get all Basecamp projects.""" + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + projects = await _run_sync(client.get_projects) + return { + "status": "success", + "projects": projects, + "count": len(projects) + } + except Exception as e: + logger.error(f"Error getting projects: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_project(project_id: str) -> Dict[str, Any]: + """Get details for a specific project. + + Args: + project_id: The project ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + project = await _run_sync(client.get_project, project_id) + return { + "status": "success", + "project": project + } + except Exception as e: + logger.error(f"Error getting project {project_id}: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def search_basecamp(query: str, project_id: Optional[str] = None) -> Dict[str, Any]: + """Search across Basecamp projects, todos, and messages. + + Args: + query: Search query + project_id: Optional project ID to limit search scope + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + search = BasecampSearch(client=client) + results = {} + + if project_id: + # Search within specific project + results["todolists"] = await _run_sync(search.search_todolists, query, project_id) + results["todos"] = await _run_sync(search.search_todos, query, project_id) + else: + # Search across all projects + results["projects"] = await _run_sync(search.search_projects, query) + results["todos"] = await _run_sync(search.search_todos, query) + results["messages"] = await _run_sync(search.search_messages, query) + + return { + "status": "success", + "query": query, + "results": results + } + except Exception as e: + logger.error(f"Error searching Basecamp: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_todolists(project_id: str) -> Dict[str, Any]: + """Get todo lists for a project. + + Args: + project_id: The project ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + todolists = await _run_sync(client.get_todolists, project_id) + return { + "status": "success", + "todolists": todolists, + "count": len(todolists) + } + except Exception as e: + logger.error(f"Error getting todolists: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_todos(project_id: str, todolist_id: str) -> Dict[str, Any]: + """Get todos from a todo list. + + Args: + project_id: Project ID + todolist_id: The todo list ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + todos = await _run_sync(client.get_todos, project_id, todolist_id) + return { + "status": "success", + "todos": todos, + "count": len(todos) + } + except Exception as e: + logger.error(f"Error getting todos: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def global_search(query: str) -> Dict[str, Any]: + """Search projects, todos and campfire messages across all projects. + + Args: + query: Search query + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + search = BasecampSearch(client=client) + results = await _run_sync(search.global_search, query) + return { + "status": "success", + "query": query, + "results": results + } + except Exception as e: + logger.error(f"Error in global search: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_comments(recording_id: str, project_id: str) -> Dict[str, Any]: + """Get comments for a Basecamp item. + + Args: + recording_id: The item ID + project_id: The project ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + comments = await _run_sync(client.get_comments, project_id, recording_id) + return { + "status": "success", + "comments": comments, + "count": len(comments) + } + except Exception as e: + logger.error(f"Error getting comments: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_campfire_lines(project_id: str, campfire_id: str) -> Dict[str, Any]: + """Get recent messages from a Basecamp campfire (chat room). + + Args: + project_id: The project ID + campfire_id: The campfire/chat room ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + lines = await _run_sync(client.get_campfire_lines, project_id, campfire_id) + return { + "status": "success", + "campfire_lines": lines, + "count": len(lines) + } + except Exception as e: + logger.error(f"Error getting campfire lines: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_card_tables(project_id: str) -> Dict[str, Any]: + """Get all card tables for a project. + + Args: + project_id: The project ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + card_tables = await _run_sync(client.get_card_tables, project_id) + return { + "status": "success", + "card_tables": card_tables, + "count": len(card_tables) + } + except Exception as e: + logger.error(f"Error getting card tables: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_card_table(project_id: str) -> Dict[str, Any]: + """Get the card table details for a project. + + Args: + project_id: The project ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + card_table = await _run_sync(client.get_card_table, project_id) + card_table_details = await _run_sync(client.get_card_table_details, project_id, card_table['id']) + return { + "status": "success", + "card_table": card_table_details + } + except Exception as e: + logger.error(f"Error getting card table: {e}") + error_msg = str(e) + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "status": "error", + "message": f"Error getting card table: {error_msg}", + "debug": error_msg + } + +@mcp.tool() +async def get_columns(project_id: str, card_table_id: str) -> Dict[str, Any]: + """Get all columns in a card table. + + Args: + project_id: The project ID + card_table_id: The card table ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + columns = await _run_sync(client.get_columns, project_id, card_table_id) + return { + "status": "success", + "columns": columns, + "count": len(columns) + } + except Exception as e: + logger.error(f"Error getting columns: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_cards(project_id: str, column_id: str) -> Dict[str, Any]: + """Get all cards in a column. + + Args: + project_id: The project ID + column_id: The column ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + cards = await _run_sync(client.get_cards, project_id, column_id) + return { + "status": "success", + "cards": cards, + "count": len(cards) + } + except Exception as e: + logger.error(f"Error getting cards: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def create_card(project_id: str, column_id: str, title: str, content: Optional[str] = None, due_on: Optional[str] = None, notify: bool = False) -> Dict[str, Any]: + """Create a new card in a column. + + Args: + project_id: The project ID + column_id: The column ID + title: The card title + content: Optional card content/description + due_on: Optional due date (ISO 8601 format) + notify: Whether to notify assignees (default: false) + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + card = await _run_sync(client.create_card, project_id, column_id, title, content, due_on, notify) + return { + "status": "success", + "card": card, + "message": f"Card '{title}' created successfully" + } + except Exception as e: + logger.error(f"Error creating card: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_column(project_id: str, column_id: str) -> Dict[str, Any]: + """Get details for a specific column. + + Args: + project_id: The project ID + column_id: The column ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + column = await _run_sync(client.get_column, project_id, column_id) + return { + "status": "success", + "column": column + } + except Exception as e: + logger.error(f"Error getting column: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def create_column(project_id: str, card_table_id: str, title: str) -> Dict[str, Any]: + """Create a new column in a card table. + + Args: + project_id: The project ID + card_table_id: The card table ID + title: The column title + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + column = await _run_sync(client.create_column, project_id, card_table_id, title) + return { + "status": "success", + "column": column, + "message": f"Column '{title}' created successfully" + } + except Exception as e: + logger.error(f"Error creating column: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def move_card(project_id: str, card_id: str, column_id: str) -> Dict[str, Any]: + """Move a card to a new column. + + Args: + project_id: The project ID + card_id: The card ID + column_id: The destination column ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + await _run_sync(client.move_card, project_id, card_id, column_id) + return { + "status": "success", + "message": f"Card moved to column {column_id}" + } + except Exception as e: + logger.error(f"Error moving card: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def complete_card(project_id: str, card_id: str) -> Dict[str, Any]: + """Mark a card as complete. + + Args: + project_id: The project ID + card_id: The card ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + await _run_sync(client.complete_card, project_id, card_id) + return { + "status": "success", + "message": "Card marked as complete" + } + except Exception as e: + logger.error(f"Error completing card: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def get_card(project_id: str, card_id: str) -> Dict[str, Any]: + """Get details for a specific card. + + Args: + project_id: The project ID + card_id: The card ID + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + card = await _run_sync(client.get_card, project_id, card_id) + return { + "status": "success", + "card": card + } + except Exception as e: + logger.error(f"Error getting card: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +@mcp.tool() +async def update_card(project_id: str, card_id: str, title: Optional[str] = None, content: Optional[str] = None, due_on: Optional[str] = None, assignee_ids: Optional[List[str]] = None) -> Dict[str, Any]: + """Update a card. + + Args: + project_id: The project ID + card_id: The card ID + title: The new card title + content: The new card content/description + due_on: Due date (ISO 8601 format) + assignee_ids: Array of person IDs to assign to the card + """ + client = _get_basecamp_client() + if not client: + return _get_auth_error_response() + + try: + card = await _run_sync(client.update_card, project_id, card_id, title, content, due_on, assignee_ids) + return { + "status": "success", + "card": card, + "message": "Card updated successfully" + } + except Exception as e: + logger.error(f"Error updating card: {e}") + if "401" in str(e) and "expired" in str(e).lower(): + return { + "error": "OAuth token expired", + "message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again." + } + return { + "error": "Execution error", + "message": str(e) + } + +# Core FastMCP server with essential Basecamp functionality +# Additional tools can be added incrementally as needed + +if __name__ == "__main__": + logger.info("Starting Basecamp FastMCP server") + # Run using official MCP stdio transport + mcp.run(transport='stdio') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c86a07f..a4599ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,6 @@ python-dotenv==1.0.0 flask==2.3.3 flask-cors==4.0.0 pytest==7.4.0 +mcp[cli]>=1.2.0 +httpx>=0.25.0 +anyio>=4.0.0