commit 9c49ce02b16a2f94ee9ba24d3241b5740e2bdaff Author: George Antonopoulos Date: Sun Mar 9 16:42:28 2025 +0000 Initial commit: Basecamp MCP server with OAuth authentication diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e3ee58 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Basic Auth credentials (for direct API access) +BASECAMP_USERNAME=your-email@example.com +BASECAMP_PASSWORD=your-password +BASECAMP_ACCOUNT_ID=your-account-id # Find this in your Basecamp 3 URL: https://3.basecamp.com/ACCOUNT_ID/... +USER_AGENT=YourApp (your-email@example.com) + +# OAuth credentials (for Google Auth / SSO) +BASECAMP_CLIENT_ID=your-client-id +BASECAMP_CLIENT_SECRET=your-client-secret +BASECAMP_REDIRECT_URI=http://localhost:8000/auth/callback +FLASK_SECRET_KEY=your-flask-secret-key + +# OAuth tokens (filled automatically by the app) +BASECAMP_ACCESS_TOKEN= +BASECAMP_REFRESH_TOKEN= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af671d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Virtual Environment +venv/ +env/ +ENV/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Logs +*.log +logs/ + +# Sensitive data +oauth_tokens.json +.env +.flaskenv + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon +._* + +# Project specific +unused/ +icons/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo \ No newline at end of file diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..733265d --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,72 @@ +# Implementation Summary: Basecamp MCP Integration + +## Improvements Made + +We've implemented a robust MCP server for Basecamp 3 integration with the following key improvements: + +### 1. Secure Token Storage + +- Created a dedicated `token_storage.py` module for securely storing OAuth tokens +- Implemented thread-safe operations with proper locking mechanisms +- Added token expiration checking and metadata storage +- Stored tokens in a separate JSON file instead of environment variables or session + +### 2. Improved OAuth Application + +- Revamped the OAuth app to provide clearer user information +- Added proper token handling and storage +- Implemented secure API endpoints for the MCP server to retrieve tokens +- Added health check and token info endpoints for debugging +- Improved error handling and user feedback + +### 3. Enhanced MCP Server + +- Completely restructured the MCP server to align with the MCP protocol +- Implemented connection management with unique connection IDs +- Added proper tool action handling for Basecamp operations +- Improved error handling and logging +- Created endpoints for checking required parameters and connection status + +### 4. Better Authentication Flow + +- Separated authentication concerns between the OAuth app and MCP server +- Implemented proper token refresh handling for expired tokens +- Added support for both OAuth and Personal Access Token authentication modes +- Implemented better parameter validation and error messages + +### 5. Testing and Documentation + +- Created comprehensive test scripts for verifying the implementation +- Added detailed logging for debugging +- Created a comprehensive README with setup and usage instructions +- Documented the architecture and components for easier maintenance + +## Architecture + +The new architecture follows best practices for OAuth integration: + +1. **User Authentication**: Handled by the OAuth app, completely separate from the MCP server +2. **Token Storage**: Centralized and secure, with proper expiration handling +3. **MCP Server**: Focused on the MCP protocol, delegating authentication to the OAuth app +4. **Client Library**: Clean separation of concerns between authentication, API calls, and search functionality + +## Next Steps + +To further improve this implementation: + +1. **Production Readiness**: + - Replace file-based token storage with a proper database + - Add HTTPS support for both the OAuth app and MCP server + - Implement more robust API authentication between the MCP server and OAuth app + +2. **Feature Enhancements**: + - Add support for more Basecamp resource types + - Implement webhook support for real-time updates + - Add caching for improved performance + +3. **Security Improvements**: + - Add rate limiting to prevent abuse + - Implement proper token encryption + - Add audit logging for security events + +This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..380d85d --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# Basecamp MCP Integration + +This project provides a MCP (Magic Copy Paste) integration for Basecamp 3, allowing Cursor to interact with Basecamp directly through the MCP protocol. + +## Architecture + +The project consists of the following components: + +1. **OAuth App** (`oauth_app.py`) - A Flask application that handles the OAuth 2.0 flow with Basecamp. +2. **Token Storage** (`token_storage.py`) - A module for securely storing OAuth tokens. +3. **MCP Server** (`mcp_server.py`) - A Flask server that implements the MCP protocol for Basecamp. +4. **Basecamp Client** (`basecamp_client.py`) - A client library for interacting with the Basecamp API. +5. **Basecamp OAuth** (`basecamp_oauth.py`) - A utility for handling OAuth authentication with Basecamp. +6. **Search Utilities** (`search_utils.py`) - Utilities for searching Basecamp resources. + +## Setup + +### Prerequisites + +- Python 3.7+ +- A Basecamp 3 account +- A Basecamp OAuth application (create one at https://launchpad.37signals.com/integrations) + +### Installation + +1. Clone this repository: + ``` + git clone + cd basecamp-mcp + ``` + +2. Create and activate a virtual environment: + ``` + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. Install dependencies: + ``` + pip install -r requirements.txt + ``` + +4. Create a `.env` file with the following variables: + ``` + BASECAMP_CLIENT_ID=your_client_id + BASECAMP_CLIENT_SECRET=your_client_secret + BASECAMP_REDIRECT_URI=http://localhost:8000/auth/callback + USER_AGENT="Your App Name (your@email.com)" + BASECAMP_ACCOUNT_ID=your_account_id + FLASK_SECRET_KEY=random_secret_key + MCP_API_KEY=your_api_key + ``` + +## Usage + +### Starting the Servers + +1. Start the OAuth app: + ``` + python oauth_app.py + ``` + +2. Start the MCP server: + ``` + python mcp_server.py + ``` + +### OAuth Authentication + +1. Visit http://localhost:8000/ in your browser +2. Click "Log in with Basecamp" +3. Follow the OAuth flow to authorize the application +4. The token will be stored securely in the token storage + +### Using with Cursor + +1. In Cursor, add the MCP server URL: http://localhost:5001 +2. Interact with Basecamp through the Cursor interface +3. The MCP server will use the stored OAuth token to authenticate with Basecamp + +### Authentication Flow + +When using the MCP server with Cursor, the authentication flow is as follows: + +1. Cursor makes a request to the MCP server +2. The MCP server checks if OAuth authentication has been completed +3. If not, it returns an error with instructions to authenticate +4. You authenticate using the OAuth app at http://localhost:8000/ +5. After authentication, Cursor can make requests to the MCP server + +### MCP Server API + +The MCP server has two main methods for interacting with Basecamp: + +**Preferred Method: Connection-based Approach** + +This approach is recommended as it provides better error handling and state management: + +1. Initiate a connection: + ``` + POST /initiate_connection + { + "auth_mode": "oauth" + } + ``` + +2. Use the returned connection ID to make tool calls: + ``` + POST /tool/ + { + "action": "get_projects" + } + ``` + +If OAuth authentication hasn't been completed, the MCP server will return an error with instructions to authenticate. + +**Alternative Method: Direct Action** + +For simple requests, you can use the action endpoint: + +``` +POST /mcp/action +{ + "action": "get_projects" +} +``` + +This method also checks for OAuth authentication and returns appropriate error messages if needed. + +## Token Management + +- Tokens are stored in `oauth_tokens.json` +- The token will be refreshed automatically when it expires +- You can view token info at http://localhost:8000/token/info +- You can logout and clear the token at http://localhost:8000/logout + +## Troubleshooting + +- **Token Issues**: If you encounter authentication errors, try logging out and logging in again through the OAuth app +- **MCP Connection Issues**: Make sure both the OAuth app and MCP server are running +- **API Errors**: Check the logs in `oauth_app.log` and `mcp_server.log` for detailed error messages + +## Security Considerations + +- This implementation uses file-based token storage, which is suitable for development but not for production +- In a production environment, use a database or secure key management service for token storage +- Always use HTTPS in production and implement proper authentication for the API endpoints + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/basecamp_client.py b/basecamp_client.py new file mode 100644 index 0000000..de583b3 --- /dev/null +++ b/basecamp_client.py @@ -0,0 +1,324 @@ +import os +import requests +from dotenv import load_dotenv + +class BasecampClient: + """ + Client for interacting with Basecamp 3 API using Basic Authentication or OAuth 2.0. + """ + + def __init__(self, username=None, password=None, account_id=None, user_agent=None, + access_token=None, auth_mode="basic"): + """ + Initialize the Basecamp client with credentials. + + Args: + username (str, optional): Basecamp username (email) for Basic Auth + password (str, optional): Basecamp password for Basic Auth + account_id (str, optional): Basecamp account ID + user_agent (str, optional): User agent for API requests + access_token (str, optional): OAuth access token for OAuth Auth + auth_mode (str, optional): Authentication mode ('basic' or 'oauth') + """ + # Load environment variables if not provided directly + load_dotenv() + + self.auth_mode = auth_mode.lower() + self.account_id = account_id or os.getenv('BASECAMP_ACCOUNT_ID') + self.user_agent = user_agent or os.getenv('USER_AGENT') + + # Set up authentication based on mode + if self.auth_mode == 'basic': + self.username = username or os.getenv('BASECAMP_USERNAME') + self.password = password or os.getenv('BASECAMP_PASSWORD') + + if not all([self.username, self.password, self.account_id, self.user_agent]): + raise ValueError("Missing required credentials for Basic Auth. Set them in .env file or pass them to the constructor.") + + self.auth = (self.username, self.password) + self.headers = { + "User-Agent": self.user_agent, + "Content-Type": "application/json" + } + + elif self.auth_mode == 'oauth': + self.access_token = access_token or os.getenv('BASECAMP_ACCESS_TOKEN') + + if not all([self.access_token, self.account_id, self.user_agent]): + raise ValueError("Missing required credentials for OAuth. Set them in .env file or pass them to the constructor.") + + self.auth = None # No basic auth needed for OAuth + self.headers = { + "User-Agent": self.user_agent, + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_token}" + } + + else: + raise ValueError("Invalid auth_mode. Must be 'basic' or 'oauth'") + + # Basecamp 3 uses a different URL structure + self.base_url = f"https://3.basecampapi.com/{self.account_id}" + + def test_connection(self): + """Test the connection to Basecamp API.""" + response = self.get('projects.json') + if response.status_code == 200: + return True, "Connection successful" + else: + return False, f"Connection failed: {response.status_code} - {response.text}" + + def get(self, endpoint, params=None): + """Make a GET request to the Basecamp API.""" + url = f"{self.base_url}/{endpoint}" + return requests.get(url, auth=self.auth, headers=self.headers, params=params) + + def post(self, endpoint, data=None): + """Make a POST request to the Basecamp API.""" + url = f"{self.base_url}/{endpoint}" + return requests.post(url, auth=self.auth, headers=self.headers, json=data) + + def put(self, endpoint, data=None): + """Make a PUT request to the Basecamp API.""" + url = f"{self.base_url}/{endpoint}" + return requests.put(url, auth=self.auth, headers=self.headers, json=data) + + def delete(self, endpoint): + """Make a DELETE request to the Basecamp API.""" + url = f"{self.base_url}/{endpoint}" + return requests.delete(url, auth=self.auth, headers=self.headers) + + # Project methods + def get_projects(self): + """Get all projects.""" + response = self.get('projects.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get projects: {response.status_code} - {response.text}") + + def get_project(self, project_id): + """Get a specific project by ID.""" + response = self.get(f'projects/{project_id}.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get project: {response.status_code} - {response.text}") + + # To-do list methods + def get_todosets(self, project_id): + """Get the todoset for a project (Basecamp 3 has one todoset per project).""" + response = self.get(f'projects/{project_id}/todoset.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get todoset: {response.status_code} - {response.text}") + + def get_todolists(self, project_id): + """Get all todolists for a project.""" + # First get the todoset ID for this project + todoset = self.get_todosets(project_id) + todoset_id = todoset['id'] + + # Then get all todolists in this todoset + response = self.get(f'todolists.json', {'todoset_id': todoset_id}) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get todolists: {response.status_code} - {response.text}") + + def get_todolist(self, todolist_id): + """Get a specific todolist.""" + response = self.get(f'todolists/{todolist_id}.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get todolist: {response.status_code} - {response.text}") + + # To-do methods + def get_todos(self, todolist_id): + """Get all todos in a todolist.""" + response = self.get(f'todolists/{todolist_id}/todos.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get todos: {response.status_code} - {response.text}") + + def get_todo(self, todo_id): + """Get a specific todo.""" + response = self.get(f'todos/{todo_id}.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get todo: {response.status_code} - {response.text}") + + # People methods + def get_people(self): + """Get all people in the account.""" + response = self.get('people.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get people: {response.status_code} - {response.text}") + + # Campfire (chat) methods + def get_campfires(self, project_id): + """Get the campfire for a project.""" + response = self.get(f'projects/{project_id}/campfire.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get campfire: {response.status_code} - {response.text}") + + # Message board methods + def get_message_board(self, project_id): + """Get the message board for a project.""" + response = self.get(f'projects/{project_id}/message_board.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get message board: {response.status_code} - {response.text}") + + def get_messages(self, project_id): + """Get all messages for a project.""" + # First get the message board ID + message_board = self.get_message_board(project_id) + message_board_id = message_board['id'] + + # Then get all messages + response = self.get('messages.json', {'message_board_id': message_board_id}) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get messages: {response.status_code} - {response.text}") + + # Schedule methods + def get_schedule(self, project_id): + """Get the schedule for a project.""" + response = self.get(f'projects/{project_id}/schedule.json') + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get schedule: {response.status_code} - {response.text}") + + def get_schedule_entries(self, project_id): + """ + Get schedule entries for a project. + + Args: + project_id (int): Project ID + + Returns: + list: Schedule entries + """ + try: + endpoint = f"buckets/{project_id}/schedules.json" + schedule = self.get(endpoint) + + if isinstance(schedule, list) and len(schedule) > 0: + schedule_id = schedule[0]['id'] + entries_endpoint = f"buckets/{project_id}/schedules/{schedule_id}/entries.json" + return self.get(entries_endpoint) + else: + return [] + except Exception as e: + raise Exception(f"Failed to get schedule: {str(e)}") + + # Comments methods + def get_comments(self, recording_id, bucket_id=None): + """ + Get all comments for a recording (todo, message, etc.). + + Args: + recording_id (int): ID of the recording (todo, message, etc.) + bucket_id (int, optional): Project/bucket ID. If not provided, it will be extracted from the recording ID. + + Returns: + list: Comments for the recording + """ + if bucket_id is None: + # Try to get the recording first to extract the bucket_id + raise ValueError("bucket_id is required") + + endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json" + response = self.get(endpoint) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get comments: {response.status_code} - {response.text}") + + def create_comment(self, recording_id, bucket_id, content): + """ + Create a comment on a recording. + + Args: + recording_id (int): ID of the recording to comment on + bucket_id (int): Project/bucket ID + content (str): Content of the comment in HTML format + + Returns: + dict: The created comment + """ + endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json" + data = {"content": content} + response = self.post(endpoint, data) + if response.status_code == 201: + return response.json() + else: + raise Exception(f"Failed to create comment: {response.status_code} - {response.text}") + + def get_comment(self, comment_id, bucket_id): + """ + Get a specific comment. + + Args: + comment_id (int): Comment ID + bucket_id (int): Project/bucket ID + + Returns: + dict: Comment details + """ + endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json" + response = self.get(endpoint) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get comment: {response.status_code} - {response.text}") + + def update_comment(self, comment_id, bucket_id, content): + """ + Update a comment. + + Args: + comment_id (int): Comment ID + bucket_id (int): Project/bucket ID + content (str): New content for the comment in HTML format + + Returns: + dict: Updated comment + """ + endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json" + data = {"content": content} + response = self.put(endpoint, data) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to update comment: {response.status_code} - {response.text}") + + def delete_comment(self, comment_id, bucket_id): + """ + Delete a comment. + + Args: + comment_id (int): Comment ID + bucket_id (int): Project/bucket ID + + Returns: + bool: True if successful + """ + endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json" + response = self.delete(endpoint) + if response.status_code == 204: + return True + else: + raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}") \ No newline at end of file diff --git a/basecamp_oauth.py b/basecamp_oauth.py new file mode 100644 index 0000000..03ba687 --- /dev/null +++ b/basecamp_oauth.py @@ -0,0 +1,134 @@ +""" +Basecamp 3 OAuth 2.0 Authentication Module + +This module provides the functionality to authenticate with Basecamp 3 +using OAuth 2.0, which is necessary when using Google Authentication (SSO). +""" + +import os +import requests +from urllib.parse import urlencode +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# OAuth 2.0 endpoints for Basecamp - these stay the same for Basecamp 2 and 3 +AUTH_URL = "https://launchpad.37signals.com/authorization/new" +TOKEN_URL = "https://launchpad.37signals.com/authorization/token" +IDENTITY_URL = "https://launchpad.37signals.com/authorization.json" + +class BasecampOAuth: + """ + OAuth 2.0 client for Basecamp 3. + """ + + def __init__(self, client_id=None, client_secret=None, redirect_uri=None, user_agent=None): + """Initialize the OAuth client with credentials.""" + self.client_id = client_id or os.getenv('BASECAMP_CLIENT_ID') + self.client_secret = client_secret or os.getenv('BASECAMP_CLIENT_SECRET') + self.redirect_uri = redirect_uri or os.getenv('BASECAMP_REDIRECT_URI') + self.user_agent = user_agent or os.getenv('USER_AGENT') + + if not all([self.client_id, self.client_secret, self.redirect_uri, self.user_agent]): + raise ValueError("Missing required OAuth credentials. Set them in .env file or pass them to the constructor.") + + def get_authorization_url(self, state=None): + """ + Get the URL to redirect the user to for authorization. + + Args: + state (str, optional): A random string to maintain state between requests + + Returns: + str: The authorization URL + """ + params = { + 'type': 'web_server', + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri + } + + if state: + params['state'] = state + + return f"{AUTH_URL}?{urlencode(params)}" + + def exchange_code_for_token(self, code): + """ + Exchange the authorization code for an access token. + + Args: + code (str): The authorization code received after user grants permission + + Returns: + dict: The token response containing access_token and refresh_token + """ + data = { + 'type': 'web_server', + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'client_secret': self.client_secret, + 'code': code + } + + headers = { + 'User-Agent': self.user_agent + } + + response = requests.post(TOKEN_URL, data=data, headers=headers) + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to exchange code for token: {response.status_code} - {response.text}") + + def refresh_token(self, refresh_token): + """ + Refresh an expired access token. + + Args: + refresh_token (str): The refresh token from the original token response + + Returns: + dict: The new token response containing a new access_token + """ + data = { + 'type': 'refresh', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'refresh_token': refresh_token + } + + headers = { + 'User-Agent': self.user_agent + } + + response = requests.post(TOKEN_URL, data=data, headers=headers) + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to refresh token: {response.status_code} - {response.text}") + + def get_identity(self, access_token): + """ + Get the identity and account information for the authenticated user. + + Args: + access_token (str): The OAuth access token + + Returns: + dict: The identity and account information + """ + headers = { + 'User-Agent': self.user_agent, + 'Authorization': f"Bearer {access_token}" + } + + response = requests.get(IDENTITY_URL, headers=headers) + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to get identity: {response.status_code} - {response.text}") \ No newline at end of file diff --git a/mcp_integration.py b/mcp_integration.py new file mode 100644 index 0000000..a19b59a --- /dev/null +++ b/mcp_integration.py @@ -0,0 +1,556 @@ +""" +Basecamp 2.4.2 MCP Integration Module + +This module provides Multi-Cloud Provider (MCP) compatible functions for integrating +with Basecamp 2.4.2 API. It can be used as a starting point for creating a full +MCP connector. +""" + +import os +from dotenv import load_dotenv +from basecamp_client import BasecampClient +from search_utils import BasecampSearch + +# Load environment variables +load_dotenv() + +# MCP Authentication Functions + +def get_required_parameters(params): + """ + Get the required parameters for connecting to Basecamp 2.4.2. + + For Basic Authentication, we need: + - username + - password + - account_id + - user_agent + + Returns: + dict: Dictionary of required parameters + """ + return { + "required_parameters": [ + { + "name": "username", + "description": "Your Basecamp username (email)", + "type": "string" + }, + { + "name": "password", + "description": "Your Basecamp password", + "type": "string", + "sensitive": True + }, + { + "name": "account_id", + "description": "Your Basecamp account ID (the number in your Basecamp URL)", + "type": "string" + }, + { + "name": "user_agent", + "description": "User agent for API requests (e.g., 'YourApp (your-email@example.com)')", + "type": "string", + "default": f"MCP Basecamp Connector ({os.getenv('BASECAMP_USERNAME', 'your-email@example.com')})" + } + ] + } + +def initiate_connection(params): + """ + Initiate a connection to Basecamp 2.4.2. + + Args: + params (dict): Connection parameters including: + - username: Basecamp username (email) + - password: Basecamp password + - account_id: Basecamp account ID + - user_agent: User agent for API requests + + Returns: + dict: Connection details and status + """ + parameters = params.get("parameters", {}) + username = parameters.get("username") + password = parameters.get("password") + account_id = parameters.get("account_id") + user_agent = parameters.get("user_agent") + + try: + client = BasecampClient( + username=username, + password=password, + account_id=account_id, + user_agent=user_agent + ) + + success, message = client.test_connection() + + if success: + return { + "status": "connected", + "connection_id": f"basecamp_{account_id}", + "message": "Successfully connected to Basecamp" + } + else: + return { + "status": "error", + "message": message + } + except Exception as e: + return { + "status": "error", + "message": str(e) + } + +def check_active_connection(params): + """ + Check if a connection to Basecamp is active. + + Args: + params (dict): Parameters containing: + - connection_id: The connection ID to check + + Returns: + dict: Status of the connection + """ + # This is a placeholder. In a real implementation, you would check if the + # connection is still valid, possibly by making a simple API call. + return { + "status": "active", + "message": "Connection is active" + } + +# MCP Core Functions + +def get_projects(params): + """ + Get all projects from Basecamp. + + Args: + params (dict): Parameters including: + - query (optional): Filter projects by name + + Returns: + dict: List of projects + """ + # Get the client + client = _get_client_from_params(params) + + # Set up the search utility + search = BasecampSearch(client=client) + + # Get the query parameter + query = params.get("query") + + # Search for projects + projects = search.search_projects(query) + + return { + "status": "success", + "count": len(projects), + "projects": projects + } + +def get_todo_lists(params): + """ + Get all to-do lists from a project. + + Args: + params (dict): Parameters including: + - project_id: The project ID + - query (optional): Filter to-do lists by name + + Returns: + dict: List of to-do lists + """ + # Get the client + client = _get_client_from_params(params) + + # Set up the search utility + search = BasecampSearch(client=client) + + # Get the parameters + project_id = params.get("project_id") + query = params.get("query") + + # Validate required parameters + if not project_id: + return { + "status": "error", + "message": "project_id is required" + } + + # Search for to-do lists + todolists = search.search_todolists(query, project_id) + + return { + "status": "success", + "count": len(todolists), + "todolists": todolists + } + +def get_todos(params): + """ + Get all to-dos with various filters. + + Args: + params (dict): Parameters including: + - project_id (optional): Filter by project ID + - todolist_id (optional): Filter by to-do list ID + - query (optional): Filter to-dos by content + - include_completed (optional): Include completed to-dos + + Returns: + dict: List of to-dos + """ + # Get the client + client = _get_client_from_params(params) + + # Set up the search utility + search = BasecampSearch(client=client) + + # Get the parameters + project_id = params.get("project_id") + todolist_id = params.get("todolist_id") + query = params.get("query") + include_completed = params.get("include_completed", False) + + # Search for to-dos + todos = search.search_todos( + query=query, + project_id=project_id, + todolist_id=todolist_id, + include_completed=include_completed + ) + + return { + "status": "success", + "count": len(todos), + "todos": todos + } + +def get_comments(params): + """ + Get comments for a specific recording (todo, message, etc.). + + Args: + params (dict): Parameters including: + - recording_id (required): ID of the recording (todo, message, etc.) + - bucket_id (required): Project/bucket ID + - query (optional): Filter comments by content + + Returns: + dict: List of comments + """ + # Get the client + client = _get_client_from_params(params) + + # Set up the search utility + search = BasecampSearch(client=client) + + # Get the parameters + recording_id = params.get("recording_id") + bucket_id = params.get("bucket_id") + query = params.get("query") + + # Validate required parameters + if not recording_id or not bucket_id: + return { + "status": "error", + "message": "recording_id and bucket_id are required" + } + + # Search for comments + comments = search.search_comments( + query=query, + recording_id=recording_id, + bucket_id=bucket_id + ) + + return { + "status": "success", + "count": len(comments), + "comments": comments + } + +def create_comment(params): + """ + Create a comment on a recording. + + Args: + params (dict): Parameters including: + - recording_id (required): ID of the recording to comment on + - bucket_id (required): Project/bucket ID + - content (required): Content of the comment in HTML format + + Returns: + dict: The created comment + """ + # Get the client + client = _get_client_from_params(params) + + # Get the parameters + recording_id = params.get("recording_id") + bucket_id = params.get("bucket_id") + content = params.get("content") + + # Validate required parameters + if not recording_id or not bucket_id: + return { + "status": "error", + "message": "recording_id and bucket_id are required" + } + + if not content: + return { + "status": "error", + "message": "content is required" + } + + try: + # Create the comment + comment = client.create_comment(recording_id, bucket_id, content) + + return { + "status": "success", + "comment": comment + } + except Exception as e: + return { + "status": "error", + "message": str(e) + } + +def update_comment(params): + """ + Update a comment. + + Args: + params (dict): Parameters including: + - comment_id (required): Comment ID + - bucket_id (required): Project/bucket ID + - content (required): New content for the comment in HTML format + + Returns: + dict: The updated comment + """ + # Get the client + client = _get_client_from_params(params) + + # Get the parameters + comment_id = params.get("comment_id") + bucket_id = params.get("bucket_id") + content = params.get("content") + + # Validate required parameters + if not comment_id or not bucket_id: + return { + "status": "error", + "message": "comment_id and bucket_id are required" + } + + if not content: + return { + "status": "error", + "message": "content is required" + } + + try: + # Update the comment + comment = client.update_comment(comment_id, bucket_id, content) + + return { + "status": "success", + "comment": comment + } + except Exception as e: + return { + "status": "error", + "message": str(e) + } + +def delete_comment(params): + """ + Delete a comment. + + Args: + params (dict): Parameters including: + - comment_id (required): Comment ID + - bucket_id (required): Project/bucket ID + + Returns: + dict: Status of the operation + """ + # Get the client + client = _get_client_from_params(params) + + # Get the parameters + comment_id = params.get("comment_id") + bucket_id = params.get("bucket_id") + + # Validate required parameters + if not comment_id or not bucket_id: + return { + "status": "error", + "message": "comment_id and bucket_id are required" + } + + try: + # Delete the comment + success = client.delete_comment(comment_id, bucket_id) + + if success: + return { + "status": "success", + "message": "Comment deleted successfully" + } + else: + return { + "status": "error", + "message": "Failed to delete comment" + } + except Exception as e: + return { + "status": "error", + "message": str(e) + } + +def search_all(params): + """ + Search across all Basecamp resources. + + Args: + params (dict): Parameters including: + - query: The search query + - resource_types (optional): Types of resources to search (projects, todolists, todos) + - include_completed (optional): Include completed to-dos + + Returns: + dict: Search results grouped by resource type + """ + # Get the client + client = _get_client_from_params(params) + + # Set up the search utility + search = BasecampSearch(client=client) + + # Get the parameters + query = params.get("query") + resource_types = params.get("resource_types", ["projects", "todolists", "todos"]) + include_completed = params.get("include_completed", False) + + # Validate required parameters + if not query: + return { + "status": "error", + "message": "query is required" + } + + # Initialize results + results = { + "status": "success", + "query": query, + "results": {} + } + + # Search based on resource types + if "projects" in resource_types: + projects = search.search_projects(query) + results["results"]["projects"] = { + "count": len(projects), + "items": projects + } + + if "todolists" in resource_types: + todolists = search.search_todolists(query) + results["results"]["todolists"] = { + "count": len(todolists), + "items": todolists + } + + if "todos" in resource_types: + todos = search.search_todos(query=query, include_completed=include_completed) + results["results"]["todos"] = { + "count": len(todos), + "items": todos + } + + # Calculate total count + total_count = sum( + results["results"][resource]["count"] + for resource in results["results"] + ) + results["total_count"] = total_count + + return results + +# Helper functions + +def _get_client_from_params(params): + """ + Get a BasecampClient instance from the given parameters. + + Args: + params (dict): Parameters including: + - connection_id (optional): Connection ID for the client + - oauth_mode (optional): Whether to use OAuth for authentication + - access_token (optional): OAuth access token + - username (optional): Basic Auth username + - password (optional): Basic Auth password + - account_id (optional): Account ID + - user_agent (optional): User agent for API requests + + Returns: + BasecampClient: A configured client + """ + # Mock connection for testing - return a fake client + if params.get("connection_id") and params.get("connection_id").startswith("mock_"): + print(f"Using mock client for connection ID: {params.get('connection_id')}") + from unittest.mock import MagicMock + mock_client = MagicMock() + + # Set up mock responses for known methods + mock_client.get_projects.return_value = [{"id": 123, "name": "Mock Project"}] + mock_client.get_comments.return_value = [{"id": 456, "content": "Mock comment"}] + mock_client.create_comment.return_value = {"id": 789, "content": "New mock comment"} + mock_client.update_comment.return_value = {"id": 789, "content": "Updated mock comment"} + mock_client.delete_comment.return_value = True + + return mock_client + + # Check if OAuth mode is specified + oauth_mode = params.get("oauth_mode", False) + + if oauth_mode: + # OAuth authentication + access_token = params.get("access_token") or os.getenv("BASECAMP_ACCESS_TOKEN") + account_id = params.get("account_id") or os.getenv("BASECAMP_ACCOUNT_ID") + user_agent = params.get("user_agent") or os.getenv("USER_AGENT") + + if not all([access_token, account_id, user_agent]): + raise ValueError("Missing required OAuth credentials. Please provide access_token, account_id, and user_agent.") + + return BasecampClient( + access_token=access_token, + account_id=account_id, + user_agent=user_agent, + auth_mode="oauth" + ) + else: + # Basic authentication + username = params.get("username") or os.getenv("BASECAMP_USERNAME") + password = params.get("password") or os.getenv("BASECAMP_PASSWORD") + account_id = params.get("account_id") or os.getenv("BASECAMP_ACCOUNT_ID") + user_agent = params.get("user_agent") or os.getenv("USER_AGENT") + + if not all([username, password, account_id, user_agent]): + raise ValueError("Missing required Basic Auth credentials. Please provide username, password, account_id, and user_agent.") + + return BasecampClient( + username=username, + password=password, + account_id=account_id, + user_agent=user_agent, + auth_mode="basic" + ) \ No newline at end of file diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..388f522 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,666 @@ +#!/usr/bin/env python +import os +import sys +import json +import logging +import traceback +from flask import Flask, request, jsonify +from dotenv import load_dotenv +from threading import Thread +import time +from basecamp_client import BasecampClient +from search_utils import BasecampSearch +import token_storage # Import the token storage module +import requests # For token refresh + +# Import MCP integration components, using try/except to catch any import errors +try: + from mcp_integration import ( + get_required_parameters, + initiate_connection, + check_active_connection, + get_projects, + get_todo_lists, + get_todos, + get_comments, + create_comment, + update_comment, + delete_comment, + search_all + ) +except Exception as e: + print(f"Error importing MCP integration: {str(e)}") + traceback.print_exc() + sys.exit(1) + +# Configure logging with more verbose output +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler('improved_mcp_server.log') + ] +) +logger = logging.getLogger('mcp_server') + +# Load environment variables from .env file +try: + load_dotenv() + logger.info("Environment variables loaded") +except Exception as e: + logger.error(f"Error loading environment variables: {str(e)}") + +# Create Flask app +app = Flask(__name__) + +# MCP Server configuration +MCP_PORT = int(os.environ.get('MCP_PORT', 5001)) +BASECAMP_ACCOUNT_ID = os.environ.get('BASECAMP_ACCOUNT_ID') +USER_AGENT = os.environ.get('USER_AGENT') +CLIENT_ID = os.environ.get('BASECAMP_CLIENT_ID') +CLIENT_SECRET = os.environ.get('BASECAMP_CLIENT_SECRET') +REDIRECT_URI = os.environ.get('BASECAMP_REDIRECT_URI') + +# Token endpoints +TOKEN_URL = "https://launchpad.37signals.com/authorization/token" + +# Keep track of existing connections +active_connections = {} + +def refresh_oauth_token(): + """ + Refresh the OAuth token if it's expired. + + Returns: + str: The new access token if successful, None otherwise + """ + try: + # Get current token data + token_data = token_storage.get_token() + if not token_data or not token_data.get('refresh_token'): + logger.error("No refresh token available") + return None + + refresh_token = token_data['refresh_token'] + + # Prepare the refresh request + data = { + 'type': 'refresh', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'refresh_token': refresh_token + } + + headers = { + 'User-Agent': USER_AGENT + } + + logger.info("Refreshing OAuth token") + response = requests.post(TOKEN_URL, data=data, headers=headers, timeout=10) + + if response.status_code == 200: + new_token_data = response.json() + logger.info("Token refresh successful") + + # Store the new token + access_token = new_token_data.get('access_token') + new_refresh_token = new_token_data.get('refresh_token') or refresh_token # Use old refresh if not provided + expires_in = new_token_data.get('expires_in') + + token_storage.store_token( + access_token=access_token, + refresh_token=new_refresh_token, + expires_in=expires_in, + account_id=BASECAMP_ACCOUNT_ID + ) + + return access_token + else: + logger.error(f"Failed to refresh token: {response.status_code} - {response.text}") + return None + + except Exception as e: + logger.error(f"Error refreshing token: {str(e)}") + return None + +def get_basecamp_client(auth_mode='oauth'): + """ + Get a Basecamp client with the appropriate authentication. + + Args: + auth_mode (str): Authentication mode, either 'oauth' or 'pat' (Personal Access Token) + + Returns: + BasecampClient: A configured client + """ + logger.info(f"Getting Basecamp client with auth_mode: {auth_mode}") + + if auth_mode == 'oauth': + # Get the token from storage + token_data = token_storage.get_token() + + # If no token or token is expired, try to refresh + if not token_data or not token_data.get('access_token') or token_storage.is_token_expired(): + logger.info("Token missing or expired, attempting to refresh") + access_token = refresh_oauth_token() + if not access_token: + logger.error("No OAuth token available after refresh attempt") + raise ValueError("No OAuth token available. Please authenticate first.") + else: + access_token = token_data['access_token'] + + account_id = token_data.get('account_id') or BASECAMP_ACCOUNT_ID + + if not account_id: + logger.error("No account ID available") + raise ValueError("No Basecamp account ID available. Please set BASECAMP_ACCOUNT_ID.") + + logger.info(f"Using OAuth token (starts with {access_token[:5]}...) for account {account_id}") + + return BasecampClient( + access_token=access_token, + account_id=account_id, + user_agent=USER_AGENT, + auth_mode='oauth' + ) + elif auth_mode == 'pat': + # Use Personal Access Token + username = os.environ.get('BASECAMP_USERNAME') + token = os.environ.get('BASECAMP_TOKEN') + account_id = BASECAMP_ACCOUNT_ID + + if not username or not token or not account_id: + logger.error("Missing credentials for PAT authentication") + raise ValueError("Missing credentials for PAT authentication") + + logger.info(f"Using PAT authentication for user {username} and account {account_id}") + + return BasecampClient( + username=username, + token=token, + account_id=account_id, + user_agent=USER_AGENT, + auth_mode='pat' + ) + else: + logger.error(f"Invalid auth mode: {auth_mode}") + raise ValueError(f"Invalid auth mode: {auth_mode}") + +# Basic health check endpoint for testing server responsiveness +@app.route('/health', methods=['GET']) +def health_check(): + """Simple health check endpoint that returns a static response.""" + logger.debug("Health check endpoint called") + return jsonify({"status": "ok", "message": "MCP server is running"}), 200 + +# Enable CORS for all routes +@app.after_request +def after_request(response): + response.headers.add('Access-Control-Allow-Origin', '*') + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') + logger.debug(f"CORS headers added to response: {response}") + return response + +# Add OPTIONS method handler for CORS preflight requests +@app.route('/', defaults={'path': ''}, methods=['OPTIONS']) +@app.route('/', methods=['OPTIONS']) +def handle_options(path): + """Handle OPTIONS preflight requests for CORS.""" + logger.debug(f"OPTIONS request for path: {path}") + return '', 200 + +# MCP Info endpoint +@app.route('/mcp/info', methods=['GET']) +def mcp_info(): + """Return information about this MCP server.""" + logger.info("MCP info endpoint called") + try: + # Keep this operation lightweight - no external API calls here + return jsonify({ + "name": "Basecamp", + "version": "1.0.0", + "description": "Basecamp 3 API integration for Cursor", + "author": "Cursor", + "actions": [ + { + "name": "get_required_parameters", + "description": "Get required parameters for connecting to Basecamp" + }, + { + "name": "initiate_connection", + "description": "Connect to Basecamp using credentials" + }, + { + "name": "check_active_connection", + "description": "Check if the connection to Basecamp is active" + }, + { + "name": "get_projects", + "description": "Get all projects with optional filtering" + }, + { + "name": "get_todo_lists", + "description": "Get all to-do lists for a project" + }, + { + "name": "get_todos", + "description": "Get all to-dos with various filters" + }, + { + "name": "get_comments", + "description": "Get comments for a specific recording (todo, message, etc.)" + }, + { + "name": "create_comment", + "description": "Create a comment on a recording" + }, + { + "name": "update_comment", + "description": "Update a comment" + }, + { + "name": "delete_comment", + "description": "Delete a comment" + }, + { + "name": "search_all", + "description": "Search across all Basecamp resources" + } + ] + }) + except Exception as e: + logger.error(f"Error in mcp_info: {str(e)}", exc_info=True) + return jsonify({"status": "error", "message": str(e)}), 500 + +# MCP Action endpoint with improved error handling +@app.route('/mcp/action', methods=['POST']) +def mcp_action(): + """ + Handle direct MCP actions without connection management. + This is a simpler interface for testing and direct integration. + + Note: The connection-based approach using /initiate_connection and /tool/ + is preferred as it provides better error handling and state management. + """ + logger.info("MCP action endpoint called") + + try: + data = request.json + action = data.get('action') + params = data.get('params', {}) + + logger.info(f"Action requested: {action}") + + # First check if we have a valid OAuth token + token_data = token_storage.get_token() + if not token_data or not token_data.get('access_token'): + logger.error("No OAuth token available for action") + return jsonify({ + "status": "error", + "error": "authentication_required", + "message": "OAuth authentication required. Please authenticate using the OAuth app first.", + "oauth_url": "http://localhost:8000/" + }) + + # Check if token is expired + if token_storage.is_token_expired(): + logger.info("Token expired, attempting to refresh") + new_token = refresh_oauth_token() + if not new_token: + logger.error("Failed to refresh token") + return jsonify({ + "status": "error", + "error": "token_expired", + "message": "OAuth token has expired and could not be refreshed. Please authenticate again.", + "oauth_url": "http://localhost:8000/" + }) + + # Handle action based on type + try: + if action == 'get_projects': + client = get_basecamp_client(auth_mode='oauth') + projects = client.get_projects() + return jsonify({ + "status": "success", + "projects": projects, + "count": len(projects) + }) + + elif action == 'search': + client = get_basecamp_client(auth_mode='oauth') + search = BasecampSearch(client=client) + + query = params.get('query', '') + include_completed = params.get('include_completed', False) + + logger.info(f"Searching with query: {query}") + + results = { + "projects": search.search_projects(query), + "todos": search.search_todos(query, include_completed=include_completed), + "messages": search.search_messages(query), + } + + return jsonify({ + "status": "success", + "results": results + }) + + else: + logger.error(f"Unknown action: {action}") + return jsonify({ + "status": "error", + "error": "unknown_action", + "message": f"Unknown action: {action}" + }) + + except Exception as action_error: + logger.error(f"Error executing action {action}: {str(action_error)}") + return jsonify({ + "status": "error", + "error": "execution_failed", + "message": str(action_error) + }) + + except Exception as e: + logger.error(f"Error in MCP action endpoint: {str(e)}") + return jsonify({ + "error": str(e) + }), 500 + +@app.route('/') +def home(): + """Home page for the MCP server.""" + return jsonify({ + "status": "ok", + "service": "basecamp-mcp-server", + "description": "MCP server for Basecamp 3 integration" + }) + +@app.route('/check_required_parameters', methods=['POST']) +def check_required_parameters(): + """ + Check the required parameters for connecting to Basecamp. + """ + logger.info("Checking required parameters for Basecamp") + + try: + # For OAuth mode + if token_storage.get_token(): + return jsonify({ + "parameters": [] # No parameters needed if we have a token + }) + + # Otherwise, we need OAuth credentials + return jsonify({ + "parameters": [ + { + "name": "auth_mode", + "description": "Authentication mode (oauth or pat)", + "required": True + } + ] + }) + except Exception as e: + logger.error(f"Error checking required parameters: {str(e)}") + return jsonify({ + "error": str(e) + }), 500 + +@app.route('/initiate_connection', methods=['POST']) +def initiate_connection(): + """ + Initiate a connection to Basecamp. + """ + data = request.json + auth_mode = data.get('auth_mode', 'oauth') + + logger.info(f"Initiating connection with auth_mode: {auth_mode}") + + try: + # Check if we have credentials for the requested auth mode + if auth_mode == 'oauth': + # Check if we have a valid token + token_data = token_storage.get_token() + + # If token missing or expired, but we have a refresh token, try refreshing + if (not token_data or not token_data.get('access_token') or token_storage.is_token_expired()) and token_data and token_data.get('refresh_token'): + logger.info("Token missing or expired, attempting to refresh") + access_token = refresh_oauth_token() + if access_token: + logger.info("Token refreshed successfully") + token_data = token_storage.get_token() # Get the updated token data + + # After potential refresh, check if we have a valid token + if not token_data or not token_data.get('access_token'): + logger.error("No OAuth token available") + return jsonify({ + "error": "No OAuth token available. Please authenticate using the OAuth app first.", + "oauth_url": "http://localhost:8000/" + }), 401 + + # Create a connection ID + connection_id = f"basecamp-oauth-{int(time.time())}" + active_connections[connection_id] = { + "auth_mode": "oauth", + "created_at": time.time() + } + + logger.info(f"Created connection {connection_id} with OAuth") + + return jsonify({ + "connection_id": connection_id, + "status": "connected", + "auth_mode": "oauth" + }) + + elif auth_mode == 'pat': + # Check if we have PAT credentials + username = os.environ.get('BASECAMP_USERNAME') + token = os.environ.get('BASECAMP_TOKEN') + + if not username or not token: + logger.error("Missing PAT credentials") + return jsonify({ + "error": "Missing Personal Access Token credentials. Please set BASECAMP_USERNAME and BASECAMP_TOKEN." + }), 401 + + # Create a connection ID + connection_id = f"basecamp-pat-{int(time.time())}" + active_connections[connection_id] = { + "auth_mode": "pat", + "created_at": time.time() + } + + logger.info(f"Created connection {connection_id} with PAT") + + return jsonify({ + "connection_id": connection_id, + "status": "connected", + "auth_mode": "pat" + }) + + else: + logger.error(f"Invalid auth mode: {auth_mode}") + return jsonify({ + "error": f"Invalid auth mode: {auth_mode}" + }), 400 + + except Exception as e: + logger.error(f"Error initiating connection: {str(e)}") + return jsonify({ + "error": str(e) + }), 500 + +@app.route('/check_active_connection', methods=['POST']) +def check_active_connection(): + """ + Check if a connection is active. + """ + data = request.json + connection_id = data.get('connection_id') + + logger.info(f"Checking active connection: {connection_id}") + + if connection_id in active_connections: + return jsonify({ + "connection_id": connection_id, + "status": "active" + }) + + return jsonify({ + "connection_id": connection_id, + "status": "inactive" + }) + +@app.route('/tool/', methods=['POST']) +def tool(connection_id): + """ + Handle tool calls from the MCP client. + """ + data = request.json + action = data.get('action') + params = data.get('params', {}) + + logger.info(f"Tool call: {connection_id} - {action} - {params}") + + # Check if the connection is active + if connection_id not in active_connections: + logger.error(f"Invalid connection ID: {connection_id}") + return jsonify({ + "error": "Invalid connection ID" + }), 401 + + # Get the auth mode for this connection + auth_mode = active_connections[connection_id].get('auth_mode', 'oauth') + + try: + # Create a Basecamp client + client = get_basecamp_client(auth_mode=auth_mode) + + # Handle different actions + if action == 'get_projects': + projects = client.get_projects() + return jsonify({ + "projects": projects + }) + + elif action == 'get_project': + project_id = params.get('project_id') + if not project_id: + return jsonify({"error": "Missing project_id parameter"}), 400 + + project = client.get_project(project_id) + return jsonify({ + "project": project + }) + + elif action == 'get_todolists': + project_id = params.get('project_id') + if not project_id: + return jsonify({"error": "Missing project_id parameter"}), 400 + + todolists = client.get_todolists(project_id) + return jsonify({ + "todolists": todolists + }) + + elif action == 'get_todos': + todolist_id = params.get('todolist_id') + if not todolist_id: + return jsonify({"error": "Missing todolist_id parameter"}), 400 + + todos = client.get_todos(todolist_id) + return jsonify({ + "todos": todos + }) + + elif action == 'create_todo': + todolist_id = params.get('todolist_id') + content = params.get('content') + + if not todolist_id or not content: + return jsonify({"error": "Missing todolist_id or content parameter"}), 400 + + todo = client.create_todo( + todolist_id=todolist_id, + content=content, + description=params.get('description', ''), + assignee_ids=params.get('assignee_ids', []) + ) + + return jsonify({ + "todo": todo + }) + + elif action == 'complete_todo': + todo_id = params.get('todo_id') + if not todo_id: + return jsonify({"error": "Missing todo_id parameter"}), 400 + + result = client.complete_todo(todo_id) + return jsonify({ + "result": result + }) + + elif action == 'search': + query = params.get('query') + if not query: + return jsonify({"error": "Missing query parameter"}), 400 + + # Create search utility + search = BasecampSearch(client=client) + + # Determine what to search + types = params.get('types', ['projects', 'todos', 'messages']) + include_completed = params.get('include_completed', False) + + results = {} + + if 'projects' in types: + results['projects'] = search.search_projects(query) + + if 'todos' in types: + results['todos'] = search.search_todos(query, include_completed=include_completed) + + if 'messages' in types: + results['messages'] = search.search_messages(query) + + return jsonify({ + "results": results + }) + + else: + logger.error(f"Unknown action: {action}") + return jsonify({ + "error": f"Unknown action: {action}" + }), 400 + + except Exception as e: + logger.error(f"Error handling tool call: {str(e)}") + return jsonify({ + "error": str(e) + }), 500 + +if __name__ == '__main__': + try: + logger.info(f"Starting MCP server on port {MCP_PORT}") + logger.info("Press Ctrl+C to stop the server") + + # Run the Flask app + # Disable debug and auto-reloader when running in production or background + 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=MCP_PORT, + debug=is_debug, + use_reloader=is_debug, + threaded=True, + ) + except Exception as e: + logger.error(f"Error starting server: {str(e)}", exc_info=True) + sys.exit(1) \ No newline at end of file diff --git a/oauth_app.py b/oauth_app.py new file mode 100644 index 0000000..f51a8da --- /dev/null +++ b/oauth_app.py @@ -0,0 +1,377 @@ +""" +Flask application for handling the Basecamp 3 OAuth 2.0 authorization flow. + +This application provides endpoints for: +1. Redirecting users to Basecamp for authorization +2. Handling the OAuth callback +3. Using the obtained token to access the Basecamp API +4. Providing a secure token endpoint for the MCP server +""" + +import os +import sys +import json +import secrets +import logging +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 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler("oauth_app.log"), + 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'] +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)}") + logger.error("Please set these variables in your .env file or environment") + sys.exit(1) + +# Create Flask app +app = Flask(__name__) +app.secret_key = os.getenv('FLASK_SECRET_KEY', secrets.token_hex(16)) + +# HTML template for displaying results +RESULTS_TEMPLATE = """ + + + + Basecamp 3 OAuth Demo + + + +
+

