#!/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')