diff --git a/README.md b/README.md index a882585..0d79174 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,48 @@ This project provides a **FastMCP-powered** integration for Basecamp 3, allowing Cursor to interact with Basecamp directly through the MCP protocol. -✅ **Migration Complete:** Successfully migrated to official Anthropic FastMCP framework with **100% feature parity** (all 46 tools) +✅ **Migration Complete:** Successfully migrated to official Anthropic FastMCP framework with **100% feature parity** (all 46 tools) 🚀 **Ready for Production:** Full protocol compliance with MCP 2025-06-18 +## Configuration + +The Basecamp MCP server is configured entirely via environment variables. No `.env` file is required. + +### Required Environment Variables + +All required configuration is passed via environment variables: + +- `BASECAMP_CLIENT_ID` - OAuth client ID from 37signals +- `BASECAMP_CLIENT_SECRET` - OAuth client secret from 37signals +- `BASECAMP_ACCOUNT_ID` - Your Basecamp account ID +- `BASECAMP_REDIRECT_URI` - OAuth callback URL (usually `http://localhost:8000/auth/callback`) +- `USER_AGENT` - User agent string for API requests + +### Data Locations + +The server stores data in XDG-compliant locations: + +| File Type | Default Location | Environment Variable Override | +|-----------|------------------|-------------------------------| +| OAuth Tokens | `~/.local/share/basecamp-mcp/oauth_tokens.json` | `BASECAMP_TOKEN_FILE` | +| Server Logs | `~/.local/state/basecamp-mcp/` | `BASECAMP_LOG_DIR` | + +### Optional .env File (Development Only) + +For local development, you can optionally create a `.env` file to avoid setting environment variables each time: + +```bash +# Default location: ~/.config/basecamp-mcp/.env +# Or override with: BASECAMP_ENV_FILE=/custom/path/.env +BASECAMP_CLIENT_ID=your_client_id_here +BASECAMP_CLIENT_SECRET=your_client_secret_here +BASECAMP_ACCOUNT_ID=your_account_id_here +BASECAMP_REDIRECT_URI=http://localhost:8000/auth/callback +USER_AGENT="Your App Name (your@email.com)" +``` + +**Note:** The server works perfectly without a `.env` file - it's only a convenience for development. + ## Quick Setup This server works with both **Cursor** and **Claude Desktop**. Choose your preferred client: diff --git a/basecamp_fastmcp.py b/basecamp_fastmcp.py index e7b51cf..88ff74c 100644 --- a/basecamp_fastmcp.py +++ b/basecamp_fastmcp.py @@ -14,6 +14,8 @@ import anyio import httpx from mcp.server.fastmcp import FastMCP +from config_paths import get_log_directory, get_env_file_path, ensure_directories_exist + # Import existing business logic from basecamp_client import BasecampClient from search_utils import BasecampSearch @@ -21,26 +23,31 @@ import token_storage import auth_manager 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) +ensure_directories_exist() -# Set up logging to file AND stderr (following MCP best practices) -LOG_FILE_PATH = os.path.join(PROJECT_ROOT, 'basecamp_fastmcp.log') +DOTENV_PATH = get_env_file_path() +if DOTENV_PATH.exists(): + load_dotenv(DOTENV_PATH) + logger = logging.getLogger("basecamp_fastmcp") + logger.debug(f"Loaded .env from {DOTENV_PATH}") +else: + logger = logging.getLogger("basecamp_fastmcp") + +LOG_FILE_PATH = get_log_directory() / "basecamp_fastmcp.log" logging.basicConfig( level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + 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 - ] + logging.StreamHandler(sys.stderr), # Critical: log to stderr, not stdout + ], ) -logger = logging.getLogger('basecamp_fastmcp') +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).""" @@ -48,7 +55,7 @@ def _get_basecamp_client() -> Optional[BasecampClient]: 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'): + if not token_data or not token_data.get("access_token"): logger.error("No OAuth token available") return None @@ -61,103 +68,104 @@ def _get_basecamp_client() -> Optional[BasecampClient]: token_data = token_storage.get_token() # 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)" + 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')}") + 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}") + logger.debug( + f"Creating Basecamp client with account_id: {account_id}, user_agent: {user_agent}" + ) return BasecampClient( - access_token=token_data['access_token'], + access_token=token_data["access_token"], account_id=account_id, user_agent=user_agent, - auth_mode='oauth' + 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." + "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." + "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) - } + 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." + "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) - } + 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 - } + 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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def search_basecamp(query: str, project_id: Optional[str] = None) -> Dict[str, Any]: +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 @@ -165,14 +173,16 @@ async def search_basecamp(query: str, project_id: Optional[str] = None) -> Dict[ 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["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 @@ -180,57 +190,45 @@ async def search_basecamp(query: str, project_id: Optional[str] = None) -> Dict[ 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 - } + 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." + "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) - } + 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) - } + 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." + "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) - } + 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 @@ -238,36 +236,34 @@ async def get_todos(project_id: str, todolist_id: str) -> Dict[str, Any]: 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) - } + 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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def create_todo(project_id: str, todolist_id: str, content: str, - description: Optional[str] = None, - assignee_ids: Optional[List[str]] = None, - completion_subscriber_ids: Optional[List[str]] = None, - notify: bool = False, - due_on: Optional[str] = None, - starts_on: Optional[str] = None) -> Dict[str, Any]: +async def create_todo( + project_id: str, + todolist_id: str, + content: str, + description: Optional[str] = None, + assignee_ids: Optional[List[str]] = None, + completion_subscriber_ids: Optional[List[str]] = None, + notify: bool = False, + due_on: Optional[str] = None, + starts_on: Optional[str] = None, +) -> Dict[str, Any]: """Create a new todo item in a todo list. - + Args: project_id: Project ID todolist_id: The todo list ID @@ -282,48 +278,51 @@ async def create_todo(project_id: str, todolist_id: str, content: str, client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: # Use lambda to properly handle keyword arguments todo = await _run_sync( lambda: client.create_todo( - project_id, todolist_id, content, + project_id, + todolist_id, + content, description=description, assignee_ids=assignee_ids, completion_subscriber_ids=completion_subscriber_ids, notify=notify, due_on=due_on, - starts_on=starts_on + starts_on=starts_on, ) ) return { "status": "success", "todo": todo, - "message": f"Todo '{content}' created successfully" + "message": f"Todo '{content}' created successfully", } except Exception as e: logger.error(f"Error creating todo: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def update_todo(project_id: str, todo_id: str, - content: Optional[str] = None, - description: Optional[str] = None, - assignee_ids: Optional[List[str]] = None, - completion_subscriber_ids: Optional[List[str]] = None, - notify: Optional[bool] = None, - due_on: Optional[str] = None, - starts_on: Optional[str] = None) -> Dict[str, Any]: +async def update_todo( + project_id: str, + todo_id: str, + content: Optional[str] = None, + description: Optional[str] = None, + assignee_ids: Optional[List[str]] = None, + completion_subscriber_ids: Optional[List[str]] = None, + notify: Optional[bool] = None, + due_on: Optional[str] = None, + starts_on: Optional[str] = None, +) -> Dict[str, Any]: """Update an existing todo item. - + Args: project_id: Project ID todo_id: The todo ID @@ -337,50 +336,58 @@ async def update_todo(project_id: str, todo_id: str, client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: # Guard against no-op updates - if all(v is None for v in [content, description, assignee_ids, - completion_subscriber_ids, notify, - due_on, starts_on]): + if all( + v is None + for v in [ + content, + description, + assignee_ids, + completion_subscriber_ids, + notify, + due_on, + starts_on, + ] + ): return { "error": "Invalid input", - "message": "At least one field to update must be provided" + "message": "At least one field to update must be provided", } # Use lambda to properly handle keyword arguments todo = await _run_sync( lambda: client.update_todo( - project_id, todo_id, + project_id, + todo_id, content=content, description=description, assignee_ids=assignee_ids, completion_subscriber_ids=completion_subscriber_ids, notify=notify, due_on=due_on, - starts_on=starts_on + starts_on=starts_on, ) ) return { "status": "success", "todo": todo, - "message": "Todo updated successfully" + "message": "Todo updated successfully", } except Exception as e: logger.error(f"Error updating todo: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def delete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: """Delete a todo item. - + Args: project_id: Project ID todo_id: The todo ID @@ -388,29 +395,24 @@ async def delete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.delete_todo, project_id, todo_id) - return { - "status": "success", - "message": "Todo deleted successfully" - } + return {"status": "success", "message": "Todo deleted successfully"} except Exception as e: logger.error(f"Error deleting todo: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def complete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: """Mark a todo item as complete. - + Args: project_id: Project ID todo_id: The todo ID @@ -418,30 +420,28 @@ async def complete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: completion = await _run_sync(client.complete_todo, project_id, todo_id) return { "status": "success", "completion": completion, - "message": "Todo marked as complete" + "message": "Todo marked as complete", } except Exception as e: logger.error(f"Error completing todo: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def uncomplete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: """Mark a todo item as incomplete. - + Args: project_id: Project ID todo_id: The todo ID @@ -449,58 +449,49 @@ async def uncomplete_todo(project_id: str, todo_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.uncomplete_todo, project_id, todo_id) - return { - "status": "success", - "message": "Todo marked as incomplete" - } + return {"status": "success", "message": "Todo marked as incomplete"} except Exception as e: logger.error(f"Error uncompleting todo: {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." + "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) - } + 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 - } + 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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def get_comments(recording_id: str, project_id: str, page: int = 1) -> Dict[str, Any]: +async def get_comments( + recording_id: str, project_id: str, page: int = 1 +) -> Dict[str, Any]: """Get comments for a Basecamp item. Args: @@ -521,22 +512,22 @@ async def get_comments(recording_id: str, project_id: str, page: int = 1) -> Dic "count": len(result["comments"]), "page": page, "total_count": result["total_count"], - "next_page": result["next_page"] + "next_page": result["next_page"], } 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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def create_comment(recording_id: str, project_id: str, content: str) -> Dict[str, Any]: +async def create_comment( + recording_id: str, project_id: str, content: str +) -> Dict[str, Any]: """Create a comment on a Basecamp item. Args: @@ -549,11 +540,13 @@ async def create_comment(recording_id: str, project_id: str, content: str) -> Di return _get_auth_error_response() try: - comment = await _run_sync(client.create_comment, recording_id, project_id, content) + comment = await _run_sync( + client.create_comment, recording_id, project_id, content + ) return { "status": "success", "comment": comment, - "message": "Comment created successfully" + "message": "Comment created successfully", } except Exception as e: logger.error(f"Error creating comment: {e}") @@ -562,15 +555,13 @@ async def create_comment(recording_id: str, project_id: str, content: str) -> Di "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) - } + 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 @@ -578,92 +569,84 @@ async def get_campfire_lines(project_id: str, campfire_id: str) -> Dict[str, Any 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) - } + 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." + "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) - } + 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) + "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." + "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) - } + 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 - } + 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." + "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 + "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 @@ -671,30 +654,24 @@ async def get_columns(project_id: str, card_table_id: str) -> Dict[str, Any]: 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) - } + 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." + "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) - } + 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 @@ -702,30 +679,31 @@ async def get_cards(project_id: str, column_id: str) -> Dict[str, Any]: 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) - } + 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." + "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) - } + 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]: +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 @@ -737,30 +715,30 @@ async def create_card(project_id: str, column_id: str, title: str, content: Opti 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) + 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" + "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." + "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) - } + 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 @@ -768,29 +746,26 @@ async def get_column(project_id: str, column_id: str) -> Dict[str, Any]: 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 - } + 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." + "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) - } + 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]: +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 @@ -799,30 +774,28 @@ async def create_column(project_id: str, card_table_id: str, title: str) -> Dict 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" + "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." + "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) - } + 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 @@ -831,29 +804,24 @@ async def move_card(project_id: str, card_id: str, column_id: str) -> Dict[str, 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}" - } + 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." + "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) - } + 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 @@ -861,29 +829,24 @@ async def complete_card(project_id: str, card_id: str) -> Dict[str, Any]: 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" - } + 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." + "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) - } + 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 @@ -891,29 +854,31 @@ async def get_card(project_id: str, card_id: str) -> Dict[str, Any]: 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 - } + 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." + "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) - } + 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]: +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 @@ -925,30 +890,38 @@ async def update_card(project_id: str, card_id: str, title: Optional[str] = None 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) + 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" + "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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def get_daily_check_ins(project_id: str, page: Optional[int] = None) -> Dict[str, Any]: +async def get_daily_check_ins( + project_id: str, page: Optional[int] = None +) -> Dict[str, Any]: """Get project's daily checking questionnaire. - + Args: project_id: The project ID page: Page number paginated response @@ -956,32 +929,30 @@ async def get_daily_check_ins(project_id: str, page: Optional[int] = None) -> Di client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: if page is not None and not isinstance(page, int): page = 1 - answers = await _run_sync(client.get_daily_check_ins, project_id, page=page or 1) - return { - "status": "success", - "campfire_lines": answers, - "count": len(answers) - } + answers = await _run_sync( + client.get_daily_check_ins, project_id, page=page or 1 + ) + return {"status": "success", "campfire_lines": answers, "count": len(answers)} except Exception as e: logger.error(f"Error getting daily check ins: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def get_question_answers(project_id: str, question_id: str, page: Optional[int] = None) -> Dict[str, Any]: +async def get_question_answers( + project_id: str, question_id: str, page: Optional[int] = None +) -> Dict[str, Any]: """Get answers on daily check-in question. - + Args: project_id: The project ID question_id: The question ID @@ -990,33 +961,29 @@ async def get_question_answers(project_id: str, question_id: str, page: Optional client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: if page is not None and not isinstance(page, int): page = 1 - answers = await _run_sync(client.get_question_answers, project_id, question_id, page=page or 1) - return { - "status": "success", - "campfire_lines": answers, - "count": len(answers) - } + answers = await _run_sync( + client.get_question_answers, project_id, question_id, page=page or 1 + ) + return {"status": "success", "campfire_lines": answers, "count": len(answers)} except Exception as e: logger.error(f"Error getting question answers: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + # Column Management Tools @mcp.tool() async def update_column(project_id: str, column_id: str, title: str) -> Dict[str, Any]: """Update a column title. - + Args: project_id: The project ID column_id: The column ID @@ -1025,30 +992,30 @@ async def update_column(project_id: str, column_id: str, title: str) -> Dict[str client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: column = await _run_sync(client.update_column, project_id, column_id, title) return { "status": "success", "column": column, - "message": "Column updated successfully" + "message": "Column updated successfully", } except Exception as e: logger.error(f"Error updating 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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def move_column(project_id: str, card_table_id: str, column_id: str, position: int) -> Dict[str, Any]: +async def move_column( + project_id: str, card_table_id: str, column_id: str, position: int +) -> Dict[str, Any]: """Move a column to a new position. - + Args: project_id: The project ID card_table_id: The card table ID @@ -1058,29 +1025,28 @@ async def move_column(project_id: str, card_table_id: str, column_id: str, posit client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: - await _run_sync(client.move_column, project_id, column_id, position, card_table_id) - return { - "status": "success", - "message": f"Column moved to position {position}" - } + await _run_sync( + client.move_column, project_id, column_id, position, card_table_id + ) + return {"status": "success", "message": f"Column moved to position {position}"} except Exception as e: logger.error(f"Error moving 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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def update_column_color(project_id: str, column_id: str, color: str) -> Dict[str, Any]: +async def update_column_color( + project_id: str, column_id: str, color: str +) -> Dict[str, Any]: """Update a column color. - + Args: project_id: The project ID column_id: The column ID @@ -1089,30 +1055,30 @@ async def update_column_color(project_id: str, column_id: str, color: str) -> Di client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: - column = await _run_sync(client.update_column_color, project_id, column_id, color) + column = await _run_sync( + client.update_column_color, project_id, column_id, color + ) return { "status": "success", "column": column, - "message": f"Column color updated to {color}" + "message": f"Column color updated to {color}", } except Exception as e: logger.error(f"Error updating column color: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def put_column_on_hold(project_id: str, column_id: str) -> Dict[str, Any]: """Put a column on hold (freeze work). - + Args: project_id: The project ID column_id: The column ID @@ -1120,29 +1086,24 @@ async def put_column_on_hold(project_id: str, column_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.put_column_on_hold, project_id, column_id) - return { - "status": "success", - "message": "Column put on hold" - } + return {"status": "success", "message": "Column put on hold"} except Exception as e: logger.error(f"Error putting column on hold: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def remove_column_hold(project_id: str, column_id: str) -> Dict[str, Any]: """Remove hold from a column (unfreeze work). - + Args: project_id: The project ID column_id: The column ID @@ -1150,29 +1111,24 @@ async def remove_column_hold(project_id: str, column_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.remove_column_hold, project_id, column_id) - return { - "status": "success", - "message": "Column hold removed" - } + return {"status": "success", "message": "Column hold removed"} except Exception as e: logger.error(f"Error removing column hold: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def watch_column(project_id: str, column_id: str) -> Dict[str, Any]: """Subscribe to notifications for changes in a column. - + Args: project_id: The project ID column_id: The column ID @@ -1180,29 +1136,24 @@ async def watch_column(project_id: str, column_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.watch_column, project_id, column_id) - return { - "status": "success", - "message": "Column notifications enabled" - } + return {"status": "success", "message": "Column notifications enabled"} except Exception as e: logger.error(f"Error watching 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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def unwatch_column(project_id: str, column_id: str) -> Dict[str, Any]: """Unsubscribe from notifications for a column. - + Args: project_id: The project ID column_id: The column ID @@ -1210,30 +1161,25 @@ async def unwatch_column(project_id: str, column_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.unwatch_column, project_id, column_id) - return { - "status": "success", - "message": "Column notifications disabled" - } + return {"status": "success", "message": "Column notifications disabled"} except Exception as e: logger.error(f"Error unwatching 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." + "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) - } + return {"error": "Execution error", "message": str(e)} -# More Card Management Tools + +# More Card Management Tools @mcp.tool() async def uncomplete_card(project_id: str, card_id: str) -> Dict[str, Any]: """Mark a card as incomplete. - + Args: project_id: The project ID card_id: The card ID @@ -1241,30 +1187,25 @@ async def uncomplete_card(project_id: str, card_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.uncomplete_card, project_id, card_id) - return { - "status": "success", - "message": "Card marked as incomplete" - } + return {"status": "success", "message": "Card marked as incomplete"} except Exception as e: logger.error(f"Error uncompleting 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." + "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) - } + return {"error": "Execution error", "message": str(e)} + # Card Steps (Sub-tasks) Management @mcp.tool() async def get_card_steps(project_id: str, card_id: str) -> Dict[str, Any]: """Get all steps (sub-tasks) for a card. - + Args: project_id: The project ID card_id: The card ID @@ -1272,30 +1213,30 @@ async def get_card_steps(project_id: str, card_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: steps = await _run_sync(client.get_card_steps, project_id, card_id) - return { - "status": "success", - "steps": steps, - "count": len(steps) - } + return {"status": "success", "steps": steps, "count": len(steps)} except Exception as e: logger.error(f"Error getting card steps: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def create_card_step(project_id: str, card_id: str, title: str, due_on: Optional[str] = None, assignee_ids: Optional[List[str]] = None) -> Dict[str, Any]: +async def create_card_step( + project_id: str, + card_id: str, + title: str, + due_on: Optional[str] = None, + assignee_ids: Optional[List[str]] = None, +) -> Dict[str, Any]: """Create a new step (sub-task) for a card. - + Args: project_id: The project ID card_id: The card ID @@ -1306,30 +1247,30 @@ async def create_card_step(project_id: str, card_id: str, title: str, due_on: Op client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: - step = await _run_sync(client.create_card_step, project_id, card_id, title, due_on, assignee_ids) + step = await _run_sync( + client.create_card_step, project_id, card_id, title, due_on, assignee_ids + ) return { "status": "success", "step": step, - "message": f"Step '{title}' created successfully" + "message": f"Step '{title}' created successfully", } except Exception as e: logger.error(f"Error creating card step: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def get_card_step(project_id: str, step_id: str) -> Dict[str, Any]: """Get details for a specific card step. - + Args: project_id: The project ID step_id: The step ID @@ -1337,29 +1278,30 @@ async def get_card_step(project_id: str, step_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: step = await _run_sync(client.get_card_step, project_id, step_id) - return { - "status": "success", - "step": step - } + return {"status": "success", "step": step} except Exception as e: logger.error(f"Error getting card step: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def update_card_step(project_id: str, step_id: str, title: Optional[str] = None, due_on: Optional[str] = None, assignee_ids: Optional[List[str]] = None) -> Dict[str, Any]: +async def update_card_step( + project_id: str, + step_id: str, + title: Optional[str] = None, + due_on: Optional[str] = None, + assignee_ids: Optional[List[str]] = None, +) -> Dict[str, Any]: """Update a card step. - + Args: project_id: The project ID step_id: The step ID @@ -1370,30 +1312,30 @@ async def update_card_step(project_id: str, step_id: str, title: Optional[str] = client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: - step = await _run_sync(client.update_card_step, project_id, step_id, title, due_on, assignee_ids) + step = await _run_sync( + client.update_card_step, project_id, step_id, title, due_on, assignee_ids + ) return { "status": "success", "step": step, - "message": f"Step updated successfully" + "message": f"Step updated successfully", } except Exception as e: logger.error(f"Error updating card step: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def delete_card_step(project_id: str, step_id: str) -> Dict[str, Any]: """Delete a card step. - + Args: project_id: The project ID step_id: The step ID @@ -1401,29 +1343,24 @@ async def delete_card_step(project_id: str, step_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.delete_card_step, project_id, step_id) - return { - "status": "success", - "message": "Step deleted successfully" - } + return {"status": "success", "message": "Step deleted successfully"} except Exception as e: logger.error(f"Error deleting card step: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def complete_card_step(project_id: str, step_id: str) -> Dict[str, Any]: """Mark a card step as complete. - + Args: project_id: The project ID step_id: The step ID @@ -1431,29 +1368,24 @@ async def complete_card_step(project_id: str, step_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.complete_card_step, project_id, step_id) - return { - "status": "success", - "message": "Step marked as complete" - } + return {"status": "success", "message": "Step marked as complete"} except Exception as e: logger.error(f"Error completing card step: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def uncomplete_card_step(project_id: str, step_id: str) -> Dict[str, Any]: """Mark a card step as incomplete. - + Args: project_id: The project ID step_id: The step ID @@ -1461,30 +1393,27 @@ async def uncomplete_card_step(project_id: str, step_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.uncomplete_card_step, project_id, step_id) - return { - "status": "success", - "message": "Step marked as incomplete" - } + return {"status": "success", "message": "Step marked as incomplete"} except Exception as e: logger.error(f"Error uncompleting card step: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + # Attachments, Events, and Webhooks @mcp.tool() -async def create_attachment(file_path: str, name: str, content_type: Optional[str] = None) -> Dict[str, Any]: +async def create_attachment( + file_path: str, name: str, content_type: Optional[str] = None +) -> Dict[str, Any]: """Upload a file as an attachment. - + Args: file_path: Local path to file name: Filename for Basecamp @@ -1493,29 +1422,29 @@ async def create_attachment(file_path: str, name: str, content_type: Optional[st client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: - result = await _run_sync(client.create_attachment, file_path, name, content_type or "application/octet-stream") - return { - "status": "success", - "attachment": result - } + result = await _run_sync( + client.create_attachment, + file_path, + name, + content_type or "application/octet-stream", + ) + return {"status": "success", "attachment": result} except Exception as e: logger.error(f"Error creating attachment: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def get_events(project_id: str, recording_id: str) -> Dict[str, Any]: """Get events for a recording. - + Args: project_id: Project ID recording_id: Recording ID @@ -1523,60 +1452,50 @@ async def get_events(project_id: str, recording_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: events = await _run_sync(client.get_events, project_id, recording_id) - return { - "status": "success", - "events": events, - "count": len(events) - } + return {"status": "success", "events": events, "count": len(events)} except Exception as e: logger.error(f"Error getting events: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def get_webhooks(project_id: str) -> Dict[str, Any]: """List webhooks for a project. - + Args: project_id: Project ID """ client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: hooks = await _run_sync(client.get_webhooks, project_id) - return { - "status": "success", - "webhooks": hooks, - "count": len(hooks) - } + return {"status": "success", "webhooks": hooks, "count": len(hooks)} except Exception as e: logger.error(f"Error getting webhooks: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def create_webhook(project_id: str, payload_url: str, types: Optional[List[str]] = None) -> Dict[str, Any]: +async def create_webhook( + project_id: str, payload_url: str, types: Optional[List[str]] = None +) -> Dict[str, Any]: """Create a webhook. - + Args: project_id: Project ID payload_url: Payload URL @@ -1585,29 +1504,24 @@ async def create_webhook(project_id: str, payload_url: str, types: Optional[List client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: hook = await _run_sync(client.create_webhook, project_id, payload_url, types) - return { - "status": "success", - "webhook": hook - } + return {"status": "success", "webhook": hook} except Exception as e: logger.error(f"Error creating webhook: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def delete_webhook(project_id: str, webhook_id: str) -> Dict[str, Any]: """Delete a webhook. - + Args: project_id: Project ID webhook_id: Webhook ID @@ -1615,30 +1529,25 @@ async def delete_webhook(project_id: str, webhook_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.delete_webhook, project_id, webhook_id) - return { - "status": "success", - "message": "Webhook deleted" - } + return {"status": "success", "message": "Webhook deleted"} except Exception as e: logger.error(f"Error deleting webhook: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + # Document Management @mcp.tool() async def get_documents(project_id: str, vault_id: str) -> Dict[str, Any]: """List documents in a vault. - + Args: project_id: Project ID vault_id: Vault ID @@ -1646,30 +1555,24 @@ async def get_documents(project_id: str, vault_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: docs = await _run_sync(client.get_documents, project_id, vault_id) - return { - "status": "success", - "documents": docs, - "count": len(docs) - } + return {"status": "success", "documents": docs, "count": len(docs)} except Exception as e: logger.error(f"Error getting documents: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def get_document(project_id: str, document_id: str) -> Dict[str, Any]: """Get a single document. - + Args: project_id: Project ID document_id: Document ID @@ -1677,29 +1580,26 @@ async def get_document(project_id: str, document_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: doc = await _run_sync(client.get_document, project_id, document_id) - return { - "status": "success", - "document": doc - } + return {"status": "success", "document": doc} except Exception as e: logger.error(f"Error getting document: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def create_document(project_id: str, vault_id: str, title: str, content: str) -> Dict[str, Any]: +async def create_document( + project_id: str, vault_id: str, title: str, content: str +) -> Dict[str, Any]: """Create a document in a vault. - + Args: project_id: Project ID vault_id: Vault ID @@ -1709,29 +1609,31 @@ async def create_document(project_id: str, vault_id: str, title: str, content: s client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: - doc = await _run_sync(client.create_document, project_id, vault_id, title, content) - return { - "status": "success", - "document": doc - } + doc = await _run_sync( + client.create_document, project_id, vault_id, title, content + ) + return {"status": "success", "document": doc} except Exception as e: logger.error(f"Error creating document: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() -async def update_document(project_id: str, document_id: str, title: Optional[str] = None, content: Optional[str] = None) -> Dict[str, Any]: +async def update_document( + project_id: str, + document_id: str, + title: Optional[str] = None, + content: Optional[str] = None, +) -> Dict[str, Any]: """Update a document. - + Args: project_id: Project ID document_id: Document ID @@ -1741,29 +1643,26 @@ async def update_document(project_id: str, document_id: str, title: Optional[str client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: - doc = await _run_sync(client.update_document, project_id, document_id, title, content) - return { - "status": "success", - "document": doc - } + doc = await _run_sync( + client.update_document, project_id, document_id, title, content + ) + return {"status": "success", "document": doc} except Exception as e: logger.error(f"Error updating document: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def trash_document(project_id: str, document_id: str) -> Dict[str, Any]: """Move a document to trash. - + Args: project_id: Project ID document_id: Document ID @@ -1771,30 +1670,27 @@ async def trash_document(project_id: str, document_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: await _run_sync(client.trash_document, project_id, document_id) - return { - "status": "success", - "message": "Document trashed" - } + return {"status": "success", "message": "Document trashed"} except Exception as e: logger.error(f"Error trashing document: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + # Upload Management @mcp.tool() -async def get_uploads(project_id: str, vault_id: Optional[str] = None) -> Dict[str, Any]: +async def get_uploads( + project_id: str, vault_id: Optional[str] = None +) -> Dict[str, Any]: """List uploads in a project or vault. - + Args: project_id: Project ID vault_id: Optional vault ID to limit to specific vault @@ -1802,30 +1698,24 @@ async def get_uploads(project_id: str, vault_id: Optional[str] = None) -> Dict[s client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: uploads = await _run_sync(client.get_uploads, project_id, vault_id) - return { - "status": "success", - "uploads": uploads, - "count": len(uploads) - } + return {"status": "success", "uploads": uploads, "count": len(uploads)} except Exception as e: logger.error(f"Error getting uploads: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + @mcp.tool() async def get_upload(project_id: str, upload_id: str) -> Dict[str, Any]: """Get details for a specific upload. - + Args: project_id: Project ID upload_id: Upload ID @@ -1833,28 +1723,23 @@ async def get_upload(project_id: str, upload_id: str) -> Dict[str, Any]: client = _get_basecamp_client() if not client: return _get_auth_error_response() - + try: upload = await _run_sync(client.get_upload, project_id, upload_id) - return { - "status": "success", - "upload": upload - } + return {"status": "success", "upload": upload} except Exception as e: logger.error(f"Error getting upload: {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." + "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) - } + return {"error": "Execution error", "message": str(e)} + # 🎉 COMPLETE FastMCP server with ALL 46 Basecamp tools migrated! 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 + mcp.run(transport="stdio") diff --git a/config_paths.py b/config_paths.py new file mode 100644 index 0000000..7fe9bfa --- /dev/null +++ b/config_paths.py @@ -0,0 +1,92 @@ +""" +Centralized path resolution for Basecamp MCP Server. + +All paths can be overridden via environment variables. +""" + +import os +from pathlib import Path + + +def get_log_directory() -> Path: + """ + Get the directory for log files. + + Priority order: + 1. BASECAMP_LOG_DIR env variable + 2. XDG_STATE_HOME (typically ~/.local/state) + 3. ~/.local/state/basecamp-mcp (fallback) + """ + if log_dir := os.getenv("BASECAMP_LOG_DIR"): + return Path(log_dir) + + if xdg_state := os.getenv("XDG_STATE_HOME"): + return Path(xdg_state) / "basecamp-mcp" + + return Path.home() / ".local/state/basecamp-mcp" + + +def get_config_directory() -> Path: + """ + Get the directory for configuration files (.env). + + Priority order: + 1. BASECAMP_CONFIG_DIR env variable + 2. XDG_CONFIG_HOME (typically ~/.config) + 3. ~/.config/basecamp-mcp (fallback) + + NOTE: This is only used if BASECAMP_ENV_FILE is not set and + a .env file exists in the config directory. The server works + entirely with environment variables and doesn't require .env files. + """ + if config_dir := os.getenv("BASECAMP_CONFIG_DIR"): + return Path(config_dir) + + if xdg_config := os.getenv("XDG_CONFIG_HOME"): + return Path(xdg_config) / "basecamp-mcp" + + return Path.home() / ".config/basecamp-mcp" + + +def get_env_file_path() -> Path: + """ + Get the path for .env file (optional). + + Priority order: + 1. BASECAMP_ENV_FILE env variable (explicitly specified) + 2. Config directory + / .env + + The server does NOT require this file - it's optional. + If not found, environment will be used as-is. + """ + if env_file := os.getenv("BASECAMP_ENV_FILE"): + return Path(env_file) + + return get_config_directory() / ".env" + + +def get_token_file_path() -> Path: + """ + Get the path for OAuth token storage. + + Priority order: + 1. BASECAMP_TOKEN_FILE env variable + 2. XDG_DATA_HOME (typically ~/.local/share) + 3. ~/.local/share/basecamp-mcp/oauth_tokens.json (fallback) + """ + if token_file := os.getenv("BASECAMP_TOKEN_FILE"): + return Path(token_file) + + if xdg_data := os.getenv("XDG_DATA_HOME"): + return Path(xdg_data) / "basecamp-mcp" / "oauth_tokens.json" + + return Path.home() / ".local/share/basecamp-mcp" / "oauth_tokens.json" + + +def ensure_directories_exist() -> None: + """ + Create all necessary directories if they don't exist. + """ + get_log_directory().mkdir(parents=True, exist_ok=True) + get_config_directory().mkdir(parents=True, exist_ok=True) + get_token_file_path().parent.mkdir(parents=True, exist_ok=True) diff --git a/mcp_server_cli.py b/mcp_server_cli.py index 27ae13a..556c155 100755 --- a/mcp_server_cli.py +++ b/mcp_server_cli.py @@ -10,31 +10,34 @@ import json import sys import logging from typing import Any, Dict, List, Optional +from config_paths import get_log_directory, get_env_file_path, ensure_directories_exist from basecamp_client import BasecampClient from search_utils import BasecampSearch import token_storage import auth_manager -import os from dotenv import load_dotenv -# Determine project root (directory containing this script) -PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) -# Explicitly load .env from the project root -DOTENV_PATH = os.path.join(PROJECT_ROOT, '.env') -load_dotenv(DOTENV_PATH) +ensure_directories_exist() -# Log file in the project directory -LOG_FILE_PATH = os.path.join(PROJECT_ROOT, 'mcp_cli_server.log') -# Set up logging to file AND stderr +DOTENV_PATH = get_env_file_path() +if DOTENV_PATH.exists(): + load_dotenv(DOTENV_PATH) + logger = logging.getLogger("mcp_cli_server") + logger.debug(f"Loaded .env from {DOTENV_PATH}") +else: + logger = logging.getLogger("mcp_cli_server") + +LOG_FILE_PATH = get_log_directory() / "mcp_cli_server.log" logging.basicConfig( level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[ logging.FileHandler(LOG_FILE_PATH), - logging.StreamHandler(sys.stderr) # Added StreamHandler for stderr - ] + logging.StreamHandler(sys.stderr), + ], ) -logger = logging.getLogger('mcp_cli_server') +logger = logging.getLogger("mcp_cli_server") + class MCPServer: """MCP server implementing the Model Context Protocol for Cursor.""" @@ -49,11 +52,7 @@ class MCPServer: { "name": "get_projects", "description": "Get all Basecamp projects", - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } + "inputSchema": {"type": "object", "properties": {}, "required": []}, }, { "name": "get_project", @@ -61,10 +60,13 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"} + "project_id": { + "type": "string", + "description": "The project ID", + } }, - "required": ["project_id"] - } + "required": ["project_id"], + }, }, { "name": "get_todolists", @@ -72,10 +74,13 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"} + "project_id": { + "type": "string", + "description": "The project ID", + } }, - "required": ["project_id"] - } + "required": ["project_id"], + }, }, { "name": "get_todos", @@ -84,10 +89,13 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "todolist_id": {"type": "string", "description": "The todo list ID"}, + "todolist_id": { + "type": "string", + "description": "The todo list ID", + }, }, - "required": ["project_id", "todolist_id"] - } + "required": ["project_id", "todolist_id"], + }, }, { "name": "create_todo", @@ -96,17 +104,43 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "todolist_id": {"type": "string", "description": "The todo list ID"}, - "content": {"type": "string", "description": "The todo item's text (required)"}, - "description": {"type": "string", "description": "HTML description of the todo"}, - "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "List of person IDs to assign"}, - "completion_subscriber_ids": {"type": "array", "items": {"type": "string"}, "description": "List of person IDs to notify on completion"}, - "notify": {"type": "boolean", "description": "Whether to notify assignees"}, - "due_on": {"type": "string", "description": "Due date in YYYY-MM-DD format"}, - "starts_on": {"type": "string", "description": "Start date in YYYY-MM-DD format"} + "todolist_id": { + "type": "string", + "description": "The todo list ID", + }, + "content": { + "type": "string", + "description": "The todo item's text (required)", + }, + "description": { + "type": "string", + "description": "HTML description of the todo", + }, + "assignee_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of person IDs to assign", + }, + "completion_subscriber_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of person IDs to notify on completion", + }, + "notify": { + "type": "boolean", + "description": "Whether to notify assignees", + }, + "due_on": { + "type": "string", + "description": "Due date in YYYY-MM-DD format", + }, + "starts_on": { + "type": "string", + "description": "Start date in YYYY-MM-DD format", + }, }, - "required": ["project_id", "todolist_id", "content"] - } + "required": ["project_id", "todolist_id", "content"], + }, }, { "name": "update_todo", @@ -116,16 +150,39 @@ class MCPServer: "properties": { "project_id": {"type": "string", "description": "Project ID"}, "todo_id": {"type": "string", "description": "The todo ID"}, - "content": {"type": "string", "description": "The todo item's text"}, - "description": {"type": "string", "description": "HTML description of the todo"}, - "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "List of person IDs to assign"}, - "completion_subscriber_ids": {"type": "array", "items": {"type": "string"}, "description": "List of person IDs to notify on completion"}, - "notify": {"type": "boolean", "description": "Whether to notify assignees"}, - "due_on": {"type": "string", "description": "Due date in YYYY-MM-DD format"}, - "starts_on": {"type": "string", "description": "Start date in YYYY-MM-DD format"} + "content": { + "type": "string", + "description": "The todo item's text", + }, + "description": { + "type": "string", + "description": "HTML description of the todo", + }, + "assignee_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of person IDs to assign", + }, + "completion_subscriber_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of person IDs to notify on completion", + }, + "notify": { + "type": "boolean", + "description": "Whether to notify assignees", + }, + "due_on": { + "type": "string", + "description": "Due date in YYYY-MM-DD format", + }, + "starts_on": { + "type": "string", + "description": "Start date in YYYY-MM-DD format", + }, }, - "required": ["project_id", "todo_id"] - } + "required": ["project_id", "todo_id"], + }, }, { "name": "delete_todo", @@ -134,10 +191,10 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "todo_id": {"type": "string", "description": "The todo ID"} + "todo_id": {"type": "string", "description": "The todo ID"}, }, - "required": ["project_id", "todo_id"] - } + "required": ["project_id", "todo_id"], + }, }, { "name": "complete_todo", @@ -146,10 +203,10 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "todo_id": {"type": "string", "description": "The todo ID"} + "todo_id": {"type": "string", "description": "The todo ID"}, }, - "required": ["project_id", "todo_id"] - } + "required": ["project_id", "todo_id"], + }, }, { "name": "uncomplete_todo", @@ -158,10 +215,10 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "todo_id": {"type": "string", "description": "The todo ID"} + "todo_id": {"type": "string", "description": "The todo ID"}, }, - "required": ["project_id", "todo_id"] - } + "required": ["project_id", "todo_id"], + }, }, { "name": "search_basecamp", @@ -170,10 +227,13 @@ class MCPServer: "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, - "project_id": {"type": "string", "description": "Optional project ID to limit search scope"} + "project_id": { + "type": "string", + "description": "Optional project ID to limit search scope", + }, }, - "required": ["query"] - } + "required": ["query"], + }, }, { "name": "global_search", @@ -183,8 +243,8 @@ class MCPServer: "properties": { "query": {"type": "string", "description": "Search query"} }, - "required": ["query"] - } + "required": ["query"], + }, }, { "name": "get_comments", @@ -192,12 +252,22 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "recording_id": {"type": "string", "description": "The item ID"}, - "project_id": {"type": "string", "description": "The project ID"}, - "page": {"type": "integer", "description": "Page number for pagination (default: 1). Basecamp uses geared pagination: page 1 has 15 results, page 2 has 30, page 3 has 50, page 4+ has 100.", "default": 1} + "recording_id": { + "type": "string", + "description": "The item ID", + }, + "project_id": { + "type": "string", + "description": "The project ID", + }, + "page": { + "type": "integer", + "description": "Page number for pagination (default: 1). Basecamp uses geared pagination: page 1 has 15 results, page 2 has 30, page 3 has 50, page 4+ has 100.", + "default": 1, + }, }, - "required": ["recording_id", "project_id"] - } + "required": ["recording_id", "project_id"], + }, }, { "name": "create_comment", @@ -205,12 +275,21 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "recording_id": {"type": "string", "description": "The item ID"}, - "project_id": {"type": "string", "description": "The project ID"}, - "content": {"type": "string", "description": "The comment content in HTML format"} + "recording_id": { + "type": "string", + "description": "The item ID", + }, + "project_id": { + "type": "string", + "description": "The project ID", + }, + "content": { + "type": "string", + "description": "The comment content in HTML format", + }, }, - "required": ["recording_id", "project_id", "content"] - } + "required": ["recording_id", "project_id", "content"], + }, }, { "name": "get_campfire_lines", @@ -218,11 +297,17 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "campfire_id": {"type": "string", "description": "The campfire/chat room ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "campfire_id": { + "type": "string", + "description": "The campfire/chat room ID", + }, }, - "required": ["project_id", "campfire_id"] - } + "required": ["project_id", "campfire_id"], + }, }, { "name": "get_daily_check_ins", @@ -230,11 +315,17 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "page": {"type": "integer", "description": "Page number paginated response"} - } + "project_id": { + "type": "string", + "description": "The project ID", + }, + "page": { + "type": "integer", + "description": "Page number paginated response", + }, + }, }, - "required": ["project_id"] + "required": ["project_id"], }, { "name": "get_question_answers", @@ -242,12 +333,21 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "question_id": {"type": "string", "description": "The question ID"}, - "page": {"type": "integer", "description": "Page number paginated response"} - } + "project_id": { + "type": "string", + "description": "The project ID", + }, + "question_id": { + "type": "string", + "description": "The question ID", + }, + "page": { + "type": "integer", + "description": "Page number paginated response", + }, + }, }, - "required": ["project_id", "question_id"] + "required": ["project_id", "question_id"], }, # Card Table tools { @@ -256,10 +356,13 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"} + "project_id": { + "type": "string", + "description": "The project ID", + } }, - "required": ["project_id"] - } + "required": ["project_id"], + }, }, { "name": "get_card_table", @@ -267,10 +370,13 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"} + "project_id": { + "type": "string", + "description": "The project ID", + } }, - "required": ["project_id"] - } + "required": ["project_id"], + }, }, { "name": "get_columns", @@ -278,11 +384,17 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "card_table_id": {"type": "string", "description": "The card table ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "card_table_id": { + "type": "string", + "description": "The card table ID", + }, }, - "required": ["project_id", "card_table_id"] - } + "required": ["project_id", "card_table_id"], + }, }, { "name": "get_column", @@ -290,11 +402,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "column_id": {"type": "string", "description": "The column ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "column_id": {"type": "string", "description": "The column ID"}, }, - "required": ["project_id", "column_id"] - } + "required": ["project_id", "column_id"], + }, }, { "name": "create_column", @@ -302,12 +417,18 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "card_table_id": {"type": "string", "description": "The card table ID"}, - "title": {"type": "string", "description": "The column title"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "card_table_id": { + "type": "string", + "description": "The card table ID", + }, + "title": {"type": "string", "description": "The column title"}, }, - "required": ["project_id", "card_table_id", "title"] - } + "required": ["project_id", "card_table_id", "title"], + }, }, { "name": "update_column", @@ -315,12 +436,18 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, + "project_id": { + "type": "string", + "description": "The project ID", + }, "column_id": {"type": "string", "description": "The column ID"}, - "title": {"type": "string", "description": "The new column title"} + "title": { + "type": "string", + "description": "The new column title", + }, }, - "required": ["project_id", "column_id", "title"] - } + "required": ["project_id", "column_id", "title"], + }, }, { "name": "move_column", @@ -328,13 +455,27 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "card_table_id": {"type": "string", "description": "The card table ID"}, + "project_id": { + "type": "string", + "description": "The project ID", + }, + "card_table_id": { + "type": "string", + "description": "The card table ID", + }, "column_id": {"type": "string", "description": "The column ID"}, - "position": {"type": "integer", "description": "The new 1-based position"} + "position": { + "type": "integer", + "description": "The new 1-based position", + }, }, - "required": ["project_id", "card_table_id", "column_id", "position"] - } + "required": [ + "project_id", + "card_table_id", + "column_id", + "position", + ], + }, }, { "name": "update_column_color", @@ -342,12 +483,18 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, + "project_id": { + "type": "string", + "description": "The project ID", + }, "column_id": {"type": "string", "description": "The column ID"}, - "color": {"type": "string", "description": "The hex color code (e.g., #FF0000)"} + "color": { + "type": "string", + "description": "The hex color code (e.g., #FF0000)", + }, }, - "required": ["project_id", "column_id", "color"] - } + "required": ["project_id", "column_id", "color"], + }, }, { "name": "put_column_on_hold", @@ -355,11 +502,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "column_id": {"type": "string", "description": "The column ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "column_id": {"type": "string", "description": "The column ID"}, }, - "required": ["project_id", "column_id"] - } + "required": ["project_id", "column_id"], + }, }, { "name": "remove_column_hold", @@ -367,11 +517,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "column_id": {"type": "string", "description": "The column ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "column_id": {"type": "string", "description": "The column ID"}, }, - "required": ["project_id", "column_id"] - } + "required": ["project_id", "column_id"], + }, }, { "name": "watch_column", @@ -379,11 +532,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "column_id": {"type": "string", "description": "The column ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "column_id": {"type": "string", "description": "The column ID"}, }, - "required": ["project_id", "column_id"] - } + "required": ["project_id", "column_id"], + }, }, { "name": "unwatch_column", @@ -391,11 +547,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "column_id": {"type": "string", "description": "The column ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "column_id": {"type": "string", "description": "The column ID"}, }, - "required": ["project_id", "column_id"] - } + "required": ["project_id", "column_id"], + }, }, { "name": "get_cards", @@ -403,11 +562,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "column_id": {"type": "string", "description": "The column ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "column_id": {"type": "string", "description": "The column ID"}, }, - "required": ["project_id", "column_id"] - } + "required": ["project_id", "column_id"], + }, }, { "name": "get_card", @@ -415,11 +577,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "card_id": {"type": "string", "description": "The card ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "card_id": {"type": "string", "description": "The card ID"}, }, - "required": ["project_id", "card_id"] - } + "required": ["project_id", "card_id"], + }, }, { "name": "create_card", @@ -427,15 +592,27 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, + "project_id": { + "type": "string", + "description": "The project ID", + }, "column_id": {"type": "string", "description": "The column ID"}, "title": {"type": "string", "description": "The card title"}, - "content": {"type": "string", "description": "Optional card content/description"}, - "due_on": {"type": "string", "description": "Optional due date (ISO 8601 format)"}, - "notify": {"type": "boolean", "description": "Whether to notify assignees (default: false)"} + "content": { + "type": "string", + "description": "Optional card content/description", + }, + "due_on": { + "type": "string", + "description": "Optional due date (ISO 8601 format)", + }, + "notify": { + "type": "boolean", + "description": "Whether to notify assignees (default: false)", + }, }, - "required": ["project_id", "column_id", "title"] - } + "required": ["project_id", "column_id", "title"], + }, }, { "name": "update_card", @@ -443,15 +620,31 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, + "project_id": { + "type": "string", + "description": "The project ID", + }, "card_id": {"type": "string", "description": "The card ID"}, - "title": {"type": "string", "description": "The new card title"}, - "content": {"type": "string", "description": "The new card content/description"}, - "due_on": {"type": "string", "description": "Due date (ISO 8601 format)"}, - "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "Array of person IDs to assign to the card"} + "title": { + "type": "string", + "description": "The new card title", + }, + "content": { + "type": "string", + "description": "The new card content/description", + }, + "due_on": { + "type": "string", + "description": "Due date (ISO 8601 format)", + }, + "assignee_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of person IDs to assign to the card", + }, }, - "required": ["project_id", "card_id"] - } + "required": ["project_id", "card_id"], + }, }, { "name": "move_card", @@ -459,12 +652,18 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, + "project_id": { + "type": "string", + "description": "The project ID", + }, "card_id": {"type": "string", "description": "The card ID"}, - "column_id": {"type": "string", "description": "The destination column ID"} + "column_id": { + "type": "string", + "description": "The destination column ID", + }, }, - "required": ["project_id", "card_id", "column_id"] - } + "required": ["project_id", "card_id", "column_id"], + }, }, { "name": "complete_card", @@ -472,11 +671,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "card_id": {"type": "string", "description": "The card ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "card_id": {"type": "string", "description": "The card ID"}, }, - "required": ["project_id", "card_id"] - } + "required": ["project_id", "card_id"], + }, }, { "name": "uncomplete_card", @@ -484,11 +686,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "card_id": {"type": "string", "description": "The card ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "card_id": {"type": "string", "description": "The card ID"}, }, - "required": ["project_id", "card_id"] - } + "required": ["project_id", "card_id"], + }, }, { "name": "get_card_steps", @@ -496,11 +701,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "card_id": {"type": "string", "description": "The card ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "card_id": {"type": "string", "description": "The card ID"}, }, - "required": ["project_id", "card_id"] - } + "required": ["project_id", "card_id"], + }, }, { "name": "create_card_step", @@ -508,14 +716,24 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, + "project_id": { + "type": "string", + "description": "The project ID", + }, "card_id": {"type": "string", "description": "The card ID"}, "title": {"type": "string", "description": "The step title"}, - "due_on": {"type": "string", "description": "Optional due date (ISO 8601 format)"}, - "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "Array of person IDs to assign to the step"} + "due_on": { + "type": "string", + "description": "Optional due date (ISO 8601 format)", + }, + "assignee_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of person IDs to assign to the step", + }, }, - "required": ["project_id", "card_id", "title"] - } + "required": ["project_id", "card_id", "title"], + }, }, { "name": "get_card_step", @@ -523,11 +741,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "step_id": {"type": "string", "description": "The step ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "step_id": {"type": "string", "description": "The step ID"}, }, - "required": ["project_id", "step_id"] - } + "required": ["project_id", "step_id"], + }, }, { "name": "update_card_step", @@ -535,14 +756,24 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, + "project_id": { + "type": "string", + "description": "The project ID", + }, "step_id": {"type": "string", "description": "The step ID"}, "title": {"type": "string", "description": "The step title"}, - "due_on": {"type": "string", "description": "Due date (ISO 8601 format)"}, - "assignee_ids": {"type": "array", "items": {"type": "string"}, "description": "Array of person IDs to assign to the step"} + "due_on": { + "type": "string", + "description": "Due date (ISO 8601 format)", + }, + "assignee_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of person IDs to assign to the step", + }, }, - "required": ["project_id", "step_id"] - } + "required": ["project_id", "step_id"], + }, }, { "name": "delete_card_step", @@ -550,11 +781,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "step_id": {"type": "string", "description": "The step ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "step_id": {"type": "string", "description": "The step ID"}, }, - "required": ["project_id", "step_id"] - } + "required": ["project_id", "step_id"], + }, }, { "name": "complete_card_step", @@ -562,11 +796,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "step_id": {"type": "string", "description": "The step ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "step_id": {"type": "string", "description": "The step ID"}, }, - "required": ["project_id", "step_id"] - } + "required": ["project_id", "step_id"], + }, }, { "name": "uncomplete_card_step", @@ -574,11 +811,14 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "project_id": {"type": "string", "description": "The project ID"}, - "step_id": {"type": "string", "description": "The step ID"} + "project_id": { + "type": "string", + "description": "The project ID", + }, + "step_id": {"type": "string", "description": "The step ID"}, }, - "required": ["project_id", "step_id"] - } + "required": ["project_id", "step_id"], + }, }, { "name": "create_attachment", @@ -586,12 +826,18 @@ class MCPServer: "inputSchema": { "type": "object", "properties": { - "file_path": {"type": "string", "description": "Local path to file"}, - "name": {"type": "string", "description": "Filename for Basecamp"}, - "content_type": {"type": "string", "description": "MIME type"} + "file_path": { + "type": "string", + "description": "Local path to file", + }, + "name": { + "type": "string", + "description": "Filename for Basecamp", + }, + "content_type": {"type": "string", "description": "MIME type"}, }, - "required": ["file_path", "name"] - } + "required": ["file_path", "name"], + }, }, { "name": "get_events", @@ -600,10 +846,13 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "recording_id": {"type": "string", "description": "Recording ID"} + "recording_id": { + "type": "string", + "description": "Recording ID", + }, }, - "required": ["project_id", "recording_id"] - } + "required": ["project_id", "recording_id"], + }, }, { "name": "get_webhooks", @@ -613,8 +862,8 @@ class MCPServer: "properties": { "project_id": {"type": "string", "description": "Project ID"} }, - "required": ["project_id"] - } + "required": ["project_id"], + }, }, { "name": "create_webhook", @@ -624,10 +873,14 @@ class MCPServer: "properties": { "project_id": {"type": "string", "description": "Project ID"}, "payload_url": {"type": "string", "description": "Payload URL"}, - "types": {"type": "array", "items": {"type": "string"}, "description": "Event types"} + "types": { + "type": "array", + "items": {"type": "string"}, + "description": "Event types", + }, }, - "required": ["project_id", "payload_url"] - } + "required": ["project_id", "payload_url"], + }, }, { "name": "delete_webhook", @@ -636,10 +889,10 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "webhook_id": {"type": "string", "description": "Webhook ID"} + "webhook_id": {"type": "string", "description": "Webhook ID"}, }, - "required": ["project_id", "webhook_id"] - } + "required": ["project_id", "webhook_id"], + }, }, { "name": "get_documents", @@ -648,10 +901,10 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "vault_id": {"type": "string", "description": "Vault ID"} + "vault_id": {"type": "string", "description": "Vault ID"}, }, - "required": ["project_id", "vault_id"] - } + "required": ["project_id", "vault_id"], + }, }, { "name": "get_document", @@ -660,10 +913,10 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "document_id": {"type": "string", "description": "Document ID"} + "document_id": {"type": "string", "description": "Document ID"}, }, - "required": ["project_id", "document_id"] - } + "required": ["project_id", "document_id"], + }, }, { "name": "create_document", @@ -674,10 +927,13 @@ class MCPServer: "project_id": {"type": "string", "description": "Project ID"}, "vault_id": {"type": "string", "description": "Vault ID"}, "title": {"type": "string", "description": "Document title"}, - "content": {"type": "string", "description": "Document HTML content"} + "content": { + "type": "string", + "description": "Document HTML content", + }, }, - "required": ["project_id", "vault_id", "title", "content"] - } + "required": ["project_id", "vault_id", "title", "content"], + }, }, { "name": "update_document", @@ -688,10 +944,13 @@ class MCPServer: "project_id": {"type": "string", "description": "Project ID"}, "document_id": {"type": "string", "description": "Document ID"}, "title": {"type": "string", "description": "New title"}, - "content": {"type": "string", "description": "New HTML content"} + "content": { + "type": "string", + "description": "New HTML content", + }, }, - "required": ["project_id", "document_id"] - } + "required": ["project_id", "document_id"], + }, }, { "name": "trash_document", @@ -700,11 +959,11 @@ class MCPServer: "type": "object", "properties": { "project_id": {"type": "string", "description": "Project ID"}, - "document_id": {"type": "string", "description": "Document ID"} + "document_id": {"type": "string", "description": "Document ID"}, }, - "required": ["project_id", "document_id"] - } - } + "required": ["project_id", "document_id"], + }, + }, ] def _get_basecamp_client(self) -> Optional[BasecampClient]: @@ -713,7 +972,7 @@ class MCPServer: 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'): + if not token_data or not token_data.get("access_token"): logger.error("No OAuth token available") return None @@ -726,22 +985,30 @@ class MCPServer: token_data = token_storage.get_token() # 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') + account_id = token_data.get("account_id") or os.getenv( + "BASECAMP_ACCOUNT_ID" + ) # Set a default user agent if none is provided - user_agent = os.getenv('USER_AGENT') or "Basecamp MCP Server (cursor@example.com)" + 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')}") + 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}") + logger.debug( + f"Creating Basecamp client with account_id: {account_id}, user_agent: {user_agent}" + ) return BasecampClient( - access_token=token_data['access_token'], + access_token=token_data["access_token"], account_id=account_id, user_agent=user_agent, - auth_mode='oauth' + auth_mode="oauth", ) except Exception as e: logger.error(f"Error creating Basecamp client: {e}") @@ -751,7 +1018,7 @@ class MCPServer: """Handle an MCP request.""" method = request.get("method") # Normalize method name for cursor compatibility - method_lower = method.lower() if isinstance(method, str) else '' + method_lower = method.lower() if isinstance(method, str) else "" params = request.get("params", {}) request_id = request.get("id") @@ -764,14 +1031,12 @@ class MCPServer: "id": request_id, "result": { "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {} - }, + "capabilities": {"tools": {}}, "serverInfo": { "name": "basecamp-mcp-server", - "version": "1.0.0" - } - } + "version": "1.0.0", + }, + }, } elif method_lower == "initialized": @@ -783,9 +1048,7 @@ class MCPServer: return { "jsonrpc": "2.0", "id": request_id, - "result": { - "tools": self.tools - } + "result": {"tools": self.tools}, } elif method_lower in ("tools/call", "toolscall"): @@ -799,47 +1062,37 @@ class MCPServer: "id": request_id, "result": { "content": [ - { - "type": "text", - "text": json.dumps(result, indent=2) - } + {"type": "text", "text": json.dumps(result, indent=2)} ] - } + }, } elif method_lower in ("listofferings", "list_offerings", "loffering"): # Respond to Cursor's ListOfferings UI request offerings = [] for tool in self.tools: - offerings.append({ - "name": tool.get("name"), - "displayName": tool.get("name"), - "description": tool.get("description") - }) + offerings.append( + { + "name": tool.get("name"), + "displayName": tool.get("name"), + "description": tool.get("description"), + } + ) return { "jsonrpc": "2.0", "id": request_id, - "result": { - "offerings": offerings - } + "result": {"offerings": offerings}, } elif method_lower == "ping": # Handle ping requests - return { - "jsonrpc": "2.0", - "id": request_id, - "result": {} - } + return {"jsonrpc": "2.0", "id": request_id, "result": {}} else: return { "jsonrpc": "2.0", "id": request_id, - "error": { - "code": -32601, - "message": f"Method not found: {method}" - } + "error": {"code": -32601, "message": f"Method not found: {method}"}, } except Exception as e: @@ -847,13 +1100,12 @@ class MCPServer: return { "jsonrpc": "2.0", "id": request_id, - "error": { - "code": -32603, - "message": f"Internal error: {str(e)}" - } + "error": {"code": -32603, "message": f"Internal error: {str(e)}"}, } - def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + def _execute_tool( + self, tool_name: str, arguments: Dict[str, Any] + ) -> Dict[str, Any]: """Execute a tool and return the result.""" client = self._get_basecamp_client() if not client: @@ -861,12 +1113,12 @@ class MCPServer: 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." + "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." + "message": "Please authenticate with Basecamp first. Visit http://localhost:8000 to log in.", } try: @@ -875,16 +1127,13 @@ class MCPServer: return { "status": "success", "projects": projects, - "count": len(projects) + "count": len(projects), } elif tool_name == "get_project": project_id = arguments.get("project_id") project = client.get_project(project_id) - return { - "status": "success", - "project": project - } + return {"status": "success", "project": project} elif tool_name == "get_todolists": project_id = arguments.get("project_id") @@ -892,18 +1141,14 @@ class MCPServer: return { "status": "success", "todolists": todolists, - "count": len(todolists) + "count": len(todolists), } elif tool_name == "get_todos": todolist_id = arguments.get("todolist_id") project_id = arguments.get("project_id") todos = client.get_todos(project_id, todolist_id) - return { - "status": "success", - "todos": todos, - "count": len(todos) - } + return {"status": "success", "todos": todos, "count": len(todos)} elif tool_name == "create_todo": project_id = arguments.get("project_id") @@ -919,20 +1164,22 @@ class MCPServer: notify = bool(notify_arg) due_on = arguments.get("due_on") starts_on = arguments.get("starts_on") - + todo = client.create_todo( - project_id, todolist_id, content, + project_id, + todolist_id, + content, description=description, assignee_ids=assignee_ids, completion_subscriber_ids=completion_subscriber_ids, notify=notify, due_on=due_on, - starts_on=starts_on + starts_on=starts_on, ) return { "status": "success", "todo": todo, - "message": f"Todo '{content}' created successfully" + "message": f"Todo '{content}' created successfully", } elif tool_name == "update_todo": @@ -945,31 +1192,29 @@ class MCPServer: due_on = arguments.get("due_on") starts_on = arguments.get("starts_on") notify = arguments.get("notify") - + todo = client.update_todo( - project_id, todo_id, + project_id, + todo_id, content=content, description=description, assignee_ids=assignee_ids, completion_subscriber_ids=completion_subscriber_ids, notify=notify, due_on=due_on, - starts_on=starts_on + starts_on=starts_on, ) return { "status": "success", "todo": todo, - "message": "Todo updated successfully" + "message": "Todo updated successfully", } elif tool_name == "delete_todo": project_id = arguments.get("project_id") todo_id = arguments.get("todo_id") client.delete_todo(project_id, todo_id) - return { - "status": "success", - "message": "Todo deleted successfully" - } + return {"status": "success", "message": "Todo deleted successfully"} elif tool_name == "complete_todo": project_id = arguments.get("project_id") @@ -978,17 +1223,14 @@ class MCPServer: return { "status": "success", "completion": completion, - "message": "Todo marked as complete" + "message": "Todo marked as complete", } elif tool_name == "uncomplete_todo": project_id = arguments.get("project_id") todo_id = arguments.get("todo_id") client.uncomplete_todo(project_id, todo_id) - return { - "status": "success", - "message": "Todo marked as incomplete" - } + return {"status": "success", "message": "Todo marked as incomplete"} elif tool_name == "search_basecamp": query = arguments.get("query") @@ -1007,21 +1249,13 @@ class MCPServer: results["todos"] = search.search_todos(query) results["messages"] = search.search_messages(query) - return { - "status": "success", - "query": query, - "results": results - } + return {"status": "success", "query": query, "results": results} elif tool_name == "global_search": query = arguments.get("query") search = BasecampSearch(client=client) results = search.global_search(query) - return { - "status": "success", - "query": query, - "results": results - } + return {"status": "success", "query": query, "results": results} elif tool_name == "get_comments": recording_id = arguments.get("recording_id") @@ -1034,7 +1268,7 @@ class MCPServer: "count": len(result["comments"]), "page": page, "total_count": result["total_count"], - "next_page": result["next_page"] + "next_page": result["next_page"], } elif tool_name == "create_comment": @@ -1045,7 +1279,7 @@ class MCPServer: return { "status": "success", "comment": comment, - "message": "Comment created successfully" + "message": "Comment created successfully", } elif tool_name == "get_campfire_lines": @@ -1055,7 +1289,7 @@ class MCPServer: return { "status": "success", "campfire_lines": lines, - "count": len(lines) + "count": len(lines), } elif tool_name == "get_daily_check_ins": project_id = arguments.get("project_id") @@ -1066,7 +1300,7 @@ class MCPServer: return { "status": "success", "campfire_lines": answers, - "count": len(answers) + "count": len(answers), } elif tool_name == "get_question_answers": project_id = arguments.get("project_id") @@ -1074,13 +1308,15 @@ class MCPServer: page = arguments.get("page", 1) if page is not None and not isinstance(page, int): page = 1 - answers = client.get_question_answers(project_id, question_id, page=page) + answers = client.get_question_answers( + project_id, question_id, page=page + ) return { "status": "success", "campfire_lines": answers, - "count": len(answers) + "count": len(answers), } - + # Card Table tools implementation elif tool_name == "get_card_tables": project_id = arguments.get("project_id") @@ -1088,44 +1324,36 @@ class MCPServer: return { "status": "success", "card_tables": card_tables, - "count": len(card_tables) + "count": len(card_tables), } elif tool_name == "get_card_table": project_id = arguments.get("project_id") try: card_table = client.get_card_table(project_id) - card_table_details = client.get_card_table_details(project_id, card_table['id']) - return { - "status": "success", - "card_table": card_table_details - } + card_table_details = client.get_card_table_details( + project_id, card_table["id"] + ) + return {"status": "success", "card_table": card_table_details} except Exception as e: error_msg = str(e) return { "status": "error", "message": f"Error getting card table: {error_msg}", - "debug": error_msg + "debug": error_msg, } elif tool_name == "get_columns": project_id = arguments.get("project_id") card_table_id = arguments.get("card_table_id") columns = client.get_columns(project_id, card_table_id) - return { - "status": "success", - "columns": columns, - "count": len(columns) - } + return {"status": "success", "columns": columns, "count": len(columns)} elif tool_name == "get_column": project_id = arguments.get("project_id") column_id = arguments.get("column_id") column = client.get_column(project_id, column_id) - return { - "status": "success", - "column": column - } + return {"status": "success", "column": column} elif tool_name == "create_column": project_id = arguments.get("project_id") @@ -1135,7 +1363,7 @@ class MCPServer: return { "status": "success", "column": column, - "message": f"Column '{title}' created successfully" + "message": f"Column '{title}' created successfully", } elif tool_name == "update_column": @@ -1146,7 +1374,7 @@ class MCPServer: return { "status": "success", "column": column, - "message": "Column updated successfully" + "message": "Column updated successfully", } elif tool_name == "move_column": @@ -1157,7 +1385,7 @@ class MCPServer: client.move_column(project_id, column_id, position, card_table_id) return { "status": "success", - "message": f"Column moved to position {position}" + "message": f"Column moved to position {position}", } elif tool_name == "update_column_color": @@ -1168,63 +1396,44 @@ class MCPServer: return { "status": "success", "column": column, - "message": f"Column color updated to {color}" + "message": f"Column color updated to {color}", } elif tool_name == "put_column_on_hold": project_id = arguments.get("project_id") column_id = arguments.get("column_id") client.put_column_on_hold(project_id, column_id) - return { - "status": "success", - "message": "Column put on hold" - } + return {"status": "success", "message": "Column put on hold"} elif tool_name == "remove_column_hold": project_id = arguments.get("project_id") column_id = arguments.get("column_id") client.remove_column_hold(project_id, column_id) - return { - "status": "success", - "message": "Column hold removed" - } + return {"status": "success", "message": "Column hold removed"} elif tool_name == "watch_column": project_id = arguments.get("project_id") column_id = arguments.get("column_id") client.watch_column(project_id, column_id) - return { - "status": "success", - "message": "Column notifications enabled" - } + return {"status": "success", "message": "Column notifications enabled"} elif tool_name == "unwatch_column": project_id = arguments.get("project_id") column_id = arguments.get("column_id") client.unwatch_column(project_id, column_id) - return { - "status": "success", - "message": "Column notifications disabled" - } + return {"status": "success", "message": "Column notifications disabled"} elif tool_name == "get_cards": project_id = arguments.get("project_id") column_id = arguments.get("column_id") cards = client.get_cards(project_id, column_id) - return { - "status": "success", - "cards": cards, - "count": len(cards) - } + return {"status": "success", "cards": cards, "count": len(cards)} elif tool_name == "get_card": project_id = arguments.get("project_id") card_id = arguments.get("card_id") card = client.get_card(project_id, card_id) - return { - "status": "success", - "card": card - } + return {"status": "success", "card": card} elif tool_name == "create_card": project_id = arguments.get("project_id") @@ -1233,11 +1442,13 @@ class MCPServer: content = arguments.get("content") due_on = arguments.get("due_on") notify = bool(arguments.get("notify", False)) - card = client.create_card(project_id, column_id, title, content, due_on, notify) + card = client.create_card( + project_id, column_id, title, content, due_on, notify + ) return { "status": "success", "card": card, - "message": f"Card '{title}' created successfully" + "message": f"Card '{title}' created successfully", } elif tool_name == "update_card": @@ -1247,11 +1458,13 @@ class MCPServer: content = arguments.get("content") due_on = arguments.get("due_on") assignee_ids = arguments.get("assignee_ids") - card = client.update_card(project_id, card_id, title, content, due_on, assignee_ids) + card = client.update_card( + project_id, card_id, title, content, due_on, assignee_ids + ) return { "status": "success", "card": card, - "message": "Card updated successfully" + "message": "Card updated successfully", } elif tool_name == "move_card": @@ -1262,38 +1475,25 @@ class MCPServer: message = "Card moved" if column_id: message = f"Card moved to column {column_id}" - return { - "status": "success", - "message": message - } - + return {"status": "success", "message": message} + elif tool_name == "complete_card": project_id = arguments.get("project_id") card_id = arguments.get("card_id") client.complete_card(project_id, card_id) - return { - "status": "success", - "message": "Card marked as complete" - } + return {"status": "success", "message": "Card marked as complete"} elif tool_name == "uncomplete_card": project_id = arguments.get("project_id") card_id = arguments.get("card_id") client.uncomplete_card(project_id, card_id) - return { - "status": "success", - "message": "Card marked as incomplete" - } + return {"status": "success", "message": "Card marked as incomplete"} elif tool_name == "get_card_steps": project_id = arguments.get("project_id") card_id = arguments.get("card_id") steps = client.get_card_steps(project_id, card_id) - return { - "status": "success", - "steps": steps, - "count": len(steps) - } + return {"status": "success", "steps": steps, "count": len(steps)} elif tool_name == "create_card_step": project_id = arguments.get("project_id") @@ -1301,21 +1501,20 @@ class MCPServer: title = arguments.get("title") due_on = arguments.get("due_on") assignee_ids = arguments.get("assignee_ids") - step = client.create_card_step(project_id, card_id, title, due_on, assignee_ids) + step = client.create_card_step( + project_id, card_id, title, due_on, assignee_ids + ) return { "status": "success", "step": step, - "message": f"Step '{title}' created successfully" + "message": f"Step '{title}' created successfully", } elif tool_name == "get_card_step": project_id = arguments.get("project_id") step_id = arguments.get("step_id") step = client.get_card_step(project_id, step_id) - return { - "status": "success", - "step": step - } + return {"status": "success", "step": step} elif tool_name == "update_card_step": project_id = arguments.get("project_id") @@ -1323,106 +1522,75 @@ class MCPServer: title = arguments.get("title") due_on = arguments.get("due_on") assignee_ids = arguments.get("assignee_ids") - step = client.update_card_step(project_id, step_id, title, due_on, assignee_ids) + step = client.update_card_step( + project_id, step_id, title, due_on, assignee_ids + ) return { "status": "success", "step": step, - "message": f"Step '{title}' updated successfully" + "message": f"Step '{title}' updated successfully", } elif tool_name == "delete_card_step": project_id = arguments.get("project_id") step_id = arguments.get("step_id") client.delete_card_step(project_id, step_id) - return { - "status": "success", - "message": "Step deleted successfully" - } + return {"status": "success", "message": "Step deleted successfully"} elif tool_name == "complete_card_step": project_id = arguments.get("project_id") step_id = arguments.get("step_id") client.complete_card_step(project_id, step_id) - return { - "status": "success", - "message": "Step marked as complete" - } + return {"status": "success", "message": "Step marked as complete"} elif tool_name == "uncomplete_card_step": project_id = arguments.get("project_id") step_id = arguments.get("step_id") client.uncomplete_card_step(project_id, step_id) - return { - "status": "success", - "message": "Step marked as incomplete" - } + return {"status": "success", "message": "Step marked as incomplete"} elif tool_name == "create_attachment": file_path = arguments.get("file_path") name = arguments.get("name") content_type = arguments.get("content_type", "application/octet-stream") result = client.create_attachment(file_path, name, content_type) - return { - "status": "success", - "attachment": result - } + return {"status": "success", "attachment": result} elif tool_name == "get_events": project_id = arguments.get("project_id") recording_id = arguments.get("recording_id") events = client.get_events(project_id, recording_id) - return { - "status": "success", - "events": events, - "count": len(events) - } + return {"status": "success", "events": events, "count": len(events)} elif tool_name == "get_webhooks": project_id = arguments.get("project_id") hooks = client.get_webhooks(project_id) - return { - "status": "success", - "webhooks": hooks, - "count": len(hooks) - } + return {"status": "success", "webhooks": hooks, "count": len(hooks)} elif tool_name == "create_webhook": project_id = arguments.get("project_id") payload_url = arguments.get("payload_url") types = arguments.get("types") hook = client.create_webhook(project_id, payload_url, types) - return { - "status": "success", - "webhook": hook - } + return {"status": "success", "webhook": hook} elif tool_name == "delete_webhook": project_id = arguments.get("project_id") webhook_id = arguments.get("webhook_id") client.delete_webhook(project_id, webhook_id) - return { - "status": "success", - "message": "Webhook deleted" - } + return {"status": "success", "message": "Webhook deleted"} elif tool_name == "get_documents": project_id = arguments.get("project_id") vault_id = arguments.get("vault_id") docs = client.get_documents(project_id, vault_id) - return { - "status": "success", - "documents": docs, - "count": len(docs) - } + return {"status": "success", "documents": docs, "count": len(docs)} elif tool_name == "get_document": project_id = arguments.get("project_id") document_id = arguments.get("document_id") doc = client.get_document(project_id, document_id) - return { - "status": "success", - "document": doc - } + return {"status": "success", "document": doc} elif tool_name == "create_document": project_id = arguments.get("project_id") @@ -1430,10 +1598,7 @@ class MCPServer: title = arguments.get("title") content = arguments.get("content") doc = client.create_document(project_id, vault_id, title, content) - return { - "status": "success", - "document": doc - } + return {"status": "success", "document": doc} elif tool_name == "update_document": project_id = arguments.get("project_id") @@ -1441,24 +1606,18 @@ class MCPServer: title = arguments.get("title") content = arguments.get("content") doc = client.update_document(project_id, document_id, title, content) - return { - "status": "success", - "document": doc - } + return {"status": "success", "document": doc} elif tool_name == "trash_document": project_id = arguments.get("project_id") document_id = arguments.get("document_id") client.trash_document(project_id, document_id) - return { - "status": "success", - "message": "Document trashed" - } + return {"status": "success", "message": "Document trashed"} else: return { "error": "Unknown tool", - "message": f"Tool '{tool_name}' is not supported" + "message": f"Tool '{tool_name}' is not supported", } except Exception as e: @@ -1467,12 +1626,9 @@ class MCPServer: 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." + "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) - } + return {"error": "Execution error", "message": str(e)} def run(self): """Run the MCP server, reading from stdin and writing to stdout.""" @@ -1496,10 +1652,7 @@ class MCPServer: error_response = { "jsonrpc": "2.0", "id": None, - "error": { - "code": -32700, - "message": "Parse error" - } + "error": {"code": -32700, "message": "Parse error"}, } print(json.dumps(error_response), flush=True) @@ -1508,13 +1661,11 @@ class MCPServer: error_response = { "jsonrpc": "2.0", "id": None, - "error": { - "code": -32603, - "message": f"Internal error: {str(e)}" - } + "error": {"code": -32603, "message": f"Internal error: {str(e)}"}, } print(json.dumps(error_response), flush=True) + if __name__ == "__main__": server = MCPServer() server.run() diff --git a/oauth_app.py b/oauth_app.py index ed62508..0f0e956 100644 --- a/oauth_app.py +++ b/oauth_app.py @@ -13,29 +13,47 @@ import sys import json import secrets import logging -from flask import Flask, request, redirect, url_for, session, render_template_string, jsonify +from flask import ( + Flask, + request, + redirect, + url_for, + session, + render_template_string, + jsonify, +) from dotenv import load_dotenv from basecamp_oauth import BasecampOAuth from basecamp_client import BasecampClient from search_utils import BasecampSearch import token_storage -# Configure logging +from config_paths import get_log_directory, get_env_file_path, ensure_directories_exist + +ensure_directories_exist() + +DOTENV_PATH = get_env_file_path() +if DOTENV_PATH.exists(): + load_dotenv(DOTENV_PATH) + +LOG_FILE_PATH = get_log_directory() / "oauth_app.log" logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[ - logging.FileHandler("oauth_app.log"), - logging.StreamHandler() - ] + logging.FileHandler(LOG_FILE_PATH), + logging.StreamHandler(), + ], ) logger = logging.getLogger(__name__) -# Load environment variables -load_dotenv() - # Check for required environment variables -required_vars = ['BASECAMP_CLIENT_ID', 'BASECAMP_CLIENT_SECRET', 'BASECAMP_REDIRECT_URI', 'USER_AGENT'] +required_vars = [ + "BASECAMP_CLIENT_ID", + "BASECAMP_CLIENT_SECRET", + "BASECAMP_REDIRECT_URI", + "USER_AGENT", +] missing_vars = [var for var in required_vars if not os.getenv(var)] if missing_vars: logger.error(f"Missing required environment variables: {', '.join(missing_vars)}") @@ -44,7 +62,7 @@ if missing_vars: # Create Flask app app = Flask(__name__) -app.secret_key = os.getenv('FLASK_SECRET_KEY', secrets.token_hex(16)) +app.secret_key = os.getenv("FLASK_SECRET_KEY", secrets.token_hex(16)) # HTML template for displaying results RESULTS_TEMPLATE = """ @@ -109,101 +127,116 @@ RESULTS_TEMPLATE = """ """ -@app.template_filter('tojson') + +@app.template_filter("tojson") def to_json(value, indent=None): return json.dumps(value, indent=indent) + def get_oauth_client(): """Get a configured OAuth client.""" try: - client_id = os.getenv('BASECAMP_CLIENT_ID') - client_secret = os.getenv('BASECAMP_CLIENT_SECRET') - redirect_uri = os.getenv('BASECAMP_REDIRECT_URI') - user_agent = os.getenv('USER_AGENT') + client_id = os.getenv("BASECAMP_CLIENT_ID") + client_secret = os.getenv("BASECAMP_CLIENT_SECRET") + redirect_uri = os.getenv("BASECAMP_REDIRECT_URI") + user_agent = os.getenv("USER_AGENT") - logger.info("Creating OAuth client with config: %s, %s, %s", client_id, redirect_uri, user_agent) + logger.info( + "Creating OAuth client with config: %s, %s, %s", + client_id, + redirect_uri, + user_agent, + ) return BasecampOAuth( client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, - user_agent=user_agent + user_agent=user_agent, ) except Exception as e: logger.error("Error creating OAuth client: %s", str(e)) raise + def ensure_valid_token(): """ - Ensure we have a valid, non-expired token. + Ensure we have a valid, non-expired token. Attempts to refresh if expired. - + Returns: dict: Valid token data or None if authentication is needed """ token_data = token_storage.get_token() - - if not token_data or not token_data.get('access_token'): + + if not token_data or not token_data.get("access_token"): logger.info("No token found") return None - + # Check if token is expired if token_storage.is_token_expired(): logger.info("Token is expired, attempting to refresh") - - refresh_token = token_data.get('refresh_token') + + refresh_token = token_data.get("refresh_token") if not refresh_token: logger.warning("No refresh token available, user needs to re-authenticate") return None - + try: oauth_client = get_oauth_client() new_token_data = oauth_client.refresh_token(refresh_token) - + # Store the new token - access_token = new_token_data.get('access_token') - new_refresh_token = new_token_data.get('refresh_token', refresh_token) # Use old refresh token if new one not provided - expires_in = new_token_data.get('expires_in') - account_id = token_data.get('account_id') # Keep the existing account_id - + access_token = new_token_data.get("access_token") + new_refresh_token = new_token_data.get( + "refresh_token", refresh_token + ) # Use old refresh token if new one not provided + expires_in = new_token_data.get("expires_in") + account_id = token_data.get("account_id") # Keep the existing account_id + if access_token: token_storage.store_token( access_token=access_token, refresh_token=new_refresh_token, expires_in=expires_in, - account_id=account_id + account_id=account_id, ) logger.info("Token refreshed successfully") return token_storage.get_token() else: logger.error("No access token in refresh response") return None - + except Exception as e: logger.error("Failed to refresh token: %s", str(e)) return None - + logger.info("Token is valid") return token_data -@app.route('/') + +@app.route("/") def home(): """Home page.""" # Ensure we have a valid token token_data = ensure_valid_token() - if token_data and token_data.get('access_token'): + if token_data and token_data.get("access_token"): # We have a valid token, show token information - access_token = token_data['access_token'] + access_token = token_data["access_token"] # Mask the token for security - masked_token = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***" + masked_token = ( + f"{access_token[:10]}...{access_token[-10:]}" + if len(access_token) > 20 + else "***" + ) token_info = { "access_token": masked_token, - "account_id": token_data.get('account_id'), - "has_refresh_token": bool(token_data.get('refresh_token')), - "expires_at": token_data.get('expires_at'), - "updated_at": token_data.get('updated_at') + "account_id": token_data.get("account_id"), + "has_refresh_token": bool(token_data.get("refresh_token")), + "expires_at": token_data.get("expires_at"), + "updated_at": token_data.get("updated_at"), } logger.info("Home page: User is authenticated") @@ -213,7 +246,7 @@ def home(): title="Basecamp OAuth Status", message="You are authenticated with Basecamp!", token_info=token_info, - show_logout=True + show_logout=True, ) else: # No valid token, show login button @@ -227,7 +260,7 @@ def home(): RESULTS_TEMPLATE, title="Basecamp OAuth Demo", message="Welcome! Please log in with your Basecamp account to continue.", - auth_url=auth_url + auth_url=auth_url, ) except Exception as e: logger.error("Error getting authorization URL: %s", str(e)) @@ -237,13 +270,14 @@ def home(): message=f"Error setting up OAuth: {str(e)}", ) -@app.route('/auth/callback') + +@app.route("/auth/callback") def auth_callback(): """Handle the OAuth callback from Basecamp.""" logger.info("OAuth callback called with args: %s", request.args) - code = request.args.get('code') - error = request.args.get('error') + code = request.args.get("code") + error = request.args.get("error") if error: logger.error("OAuth callback error: %s", error) @@ -251,7 +285,7 @@ def auth_callback(): RESULTS_TEMPLATE, title="Authentication Error", message=f"Basecamp returned an error: {error}", - show_home=True + show_home=True, ) if not code: @@ -260,7 +294,7 @@ def auth_callback(): RESULTS_TEMPLATE, title="Error", message="No authorization code received.", - show_home=True + show_home=True, ) try: @@ -271,10 +305,10 @@ def auth_callback(): logger.info(f"Raw token data from Basecamp exchange: {token_data}") # Store the token in our secure storage - access_token = token_data.get('access_token') - refresh_token = token_data.get('refresh_token') - expires_in = token_data.get('expires_in') - account_id = os.getenv('BASECAMP_ACCOUNT_ID') + access_token = token_data.get("access_token") + refresh_token = token_data.get("refresh_token") + expires_in = token_data.get("expires_in") + account_id = os.getenv("BASECAMP_ACCOUNT_ID") if not access_token: logger.error("OAuth exchange: No access token received") @@ -282,7 +316,7 @@ def auth_callback(): RESULTS_TEMPLATE, title="Authentication Error", message="No access token received from Basecamp.", - show_home=True + show_home=True, ) # Try to get identity if account_id is not set @@ -293,10 +327,10 @@ def auth_callback(): logger.info("Identity response: %s", identity) # Find Basecamp 3 account - if identity.get('accounts'): - for account in identity['accounts']: - if account.get('product') == 'bc3': # Basecamp 3 - account_id = account['id'] + if identity.get("accounts"): + for account in identity["accounts"]: + if account.get("product") == "bc3": # Basecamp 3 + account_id = account["id"] logger.info("Found account_id: %s", account_id) break except Exception as identity_error: @@ -308,7 +342,7 @@ def auth_callback(): access_token=access_token, refresh_token=refresh_token, expires_in=expires_in, - account_id=account_id + account_id=account_id, ) if not stored: @@ -317,29 +351,30 @@ def auth_callback(): RESULTS_TEMPLATE, title="Error", message="Failed to store token. Please try again.", - show_home=True + show_home=True, ) # Also keep the access token in session for convenience - session['access_token'] = access_token + session["access_token"] = access_token if refresh_token: - session['refresh_token'] = refresh_token + session["refresh_token"] = refresh_token if account_id: - session['account_id'] = account_id + session["account_id"] = account_id logger.info("OAuth flow completed successfully") - return redirect(url_for('home')) + return redirect(url_for("home")) except Exception as e: logger.error("Error in OAuth callback: %s", str(e), exc_info=True) return render_template_string( RESULTS_TEMPLATE, title="Error", message=f"Failed to exchange code for token: {str(e)}", - show_home=True + show_home=True, ) -@app.route('/api/token', methods=['GET']) + +@app.route("/api/token", methods=["GET"]) def get_token_api(): """ Secure API endpoint for the MCP server to get the token. @@ -349,38 +384,40 @@ def get_token_api(): # In production, implement proper authentication for this endpoint # For now, we'll use a simple API key check - api_key = request.headers.get('X-API-Key') - if not api_key or api_key != os.getenv('MCP_API_KEY', 'mcp_secret_key'): + api_key = request.headers.get("X-API-Key") + if not api_key or api_key != os.getenv("MCP_API_KEY", "mcp_secret_key"): logger.error("Token API: Invalid API key") - return jsonify({ - "error": "Unauthorized", - "message": "Invalid or missing API key" - }), 401 + return jsonify( + {"error": "Unauthorized", "message": "Invalid or missing API key"} + ), 401 # Use the ensure_valid_token function to get a fresh token token_data = ensure_valid_token() - if not token_data or not token_data.get('access_token'): + if not token_data or not token_data.get("access_token"): logger.error("Token API: No valid token available") - return jsonify({ - "error": "Not authenticated", - "message": "No valid token available" - }), 404 + return jsonify( + {"error": "Not authenticated", "message": "No valid token available"} + ), 404 logger.info("Token API: Successfully returned token") - return jsonify({ - "access_token": token_data['access_token'], - "account_id": token_data.get('account_id') - }) + return jsonify( + { + "access_token": token_data["access_token"], + "account_id": token_data.get("account_id"), + } + ) -@app.route('/logout') + +@app.route("/logout") def logout(): """Clear the session and token storage.""" logger.info("Logout called") session.clear() token_storage.clear_tokens() - return redirect(url_for('home')) + return redirect(url_for("home")) -@app.route('/token/info') + +@app.route("/token/info") def token_info(): """Display information about the stored token.""" logger.info("Token info called") @@ -392,26 +429,36 @@ def token_info(): RESULTS_TEMPLATE, title="Token Information", message="No token stored.", - show_home=True + show_home=True, ) # Check if token is expired is_expired = token_storage.is_token_expired() - - # Mask the tokens for security - access_token = token_data.get('access_token', '') - refresh_token = token_data.get('refresh_token', '') - masked_access = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***" - masked_refresh = f"{refresh_token[:10]}...{refresh_token[-10:]}" if refresh_token and len(refresh_token) > 20 else "***" if refresh_token else None + # Mask the tokens for security + access_token = token_data.get("access_token", "") + refresh_token = token_data.get("refresh_token", "") + + masked_access = ( + f"{access_token[:10]}...{access_token[-10:]}" + if len(access_token) > 20 + else "***" + ) + masked_refresh = ( + f"{refresh_token[:10]}...{refresh_token[-10:]}" + if refresh_token and len(refresh_token) > 20 + else "***" + if refresh_token + else None + ) display_info = { "access_token": masked_access, "has_refresh_token": bool(refresh_token), - "account_id": token_data.get('account_id'), - "expires_at": token_data.get('expires_at'), - "updated_at": token_data.get('updated_at'), - "is_expired": is_expired + "account_id": token_data.get("account_id"), + "expires_at": token_data.get("expires_at"), + "updated_at": token_data.get("updated_at"), + "is_expired": is_expired, } warning_message = None @@ -424,29 +471,28 @@ def token_info(): title="Token Information", content=json.dumps(display_info, indent=2), warning=warning_message, - show_home=True + show_home=True, ) -@app.route('/health') + +@app.route("/health") def health_check(): """Health check endpoint.""" logger.info("Health check called") - return jsonify({ - "status": "ok", - "service": "basecamp-oauth-app" - }) + return jsonify({"status": "ok", "service": "basecamp-oauth-app"}) -if __name__ == '__main__': + +if __name__ == "__main__": try: - logger.info("Starting OAuth app on port %s", os.environ.get('PORT', 8000)) + logger.info("Starting OAuth app on port %s", os.environ.get("PORT", 8000)) # Run the Flask app - port = int(os.environ.get('PORT', 8000)) + port = int(os.environ.get("PORT", 8000)) # Disable debug and auto-reloader when running in production or background - is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' + is_debug = os.environ.get("FLASK_DEBUG", "False").lower() == "true" logger.info("Running in %s mode", "debug" if is_debug else "production") - app.run(host='0.0.0.0', port=port, debug=is_debug, use_reloader=is_debug) + app.run(host="0.0.0.0", port=port, debug=is_debug, use_reloader=is_debug) except Exception as e: logger.error("Fatal error: %s", str(e), exc_info=True) sys.exit(1) diff --git a/token_storage.py b/token_storage.py index 8e0332d..86e9a22 100644 --- a/token_storage.py +++ b/token_storage.py @@ -12,12 +12,12 @@ import threading from datetime import datetime, timedelta import logging -# Determine the directory where this script (token_storage.py) is located +from config_paths import get_token_file_path + SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -# Allow token file location to be configured via environment variable -# Falls back to oauth_tokens.json in the script directory if not set TOKEN_FILE = os.environ.get( - "BASECAMP_TOKEN_FILE", os.path.join(SCRIPT_DIR, "oauth_tokens.json") + "BASECAMP_TOKEN_FILE", + str(get_token_file_path()), ) # Lock for thread-safe operations