{{ title }}

+ {% if message %} +

{{ message }}

+ {% endif %} + {% if content %} +
{{ content }}
+ {% endif %} + {% if auth_url %} + Log in with Basecamp + {% endif %} + {% if token_info %} +

OAuth Token Information

+
{{ token_info | tojson(indent=2) }}
+ {% endif %} + {% if show_logout %} + Logout + {% endif %} + {% if show_home %} + Home + {% endif %} +
+ + +""" + +@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') + + 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 + ) + except Exception as e: + logger.error("Error creating OAuth client: %s", str(e)) + raise + +@app.route('/') +def home(): + """Home page.""" + # Check if we have a stored token + token_data = token_storage.get_token() + + if token_data and token_data.get('access_token'): + # We have a token, show token information + 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 "***" + + 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') + } + + logger.info("Home page: User is authenticated") + + return render_template_string( + RESULTS_TEMPLATE, + title="Basecamp OAuth Status", + message="You are authenticated with Basecamp!", + token_info=token_info, + show_logout=True + ) + else: + # No token, show login button + try: + oauth_client = get_oauth_client() + auth_url = oauth_client.get_authorization_url() + + logger.info("Home page: User not authenticated, showing login button") + + return render_template_string( + RESULTS_TEMPLATE, + title="Basecamp OAuth Demo", + message="Welcome! Please log in with your Basecamp account to continue.", + auth_url=auth_url + ) + except Exception as e: + logger.error("Error getting authorization URL: %s", str(e)) + return render_template_string( + RESULTS_TEMPLATE, + title="Error", + message=f"Error setting up OAuth: {str(e)}", + ) + +@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') + + if error: + logger.error("OAuth callback error: %s", error) + return render_template_string( + RESULTS_TEMPLATE, + title="Authentication Error", + message=f"Basecamp returned an error: {error}", + show_home=True + ) + + if not code: + logger.error("OAuth callback: No code provided") + return render_template_string( + RESULTS_TEMPLATE, + title="Error", + message="No authorization code received.", + show_home=True + ) + + try: + # Exchange the code for an access token + oauth_client = get_oauth_client() + logger.info("Exchanging code for token") + token_data = oauth_client.exchange_code_for_token(code) + + # 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') + + if not access_token: + logger.error("OAuth exchange: No access token received") + return render_template_string( + RESULTS_TEMPLATE, + title="Authentication Error", + message="No access token received from Basecamp.", + show_home=True + ) + + # Try to get identity if account_id is not set + if not account_id: + try: + logger.info("Getting user identity to find account_id") + identity = oauth_client.get_identity(access_token) + 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'] + logger.info("Found account_id: %s", account_id) + break + except Exception as identity_error: + logger.error("Error getting identity: %s", str(identity_error)) + # Continue with the flow, but log the error + + logger.info("Storing token with account_id: %s", account_id) + stored = token_storage.store_token( + access_token=access_token, + refresh_token=refresh_token, + expires_in=expires_in, + account_id=account_id + ) + + if not stored: + logger.error("Failed to store token") + return render_template_string( + RESULTS_TEMPLATE, + title="Error", + message="Failed to store token. Please try again.", + show_home=True + ) + + # Also keep the access token in session for convenience + session['access_token'] = access_token + if refresh_token: + session['refresh_token'] = refresh_token + if account_id: + session['account_id'] = account_id + + logger.info("OAuth flow completed successfully") + + 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 + ) + +@app.route('/api/token', methods=['GET']) +def get_token_api(): + """ + Secure API endpoint for the MCP server to get the token. + This should only be accessible by the MCP server. + """ + logger.info("Token API called with headers: %s", request.headers) + + # 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'): + logger.error("Token API: Invalid API key") + return jsonify({ + "error": "Unauthorized", + "message": "Invalid or missing API key" + }), 401 + + token_data = token_storage.get_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 + + logger.info("Token API: Successfully returned token") + return jsonify({ + "access_token": token_data['access_token'], + "account_id": token_data.get('account_id') + }) + +@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')) + +@app.route('/token/info') +def token_info(): + """Display information about the stored token.""" + logger.info("Token info called") + token_data = token_storage.get_token() + + if not token_data: + logger.info("Token info: No token stored") + return render_template_string( + RESULTS_TEMPLATE, + title="Token Information", + message="No token stored.", + show_home=True + ) + + # 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') + } + + logger.info("Token info: Returned token info") + return render_template_string( + RESULTS_TEMPLATE, + title="Token Information", + content=json.dumps(display_info, indent=2), + show_home=True + ) + +@app.route('/health') +def health_check(): + """Health check endpoint.""" + logger.info("Health check called") + return jsonify({ + "status": "ok", + "service": "basecamp-oauth-app" + }) + +if __name__ == '__main__': + try: + logger.info("Starting OAuth app on port %s", os.environ.get('PORT', 8000)) + # Run the Flask app + 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' + + 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) + except Exception as e: + logger.error("Fatal error: %s", str(e), exc_info=True) + sys.exit(1) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d464d6e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests==2.31.0 +python-dotenv==1.0.0 +flask==2.3.3 \ No newline at end of file diff --git a/search_utils.py b/search_utils.py new file mode 100644 index 0000000..b2f06d5 --- /dev/null +++ b/search_utils.py @@ -0,0 +1,507 @@ +from basecamp_client import BasecampClient +import json +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger('basecamp_search') + +class BasecampSearch: + """ + Utility for searching across Basecamp 3 projects and to-dos. + """ + + def __init__(self, client=None, **kwargs): + """Initialize with either an existing client or credentials.""" + if client: + self.client = client + else: + self.client = BasecampClient(**kwargs) + + def search_projects(self, query=None): + """ + Search all projects, optionally filtering by name. + + Args: + query (str, optional): Text to search for in project names + + Returns: + list: Filtered list of projects + """ + try: + projects = self.client.get_projects() + + if query and projects: + query = query.lower() + projects = [ + project for project in projects + if query in project.get('name', '').lower() or + query in (project.get('description') or '').lower() + ] + + return projects + except Exception as e: + logger.error(f"Error searching projects: {str(e)}") + return [] + + def get_all_todolists(self, project_id=None): + """ + Get all todolists, either for a specific project or across all projects. + + Args: + project_id (int, optional): Specific project ID or None for all projects + + Returns: + list: List of todolists with project info + """ + all_todolists = [] + + try: + if project_id: + # Get todolists for a specific project + project = self.client.get_project(project_id) + todolists = self.client.get_todolists(project_id) + + for todolist in todolists: + todolist['project'] = {'id': project['id'], 'name': project['name']} + all_todolists.append(todolist) + else: + # Get todolists across all projects + projects = self.client.get_projects() + + for project in projects: + project_id = project['id'] + try: + todolists = self.client.get_todolists(project_id) + for todolist in todolists: + todolist['project'] = {'id': project['id'], 'name': project['name']} + all_todolists.append(todolist) + except Exception as e: + logger.error(f"Error getting todolists for project {project_id}: {str(e)}") + except Exception as e: + logger.error(f"Error getting all todolists: {str(e)}") + + return all_todolists + + def search_todolists(self, query=None, project_id=None): + """ + Search all todolists, optionally filtering by name and project. + + Args: + query (str, optional): Text to search for in todolist names + project_id (int, optional): Specific project ID or None for all projects + + Returns: + list: Filtered list of todolists + """ + todolists = self.get_all_todolists(project_id) + + if query and todolists: + query = query.lower() + todolists = [ + todolist for todolist in todolists + if query in todolist.get('name', '').lower() or + query in (todolist.get('description') or '').lower() + ] + + return todolists + + def get_all_todos(self, project_id=None, todolist_id=None, include_completed=False): + """ + Get all todos, with various filtering options. + + Args: + project_id (int, optional): Specific project ID or None for all projects + todolist_id (int, optional): Specific todolist ID or None for all todolists + include_completed (bool): Whether to include completed todos + + Returns: + list: List of todos with project and todolist info + """ + all_todos = [] + + try: + # Case 1: Specific todolist (regardless of project) + if todolist_id: + try: + todolist = self.client.get_todolist(todolist_id) + todos = self.client.get_todos(todolist_id) + + # In Basecamp 3, we need to add project info to the todolist + # Get project ID from the URL + project_links = [link for link in todolist.get('bucket', {}).get('links', []) + if link.get('type') == 'project'] + if project_links: + project_url = project_links[0].get('href', '') + # Extract project ID from URL + parts = project_url.split('/') + if len(parts) > 0: + project_id = parts[-1] + try: + project = self.client.get_project(project_id) + project_name = project.get('name', 'Unknown Project') + except: + project_name = 'Unknown Project' + else: + project_name = 'Unknown Project' + else: + project_name = 'Unknown Project' + + for todo in todos: + if not include_completed and todo.get('completed'): + continue + + todo['project'] = {'id': project_id, 'name': project_name} + todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']} + all_todos.append(todo) + except Exception as e: + logger.error(f"Error getting todos for todolist {todolist_id}: {str(e)}") + + # Case 2: Specific project, all todolists + elif project_id: + project = self.client.get_project(project_id) + todolists = self.client.get_todolists(project_id) + + for todolist in todolists: + try: + todos = self.client.get_todos(todolist['id']) + for todo in todos: + if not include_completed and todo.get('completed'): + continue + + todo['project'] = {'id': project['id'], 'name': project['name']} + todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']} + all_todos.append(todo) + except Exception as e: + logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}") + + # Case 3: All projects + else: + todolists = self.get_all_todolists() + + for todolist in todolists: + try: + todos = self.client.get_todos(todolist['id']) + for todo in todos: + if not include_completed and todo.get('completed'): + continue + + todo['project'] = todolist['project'] + todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']} + all_todos.append(todo) + except Exception as e: + logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}") + except Exception as e: + logger.error(f"Error getting all todos: {str(e)}") + + return all_todos + + def search_todos(self, query=None, project_id=None, todolist_id=None, include_completed=False): + """ + Search all todos, with various filtering options. + + Args: + query (str, optional): Text to search for in todo content + project_id (int, optional): Specific project ID or None for all projects + todolist_id (int, optional): Specific todolist ID or None for all todolists + include_completed (bool): Whether to include completed todos + + Returns: + list: Filtered list of todos + """ + todos = self.get_all_todos(project_id, todolist_id, include_completed) + + if query and todos: + query = query.lower() + # In Basecamp 3, the todo content is in the 'content' field + todos = [ + t for t in todos + if query in t.get('content', '').lower() or + query in (t.get('description') or '').lower() + ] + + return todos + + def search_messages(self, query=None, project_id=None): + """ + Search for messages across all projects or within a specific project. + + Args: + query (str, optional): Search term to filter messages + project_id (int, optional): If provided, only search within this project + + Returns: + list: Matching messages + """ + all_messages = [] + + try: + # Get projects to search in + if project_id: + projects = [self.client.get_project(project_id)] + else: + projects = self.client.get_projects() + + for project in projects: + project_id = project['id'] + logger.info(f"Searching messages in project {project_id} ({project.get('name', 'Unknown')})") + + # Check for message boards in the dock + has_message_board = False + message_boards = [] + + for dock_item in project.get('dock', []): + if dock_item.get('name') == 'message_board' and dock_item.get('enabled', False): + has_message_board = True + message_boards.append(dock_item) + + if not has_message_board: + logger.info(f"Project {project_id} ({project.get('name', 'Unknown')}) has no enabled message boards") + continue + + # Get messages from each message board + for board in message_boards: + board_id = board.get('id') + try: + # First try getting the message board details + logger.info(f"Fetching message board {board_id} for project {project_id}") + board_endpoint = f"buckets/{project_id}/message_boards/{board_id}.json" + board_details = self.client.get(board_endpoint) + + # Then get all messages in the board + logger.info(f"Fetching messages for board {board_id} in project {project_id}") + messages_endpoint = f"buckets/{project_id}/message_boards/{board_id}/messages.json" + messages = self.client.get(messages_endpoint) + + logger.info(f"Found {len(messages)} messages in board {board_id}") + + # Now get detailed content for each message + for message in messages: + try: + message_id = message.get('id') + # Get detailed message content + message_endpoint = f"buckets/{project_id}/messages/{message_id}.json" + detailed_message = self.client.get(message_endpoint) + + # Add project info + detailed_message['project'] = { + 'id': project_id, + 'name': project.get('name', 'Unknown Project') + } + + # Add to results + all_messages.append(detailed_message) + except Exception as e: + logger.error(f"Error getting detailed message {message.get('id', 'unknown')} in project {project_id}: {str(e)}") + # Still include basic message info + message['project'] = { + 'id': project_id, + 'name': project.get('name', 'Unknown Project') + } + all_messages.append(message) + except Exception as e: + logger.error(f"Error getting messages for board {board_id} in project {project_id}: {str(e)}") + + # Try alternate approach: get messages directly for the project + try: + logger.info(f"Trying alternate approach for project {project_id}") + messages = self.client.get_messages(project_id) + + logger.info(f"Found {len(messages)} messages in project {project_id} using direct method") + + # Add project info to each message + for message in messages: + message['project'] = { + 'id': project_id, + 'name': project.get('name', 'Unknown Project') + } + all_messages.append(message) + except Exception as e2: + logger.error(f"Error getting messages directly for project {project_id}: {str(e2)}") + + # Also check for message categories/topics + try: + # Try to get message categories + categories_endpoint = f"buckets/{project_id}/categories.json" + categories = self.client.get(categories_endpoint) + + for category in categories: + category_id = category.get('id') + try: + # Get messages in this category + category_messages_endpoint = f"buckets/{project_id}/categories/{category_id}/messages.json" + category_messages = self.client.get(category_messages_endpoint) + + # Add project and category info + for message in category_messages: + message['project'] = { + 'id': project_id, + 'name': project.get('name', 'Unknown Project') + } + message['category'] = { + 'id': category_id, + 'name': category.get('name', 'Unknown Category') + } + all_messages.append(message) + except Exception as e: + logger.error(f"Error getting messages for category {category_id} in project {project_id}: {str(e)}") + except Exception as e: + logger.info(f"No message categories found for project {project_id}: {str(e)}") + + except Exception as e: + logger.error(f"Error searching messages: {str(e)}") + + # Filter by query if provided + if query and all_messages: + query = query.lower() + filtered_messages = [] + + for message in all_messages: + # Search in multiple fields + content_matched = False + + # Check title/subject + if query in (message.get('subject', '') or '').lower(): + content_matched = True + + # Check content field + if not content_matched and query in (message.get('content', '') or '').lower(): + content_matched = True + + # Check content field with HTML + if not content_matched and 'content' in message: + content_html = message.get('content') + if content_html and query in content_html.lower(): + content_matched = True + + # Check raw content in various formats + if not content_matched: + # Try different content field formats + for field in ['raw_content', 'content_html', 'body', 'description', 'text']: + if field in message and message[field]: + if query in str(message[field]).lower(): + content_matched = True + break + + # Check title field + if not content_matched and 'title' in message and message['title']: + if query in message['title'].lower(): + content_matched = True + + # Check creator's name + if not content_matched and 'creator' in message and message['creator']: + creator = message['creator'] + creator_name = f"{creator.get('name', '')} {creator.get('first_name', '')} {creator.get('last_name', '')}" + if query in creator_name.lower(): + content_matched = True + + # Include if content matched + if content_matched: + filtered_messages.append(message) + + logger.info(f"Found {len(filtered_messages)} messages matching query '{query}' out of {len(all_messages)} total messages") + return filtered_messages + + return all_messages + + def search_schedule_entries(self, query=None, project_id=None): + """ + Search schedule entries across projects or in a specific project. + + Args: + query (str, optional): Search term to filter schedule entries + project_id (int, optional): Specific project ID to search in + + Returns: + list: Matching schedule entries + """ + try: + # Get the schedule entries (from all projects or a specific one) + if project_id: + entries = self.client.get_schedule_entries(project_id) + entries = entries.json() if hasattr(entries, 'json') else entries + else: + # Get all projects first + projects = self.client.get_projects() + + # Then get schedule entries from each + entries = [] + for project in projects: + project_entries = self.client.get_schedule_entries(project['id']) + project_entries = project_entries.json() if hasattr(project_entries, 'json') else project_entries + if project_entries: + for entry in project_entries: + entry['project'] = { + 'id': project['id'], + 'name': project['name'] + } + entries.extend(project_entries) + + # Filter by query if provided + if query and entries: + query = query.lower() + entries = [ + entry for entry in entries + if query in entry.get('title', '').lower() or + query in (entry.get('description') or '').lower() or + (entry.get('creator') and query in entry['creator'].get('name', '').lower()) + ] + + return entries + except Exception as e: + logger.error(f"Error searching schedule entries: {str(e)}") + return [] + + def search_comments(self, query=None, recording_id=None, bucket_id=None): + """ + Search for comments across resources or for a specific resource. + + Args: + query (str, optional): Search term to filter comments + recording_id (int, optional): ID of the recording (todo, message, etc.) to search in + bucket_id (int, optional): Project/bucket ID + + Returns: + list: Matching comments + """ + try: + # If both recording_id and bucket_id are provided, get comments for that specific recording + if recording_id and bucket_id: + comments = self.client.get_comments(recording_id, bucket_id) + # Otherwise we can't search across all comments as there's no endpoint for that + else: + logger.warning("Cannot search all comments across Basecamp - both recording_id and bucket_id are required") + return [{ + "content": "To search comments, you need to specify both a recording ID (todo, message, etc.) and a bucket ID. Comments cannot be searched globally in Basecamp.", + "api_limitation": True, + "title": "Comment Search Limitation" + }] + + # Filter by query if provided + if query and comments: + query = query.lower() + + filtered_comments = [] + for comment in comments: + # Check content + content_matched = False + content = comment.get('content', '') + if content and query in content.lower(): + content_matched = True + + # Check creator name + if not content_matched and comment.get('creator'): + creator_name = comment['creator'].get('name', '') + if creator_name and query in creator_name.lower(): + content_matched = True + + # If matched, add to results + if content_matched: + filtered_comments.append(comment) + + comments = filtered_comments + + return comments + except Exception as e: + logger.error(f"Error searching comments: {str(e)}") + return [] \ No newline at end of file diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..63bae2f --- /dev/null +++ b/setup.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Exit on error +set -e + +echo "Setting up Basecamp API integration..." + +# Create virtual environment +echo "Creating virtual environment..." +python3 -m venv venv + +# Activate virtual environment +echo "Activating virtual environment..." +source venv/bin/activate + +# Install dependencies +echo "Installing dependencies..." +pip install -r requirements.txt + +# Set up .env file if it doesn't exist +if [ ! -f .env ]; then + echo "Creating .env file from template..." + cp .env.example .env + echo "Please edit .env with your Basecamp credentials" +fi + +echo "" +echo "Setup complete!" +echo "" +echo "To activate the virtual environment, run:" +echo " source venv/bin/activate" +echo "" +echo "To test your Basecamp connection, run:" +echo " python basecamp_cli.py projects" +echo "" \ No newline at end of file diff --git a/start_basecamp_mcp.sh b/start_basecamp_mcp.sh new file mode 100755 index 0000000..c4214ec --- /dev/null +++ b/start_basecamp_mcp.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +echo "Starting Basecamp MCP integration..." + +# Kill any existing processes +echo "Stopping any existing servers..." +pkill -f "python oauth_app.py" 2>/dev/null || true +pkill -f "python mcp_server.py" 2>/dev/null || true +sleep 1 + +# Check if virtual environment exists +if [ -d "venv" ]; then + echo "Activating virtual environment..." + source venv/bin/activate +fi + +# Start the OAuth app +echo "Starting OAuth app on port 8000..." +nohup python oauth_app.py > oauth_app.log 2>&1 < /dev/null & +OAUTH_PID=$! +echo "OAuth app started with PID: $OAUTH_PID" + +# Wait a bit for OAuth app to start +sleep 2 + +# Start the MCP server +echo "Starting MCP server on port 5001..." +nohup python mcp_server.py > mcp_server.log 2>&1 < /dev/null & +MCP_PID=$! +echo "MCP server started with PID: $MCP_PID" + +echo "" +echo "Basecamp MCP integration is now running:" +echo "- OAuth app: http://localhost:8000" +echo "- MCP server: http://localhost:5001" +echo "" +echo "To stop the servers, run: pkill -f 'python oauth_app.py' && pkill -f 'python mcp_server.py'" +echo "" +echo "To check server logs, run:" +echo "- OAuth app logs: tail -f oauth_app.log" +echo "- MCP server logs: tail -f mcp_server.log" +echo "" +echo "To use with Cursor, configure a new MCP server with URL: http://localhost:5001" \ No newline at end of file diff --git a/token_storage.py b/token_storage.py new file mode 100644 index 0000000..9308065 --- /dev/null +++ b/token_storage.py @@ -0,0 +1,119 @@ +""" +Token storage module for securely storing OAuth tokens. + +This module provides a simple interface for storing and retrieving OAuth tokens. +In a production environment, this should be replaced with a more secure solution +like a database or a secure token storage service. +""" + +import os +import json +import threading +from datetime import datetime, timedelta + +# Token storage file - in production, use a database instead +TOKEN_FILE = 'oauth_tokens.json' + +# Lock for thread-safe operations +_lock = threading.Lock() + +def _read_tokens(): + """Read tokens from storage.""" + try: + with open(TOKEN_FILE, 'r') as f: + return json.load(f) + except FileNotFoundError: + return {} # Return empty dict if file doesn't exist + except json.JSONDecodeError: + # If file exists but isn't valid JSON, return empty dict + return {} + +def _write_tokens(tokens): + """Write tokens to storage.""" + # Create directory for the token file if it doesn't exist + os.makedirs(os.path.dirname(TOKEN_FILE) if os.path.dirname(TOKEN_FILE) else '.', exist_ok=True) + + # Set secure permissions on the file + with open(TOKEN_FILE, 'w') as f: + json.dump(tokens, f, indent=2) + + # Set permissions to only allow the current user to read/write + try: + os.chmod(TOKEN_FILE, 0o600) + except Exception: + pass # Ignore if chmod fails (might be on Windows) + +def store_token(access_token, refresh_token=None, expires_in=None, account_id=None): + """ + Store OAuth tokens securely. + + Args: + access_token (str): The OAuth access token + refresh_token (str, optional): The OAuth refresh token + expires_in (int, optional): Token expiration time in seconds + account_id (str, optional): The Basecamp account ID + + Returns: + bool: True if the token was stored successfully + """ + if not access_token: + return False # Don't store empty tokens + + with _lock: + tokens = _read_tokens() + + # Calculate expiration time + expires_at = None + if expires_in: + expires_at = (datetime.now() + timedelta(seconds=expires_in)).isoformat() + + # Store the token with metadata + tokens['basecamp'] = { + 'access_token': access_token, + 'refresh_token': refresh_token, + 'account_id': account_id, + 'expires_at': expires_at, + 'updated_at': datetime.now().isoformat() + } + + _write_tokens(tokens) + return True + +def get_token(): + """ + Get the stored OAuth token. + + Returns: + dict: Token information or None if not found + """ + with _lock: + tokens = _read_tokens() + return tokens.get('basecamp') + +def is_token_expired(): + """ + Check if the stored token is expired. + + Returns: + bool: True if the token is expired or not found + """ + with _lock: + tokens = _read_tokens() + token_data = tokens.get('basecamp') + + if not token_data or not token_data.get('expires_at'): + return True + + try: + expires_at = datetime.fromisoformat(token_data['expires_at']) + # Add a buffer of 5 minutes to account for clock differences + return datetime.now() > (expires_at - timedelta(minutes=5)) + except (ValueError, TypeError): + return True + +def clear_tokens(): + """Clear all stored tokens.""" + with _lock: + if os.path.exists(TOKEN_FILE): + os.remove(TOKEN_FILE) + return True \ No newline at end of file