diff --git a/.env.example b/.env.example index 2e3ee58..aa52339 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,4 @@ 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 +BASECAMP_REFRESH_TOKEN= diff --git a/.gitignore b/.gitignore index af671d8..ccbf6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,4 @@ icons/ .idea/ .vscode/ *.swp -*.swo \ No newline at end of file +*.swo diff --git a/Claude_MCP_integration_instructions.md b/Claude_MCP_integration_instructions.md index 1217507..94439d9 100644 --- a/Claude_MCP_integration_instructions.md +++ b/Claude_MCP_integration_instructions.md @@ -138,4 +138,4 @@ Claude's servers need to be able to reach your MCP server over the internet. 5. **Firewall/Network:** * If deploying to a server with a firewall, ensure the port your MCP server is running on (e.g., 5001) allows inbound HTTPS traffic. -By following these steps, you should be able to successfully integrate your Basecamp MCP server with Anthropic Claude, empowering Claude with your custom Basecamp functionalities. \ No newline at end of file +By following these steps, you should be able to successfully integrate your Basecamp MCP server with Anthropic Claude, empowering Claude with your custom Basecamp functionalities. diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index 733265d..860b7d8 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -69,4 +69,4 @@ To further improve this implementation: - 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 +This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol. diff --git a/README.md b/README.md index e1aacd4..6377799 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3, ### Prerequisites - Python 3.7+ -- A Basecamp 3 account +- A Basecamp 3 account - A Basecamp OAuth application (create one at https://launchpad.37signals.com/integrations) ### Step-by-Step Instructions @@ -42,10 +42,10 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3, ```bash python generate_cursor_config.py ``` - + This script will: - Generate the correct MCP configuration with full paths - - Automatically detect your virtual environment + - Automatically detect your virtual environment - Include the BASECAMP_ACCOUNT_ID environment variable - Update your Cursor configuration file automatically @@ -71,7 +71,7 @@ python -m pytest tests/ -v Once configured, you can use these tools in Cursor: - `get_projects` - Get all Basecamp projects -- `get_project` - Get details for a specific project +- `get_project` - Get details for a specific project - `get_todolists` - Get todo lists for a project - `get_todos` - Get todos from a todo list - `search_basecamp` - Search across projects, todos, and messages @@ -90,7 +90,7 @@ Ask Cursor things like: The project consists of: 1. **OAuth App** (`oauth_app.py`) - Handles OAuth 2.0 flow with Basecamp -2. **MCP Server** (`mcp_server_cli.py`) - Implements MCP protocol for Cursor +2. **MCP Server** (`mcp_server_cli.py`) - Implements MCP protocol for Cursor 3. **Token Storage** (`token_storage.py`) - Securely stores OAuth tokens 4. **Basecamp Client** (`basecamp_client.py`) - Basecamp API client library 5. **Search Utilities** (`search_utils.py`) - Search across Basecamp resources @@ -108,7 +108,7 @@ The project consists of: If automatic configuration doesn't work, manually edit your Cursor MCP configuration: -**On macOS/Linux:** `~/.cursor/mcp.json` +**On macOS/Linux:** `~/.cursor/mcp.json` **On Windows:** `%APPDATA%\Cursor\mcp.json` ```json @@ -152,7 +152,7 @@ Based on [Cursor community forums](https://forum.cursor.com/t/mcp-servers-no-too ## License -MIT License - see LICENSE file for details. +MIT License - see LICENSE file for details. ## Recent Changes @@ -166,7 +166,7 @@ MIT License - see LICENSE file for details. - Improved error handling and response formatting across all action handlers - Fixed CORS support by adding the Flask-CORS package -These changes ensure more reliable and consistent responses from the MCP server, particularly for Campfire chat functionality. +These changes ensure more reliable and consistent responses from the MCP server, particularly for Campfire chat functionality. ### March 9, 2024 - Added Local Testing @@ -255,7 +255,7 @@ TODO: To test the integration without connecting to Composio: -d '{"tool": "GET_PROJECTS", "params": {}}' ``` -TODO: For more detailed documentation on Composio integration, refer to the [official Composio documentation](https://docs.composio.dev). +TODO: For more detailed documentation on Composio integration, refer to the [official Composio documentation](https://docs.composio.dev). ## Quick Setup for Cursor @@ -291,7 +291,7 @@ TODO: For more detailed documentation on Composio integration, refer to the [off ```bash python generate_cursor_config.py ``` - + This script will: - Generate the correct MCP configuration with full paths - Automatically detect your virtual environment @@ -323,7 +323,7 @@ Based on the [Cursor community forum](https://forum.cursor.com/t/mcp-servers-no- If the automatic configuration doesn't work, manually edit your Cursor MCP configuration: -**On macOS/Linux:** `~/.cursor/mcp.json` +**On macOS/Linux:** `~/.cursor/mcp.json` **On Windows:** `%APPDATA%\Cursor\mcp.json` ```json @@ -366,4 +366,4 @@ The key fixes that resolve the "yellow indicator" and "tool not found" issues me 1. **Full Python executable path** instead of just "python" 2. **Environment variables** for PYTHONPATH and VIRTUAL_ENV 3. **Proper MCP protocol handling** including the 'initialized' notification -4. **Absolute paths** for all configuration values \ No newline at end of file +4. **Absolute paths** for all configuration values diff --git a/basecamp_client.py b/basecamp_client.py index 750cb86..e64f8d0 100644 --- a/basecamp_client.py +++ b/basecamp_client.py @@ -6,12 +6,12 @@ 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, + + 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 @@ -22,44 +22,44 @@ class BasecampClient: """ # 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') @@ -67,27 +67,27 @@ class BasecampClient: 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.""" @@ -96,7 +96,7 @@ class BasecampClient: 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') @@ -104,7 +104,7 @@ class BasecampClient: 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).""" @@ -113,20 +113,20 @@ class BasecampClient: 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') @@ -134,7 +134,7 @@ class BasecampClient: 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.""" @@ -143,7 +143,7 @@ class BasecampClient: 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') @@ -151,7 +151,7 @@ class BasecampClient: 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.""" @@ -160,7 +160,7 @@ class BasecampClient: 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.""" @@ -169,7 +169,7 @@ class BasecampClient: return response.json() else: raise Exception(f"Failed to get campfire: {response.status_code} - {response.text}") - + def get_campfire_lines(self, project_id, campfire_id): """Get chat lines from a campfire.""" response = self.get(f'buckets/{project_id}/chats/{campfire_id}/lines.json') @@ -177,7 +177,7 @@ class BasecampClient: return response.json() else: raise Exception(f"Failed to get campfire lines: {response.status_code} - {response.text}") - + # Message board methods def get_message_board(self, project_id): """Get the message board for a project.""" @@ -186,20 +186,20 @@ class BasecampClient: 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.""" @@ -208,21 +208,21 @@ class BasecampClient: 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" @@ -231,39 +231,39 @@ class BasecampClient: 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 """ @@ -274,15 +274,15 @@ class BasecampClient: 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 """ @@ -292,16 +292,16 @@ class BasecampClient: 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 """ @@ -312,15 +312,15 @@ class BasecampClient: 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 """ @@ -329,4 +329,4 @@ class BasecampClient: 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 + raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}") diff --git a/basecamp_oauth.py b/basecamp_oauth.py index 03ba687..5757eaa 100644 --- a/basecamp_oauth.py +++ b/basecamp_oauth.py @@ -22,24 +22,24 @@ 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 """ @@ -48,19 +48,19 @@ class BasecampOAuth: '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 """ @@ -71,25 +71,25 @@ class BasecampOAuth: '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 """ @@ -99,25 +99,25 @@ class BasecampOAuth: '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 """ @@ -125,10 +125,10 @@ class BasecampOAuth: '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 + raise Exception(f"Failed to get identity: {response.status_code} - {response.text}") diff --git a/generate_cursor_config.py b/generate_cursor_config.py index fa911ad..69f2a62 100644 --- a/generate_cursor_config.py +++ b/generate_cursor_config.py @@ -17,10 +17,10 @@ def get_python_path(): """Get the path to the Python executable in the virtual environment.""" project_root = get_project_root() venv_python = os.path.join(project_root, "venv", "bin", "python") - + if os.path.exists(venv_python): return venv_python - + # Fallback to system Python return sys.executable @@ -30,7 +30,7 @@ def generate_config(): python_path = get_python_path() # Use absolute path to the MCP CLI script to avoid double-slash issues script_path = os.path.join(project_root, "mcp_server_cli.py") - + # Load .env file from project root to get BASECAMP_ACCOUNT_ID dotenv_path = os.path.join(project_root, ".env") load_dotenv(dotenv_path) @@ -56,13 +56,13 @@ def generate_config(): } } } - + return config def get_cursor_config_path(): """Get the path to the Cursor MCP configuration file.""" home = Path.home() - + if sys.platform == "darwin": # macOS return home / ".cursor" / "mcp.json" elif sys.platform == "win32": # Windows @@ -74,59 +74,59 @@ def main(): """Main function.""" config = generate_config() config_path = get_cursor_config_path() - + print("🔧 Generated Cursor MCP Configuration:") print(json.dumps(config, indent=2)) print() - + print(f"📁 Configuration should be saved to: {config_path}") print() - + # Check if the file exists and offer to update it if config_path.exists(): print("⚠️ Configuration file already exists.") response = input("Do you want to update it? (y/N): ").lower().strip() - + if response in ['y', 'yes']: # Read existing config try: with open(config_path, 'r') as f: existing_config = json.load(f) - + # Update the basecamp server configuration if "mcpServers" not in existing_config: existing_config["mcpServers"] = {} - + existing_config["mcpServers"]["basecamp"] = config["mcpServers"]["basecamp"] - + # Write back the updated config config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, 'w') as f: json.dump(existing_config, f, indent=2) - + print("✅ Configuration updated successfully!") - + except Exception as e: print(f"❌ Error updating configuration: {e}") - + else: print("Configuration not updated.") else: response = input("Do you want to create the configuration file? (y/N): ").lower().strip() - + if response in ['y', 'yes']: try: config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, 'w') as f: json.dump(config, f, indent=2) - + print("✅ Configuration file created successfully!") - + except Exception as e: print(f"❌ Error creating configuration file: {e}") else: print("Configuration file not created.") - + print() print("📋 Next steps:") print("1. Make sure you've authenticated with Basecamp: python oauth_app.py") @@ -134,4 +134,4 @@ def main(): print("3. Check Cursor Settings → MCP for a green checkmark next to 'basecamp'") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/mcp_server_cli.py b/mcp_server_cli.py index 122af21..2c08c96 100755 --- a/mcp_server_cli.py +++ b/mcp_server_cli.py @@ -37,11 +37,11 @@ logger = logging.getLogger('mcp_cli_server') class MCPServer: """MCP server implementing the Model Context Protocol for Cursor.""" - + def __init__(self): self.tools = self._get_available_tools() logger.info("MCP CLI Server initialized") - + def _get_available_tools(self) -> List[Dict[str, Any]]: """Get list of available tools for Basecamp.""" return [ @@ -58,7 +58,7 @@ class MCPServer: "name": "get_project", "description": "Get details for a specific project", "inputSchema": { - "type": "object", + "type": "object", "properties": { "project_id": {"type": "string", "description": "The project ID"} }, @@ -124,34 +124,34 @@ class MCPServer: } } ] - + def _get_basecamp_client(self) -> Optional[BasecampClient]: """Get authenticated Basecamp client.""" try: token_data = token_storage.get_token() logger.debug(f"Token data retrieved: {token_data}") - + if not token_data or not token_data.get('access_token'): logger.error("No OAuth token available") return None - + # Check if token is expired if token_storage.is_token_expired(): logger.error("OAuth token has expired") return None - + # Get account_id from token data first, then fall back to env var account_id = token_data.get('account_id') or os.getenv('BASECAMP_ACCOUNT_ID') - + # Set a default user agent if none is provided user_agent = os.getenv('USER_AGENT') or "Basecamp MCP Server (cursor@example.com)" - + if not account_id: logger.error(f"Missing account_id. Token data: {token_data}, Env BASECAMP_ACCOUNT_ID: {os.getenv('BASECAMP_ACCOUNT_ID')}") return None - + logger.debug(f"Creating Basecamp client with account_id: {account_id}, user_agent: {user_agent}") - + return BasecampClient( access_token=token_data['access_token'], account_id=account_id, @@ -161,7 +161,7 @@ class MCPServer: except Exception as e: logger.error(f"Error creating Basecamp client: {e}") return None - + def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]: """Handle an MCP request.""" method = request.get("method") @@ -169,9 +169,9 @@ class MCPServer: method_lower = method.lower() if isinstance(method, str) else '' params = request.get("params", {}) request_id = request.get("id") - + logger.info(f"Handling request: {method}") - + try: if method_lower == "initialize": return { @@ -188,12 +188,12 @@ class MCPServer: } } } - + elif method_lower == "initialized": # This is a notification, no response needed logger.info("Received initialized notification") return None - + elif method_lower in ("tools/list", "listtools"): return { "jsonrpc": "2.0", @@ -202,13 +202,13 @@ class MCPServer: "tools": self.tools } } - + elif method_lower in ("tools/call", "toolscall"): tool_name = params.get("name") arguments = params.get("arguments", {}) - + result = self._execute_tool(tool_name, arguments) - + return { "jsonrpc": "2.0", "id": request_id, @@ -221,7 +221,7 @@ class MCPServer: ] } } - + elif method_lower in ("listofferings", "list_offerings", "loffering"): # Respond to Cursor's ListOfferings UI request offerings = [] @@ -238,7 +238,7 @@ class MCPServer: "offerings": offerings } } - + elif method_lower == "ping": # Handle ping requests return { @@ -246,7 +246,7 @@ class MCPServer: "id": request_id, "result": {} } - + else: return { "jsonrpc": "2.0", @@ -256,7 +256,7 @@ class MCPServer: "message": f"Method not found: {method}" } } - + except Exception as e: logger.error(f"Error handling request: {e}") return { @@ -267,7 +267,7 @@ class MCPServer: "message": f"Internal error: {str(e)}" } } - + 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() @@ -283,7 +283,7 @@ class MCPServer: "error": "Authentication required", "message": "Please authenticate with Basecamp first. Visit http://localhost:8000 to log in." } - + try: if tool_name == "get_projects": projects = client.get_projects() @@ -292,7 +292,7 @@ class MCPServer: "projects": projects, "count": len(projects) } - + elif tool_name == "get_project": project_id = arguments.get("project_id") project = client.get_project(project_id) @@ -300,7 +300,7 @@ class MCPServer: "status": "success", "project": project } - + elif tool_name == "get_todolists": project_id = arguments.get("project_id") todolists = client.get_todolists(project_id) @@ -309,7 +309,7 @@ class MCPServer: "todolists": todolists, "count": len(todolists) } - + elif tool_name == "get_todos": todolist_id = arguments.get("todolist_id") todos = client.get_todos(todolist_id) @@ -318,14 +318,14 @@ class MCPServer: "todos": todos, "count": len(todos) } - + elif tool_name == "search_basecamp": query = arguments.get("query") project_id = arguments.get("project_id") - + search = BasecampSearch(client=client) results = {} - + if project_id: # Search within specific project results["todolists"] = search.search_todolists(query, project_id) @@ -335,13 +335,13 @@ class MCPServer: results["projects"] = search.search_projects(query) results["todos"] = search.search_todos(query) results["messages"] = search.search_messages(query) - + return { "status": "success", "query": query, "results": results } - + elif tool_name == "get_comments": recording_id = arguments.get("recording_id") bucket_id = arguments.get("bucket_id") @@ -351,7 +351,7 @@ class MCPServer: "comments": comments, "count": len(comments) } - + elif tool_name == "get_campfire_lines": project_id = arguments.get("project_id") campfire_id = arguments.get("campfire_id") @@ -361,13 +361,13 @@ class MCPServer: "campfire_lines": lines, "count": len(lines) } - + else: return { "error": "Unknown tool", "message": f"Tool '{tool_name}' is not supported" } - + except Exception as e: logger.error(f"Error executing tool {tool_name}: {e}") # Check if it's a 401 error (token expired during API call) @@ -380,24 +380,24 @@ class MCPServer: "error": "Execution error", "message": str(e) } - + def run(self): """Run the MCP server, reading from stdin and writing to stdout.""" logger.info("Starting MCP CLI server") - + for line in sys.stdin: try: line = line.strip() if not line: continue - + request = json.loads(line) response = self.handle_request(request) - + # Write response to stdout (only if there's a response) if response is not None: print(json.dumps(response), flush=True) - + except json.JSONDecodeError as e: logger.error(f"Invalid JSON received: {e}") error_response = { @@ -409,11 +409,11 @@ class MCPServer: } } print(json.dumps(error_response), flush=True) - + except Exception as e: logger.error(f"Unexpected error: {e}") error_response = { - "jsonrpc": "2.0", + "jsonrpc": "2.0", "id": None, "error": { "code": -32603, @@ -424,4 +424,4 @@ class MCPServer: if __name__ == "__main__": server = MCPServer() - server.run() \ No newline at end of file + server.run() diff --git a/oauth_app.py b/oauth_app.py index 777a568..6e44b2b 100644 --- a/oauth_app.py +++ b/oauth_app.py @@ -56,13 +56,13 @@ RESULTS_TEMPLATE = """ body { font-family: Arial, sans-serif; margin: 20px; } h1 { color: #333; } pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; } - .button { - display: inline-block; - background-color: #4CAF50; - color: white; - padding: 10px 20px; - text-decoration: none; - border-radius: 5px; + .button { + display: inline-block; + background-color: #4CAF50; + color: white; + padding: 10px 20px; + text-decoration: none; + border-radius: 5px; margin-top: 20px; } .container { max-width: 1000px; margin: 0 auto; } @@ -109,9 +109,9 @@ def get_oauth_client(): 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, @@ -127,13 +127,13 @@ 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'), @@ -141,9 +141,9 @@ def home(): "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", @@ -156,9 +156,9 @@ def home(): 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", @@ -177,10 +177,10 @@ def home(): 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( @@ -189,7 +189,7 @@ def auth_callback(): message=f"Basecamp returned an error: {error}", show_home=True ) - + if not code: logger.error("OAuth callback: No code provided") return render_template_string( @@ -198,20 +198,20 @@ def auth_callback(): 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) 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') - + if not access_token: logger.error("OAuth exchange: No access token received") return render_template_string( @@ -220,14 +220,14 @@ def auth_callback(): 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']: @@ -238,7 +238,7 @@ def auth_callback(): 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, @@ -246,7 +246,7 @@ def auth_callback(): expires_in=expires_in, account_id=account_id ) - + if not stored: logger.error("Failed to store token") return render_template_string( @@ -255,16 +255,16 @@ def auth_callback(): 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) @@ -282,7 +282,7 @@ def get_token_api(): 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') @@ -292,7 +292,7 @@ def get_token_api(): "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") @@ -300,7 +300,7 @@ def get_token_api(): "error": "Not authenticated", "message": "No valid token available" }), 404 - + logger.info("Token API: Successfully returned token") return jsonify({ "access_token": token_data['access_token'], @@ -320,7 +320,7 @@ 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( @@ -329,14 +329,14 @@ def token_info(): 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), @@ -344,7 +344,7 @@ def token_info(): "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, @@ -367,12 +367,12 @@ if __name__ == '__main__': 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 + sys.exit(1) diff --git a/requirements.txt b/requirements.txt index b939825..c86a07f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ requests==2.31.0 python-dotenv==1.0.0 flask==2.3.3 flask-cors==4.0.0 -pytest==7.4.0 \ No newline at end of file +pytest==7.4.0 diff --git a/search_utils.py b/search_utils.py index 9bf266f..1b44b3e 100644 --- a/search_utils.py +++ b/search_utils.py @@ -10,65 +10,65 @@ 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 + 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: @@ -80,56 +80,56 @@ class BasecampSearch: 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 + 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', []) + 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', '') @@ -146,46 +146,46 @@ class BasecampSearch: 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) @@ -193,72 +193,72 @@ class BasecampSearch: 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 + 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') @@ -267,14 +267,14 @@ class BasecampSearch: 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: @@ -282,13 +282,13 @@ class BasecampSearch: # 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: @@ -301,14 +301,14 @@ class BasecampSearch: 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'] = { @@ -318,20 +318,20 @@ class BasecampSearch: 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'] = { @@ -347,33 +347,33 @@ class BasecampSearch: 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 @@ -382,36 +382,36 @@ class BasecampSearch: 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 """ @@ -423,7 +423,7 @@ class BasecampSearch: else: # Get all projects first projects = self.client.get_projects() - + # Then get schedule entries from each entries = [] for project in projects: @@ -436,7 +436,7 @@ class BasecampSearch: 'name': project['name'] } entries.extend(project_entries) - + # Filter by query if provided if query and entries: query = query.lower() @@ -446,21 +446,21 @@ class BasecampSearch: 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 """ @@ -476,11 +476,11 @@ class BasecampSearch: "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 @@ -488,33 +488,33 @@ class BasecampSearch: 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) - + return filtered_comments - + return comments except Exception as e: logger.error(f"Error searching comments: {str(e)}") return [] - + def search_campfire_lines(self, query=None, project_id=None, campfire_id=None): """ Search for lines in campfire chats. - + Args: query (str, optional): Search term to filter lines project_id (int, optional): Project ID campfire_id (int, optional): Campfire ID - + Returns: list: Matching chat lines """ @@ -526,12 +526,12 @@ class BasecampSearch: "api_limitation": True, "title": "Campfire Search Limitation" }] - + lines = self.client.get_campfire_lines(project_id, campfire_id) - + if query and lines: query = query.lower() - + filtered_lines = [] for line in lines: # Check content @@ -539,20 +539,20 @@ class BasecampSearch: content = line.get('content', '') if content and query in content.lower(): content_matched = True - + # Check creator name if not content_matched and line.get('creator'): creator_name = line['creator'].get('name', '') if creator_name and query in creator_name.lower(): content_matched = True - + # If matched, add to results if content_matched: filtered_lines.append(line) - + return filtered_lines - + return lines except Exception as e: logger.error(f"Error searching campfire lines: {str(e)}") - return [] \ No newline at end of file + return [] diff --git a/tests/__init__.py b/tests/__init__.py index 5998a07..d4839a6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -# Tests package \ No newline at end of file +# Tests package diff --git a/tests/test_cli_server.py b/tests/test_cli_server.py index 50a7a2f..86f9792 100644 --- a/tests/test_cli_server.py +++ b/tests/test_cli_server.py @@ -18,7 +18,7 @@ def test_cli_server_initialize(): "method": "initialize", "params": {} } - + # Start the CLI server process proc = subprocess.Popen( [sys.executable, "mcp_server_cli.py"], @@ -27,17 +27,17 @@ def test_cli_server_initialize(): stderr=subprocess.PIPE, text=True ) - + try: # Send the request stdout, stderr = proc.communicate( input=json.dumps(request) + "\n", timeout=10 ) - + # Parse the response response = json.loads(stdout.strip()) - + # Check the response assert response["jsonrpc"] == "2.0" assert response["id"] == 1 @@ -45,7 +45,7 @@ def test_cli_server_initialize(): assert "protocolVersion" in response["result"] assert "capabilities" in response["result"] assert "serverInfo" in response["result"] - + finally: if proc.poll() is None: proc.terminate() @@ -59,14 +59,14 @@ def test_cli_server_tools_list(): "method": "initialize", "params": {} } - + tools_request = { "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} } - + # Start the CLI server process proc = subprocess.Popen( [sys.executable, "mcp_server_cli.py"], @@ -75,7 +75,7 @@ def test_cli_server_tools_list(): stderr=subprocess.PIPE, text=True ) - + try: # Send both requests input_data = json.dumps(init_request) + "\n" + json.dumps(tools_request) + "\n" @@ -83,29 +83,29 @@ def test_cli_server_tools_list(): input=input_data, timeout=10 ) - + # Parse responses (we get two lines) lines = stdout.strip().split('\n') assert len(lines) >= 2 - + # Check the tools list response (second response) tools_response = json.loads(lines[1]) - + assert tools_response["jsonrpc"] == "2.0" assert tools_response["id"] == 2 assert "result" in tools_response assert "tools" in tools_response["result"] - + tools = tools_response["result"]["tools"] assert isinstance(tools, list) assert len(tools) > 0 - + # Check that expected tools are present tool_names = [tool["name"] for tool in tools] expected_tools = ["get_projects", "search_basecamp", "get_todos"] for expected_tool in expected_tools: assert expected_tool in tool_names - + finally: if proc.poll() is None: proc.terminate() @@ -113,9 +113,9 @@ def test_cli_server_tools_list(): @patch.object(token_storage, 'get_token') def test_cli_server_tool_call_no_auth(mock_get_token): """Test tool call when not authenticated.""" - # Note: The mock doesn't work across processes, so this test checks + # Note: The mock doesn't work across processes, so this test checks # that the CLI server handles authentication errors gracefully - + # Create requests init_request = { "jsonrpc": "2.0", @@ -123,7 +123,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token): "method": "initialize", "params": {} } - + tool_request = { "jsonrpc": "2.0", "id": 2, @@ -133,7 +133,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token): "arguments": {} } } - + # Start the CLI server process proc = subprocess.Popen( [sys.executable, "mcp_server_cli.py"], @@ -142,7 +142,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token): stderr=subprocess.PIPE, text=True ) - + try: # Send both requests input_data = json.dumps(init_request) + "\n" + json.dumps(tool_request) + "\n" @@ -150,27 +150,27 @@ def test_cli_server_tool_call_no_auth(mock_get_token): input=input_data, timeout=10 ) - + # Parse responses lines = stdout.strip().split('\n') assert len(lines) >= 2 - + # Check the tool call response (second response) tool_response = json.loads(lines[1]) - + assert tool_response["jsonrpc"] == "2.0" assert tool_response["id"] == 2 assert "result" in tool_response assert "content" in tool_response["result"] - + # The content should contain some kind of response (either data or error) content_text = tool_response["result"]["content"][0]["text"] content_data = json.loads(content_text) - + # Since we have valid OAuth tokens, this might succeed or fail # We just check that we get a valid JSON response assert isinstance(content_data, dict) - + finally: if proc.poll() is None: proc.terminate() @@ -183,7 +183,7 @@ def test_cli_server_invalid_method(): "method": "invalid_method", "params": {} } - + # Start the CLI server process proc = subprocess.Popen( [sys.executable, "mcp_server_cli.py"], @@ -192,23 +192,23 @@ def test_cli_server_invalid_method(): stderr=subprocess.PIPE, text=True ) - + try: # Send the request stdout, stderr = proc.communicate( input=json.dumps(request) + "\n", timeout=10 ) - + # Parse the response response = json.loads(stdout.strip()) - + # Check the error response assert response["jsonrpc"] == "2.0" assert response["id"] == 1 assert "error" in response assert response["error"]["code"] == -32601 # Method not found - + finally: if proc.poll() is None: - proc.terminate() \ No newline at end of file + proc.terminate() diff --git a/token_storage.py b/token_storage.py index 783ef5a..65605d0 100644 --- a/token_storage.py +++ b/token_storage.py @@ -42,7 +42,7 @@ 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) - + basecamp_data_to_write = tokens.get('basecamp', {}) updated_at_to_write = basecamp_data_to_write.get('updated_at') _logger.info(f"Writing tokens to {TOKEN_FILE}. Basecamp token updated_at to be written: {updated_at_to_write}") @@ -50,7 +50,7 @@ def _write_tokens(tokens): # 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) @@ -60,27 +60,27 @@ def _write_tokens(tokens): 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, @@ -89,14 +89,14 @@ def store_token(access_token, refresh_token=None, expires_in=None, account_id=No '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 """ @@ -107,17 +107,17 @@ def get_token(): 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 @@ -130,4 +130,4 @@ def clear_tokens(): with _lock: if os.path.exists(TOKEN_FILE): os.remove(TOKEN_FILE) - return True \ No newline at end of file + return True