chore: remove trailing spaces and ensure newline

This commit is contained in:
George Antonopoulos
2025-06-06 10:23:50 +01:00
parent 32a0de3bb0
commit 0884dcd105
15 changed files with 343 additions and 343 deletions

View File

@@ -12,4 +12,4 @@ FLASK_SECRET_KEY=your-flask-secret-key
# OAuth tokens (filled automatically by the app) # OAuth tokens (filled automatically by the app)
BASECAMP_ACCESS_TOKEN= BASECAMP_ACCESS_TOKEN=
BASECAMP_REFRESH_TOKEN= BASECAMP_REFRESH_TOKEN=

2
.gitignore vendored
View File

@@ -49,4 +49,4 @@ icons/
.idea/ .idea/
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo

View File

@@ -138,4 +138,4 @@ Claude's servers need to be able to reach your MCP server over the internet.
5. **Firewall/Network:** 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. * 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. 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.

View File

@@ -69,4 +69,4 @@ To further improve this implementation:
- Implement proper token encryption - Implement proper token encryption
- Add audit logging for security events - Add audit logging for security events
This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol. This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol.

View File

@@ -7,7 +7,7 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3,
### Prerequisites ### Prerequisites
- Python 3.7+ - Python 3.7+
- A Basecamp 3 account - A Basecamp 3 account
- A Basecamp OAuth application (create one at https://launchpad.37signals.com/integrations) - A Basecamp OAuth application (create one at https://launchpad.37signals.com/integrations)
### Step-by-Step Instructions ### Step-by-Step Instructions
@@ -42,10 +42,10 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3,
```bash ```bash
python generate_cursor_config.py python generate_cursor_config.py
``` ```
This script will: This script will:
- Generate the correct MCP configuration with full paths - 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 - Include the BASECAMP_ACCOUNT_ID environment variable
- Update your Cursor configuration file automatically - Update your Cursor configuration file automatically
@@ -71,7 +71,7 @@ python -m pytest tests/ -v
Once configured, you can use these tools in Cursor: Once configured, you can use these tools in Cursor:
- `get_projects` - Get all Basecamp projects - `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_todolists` - Get todo lists for a project
- `get_todos` - Get todos from a todo list - `get_todos` - Get todos from a todo list
- `search_basecamp` - Search across projects, todos, and messages - `search_basecamp` - Search across projects, todos, and messages
@@ -90,7 +90,7 @@ Ask Cursor things like:
The project consists of: The project consists of:
1. **OAuth App** (`oauth_app.py`) - Handles OAuth 2.0 flow with Basecamp 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 3. **Token Storage** (`token_storage.py`) - Securely stores OAuth tokens
4. **Basecamp Client** (`basecamp_client.py`) - Basecamp API client library 4. **Basecamp Client** (`basecamp_client.py`) - Basecamp API client library
5. **Search Utilities** (`search_utils.py`) - Search across Basecamp resources 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: 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` **On Windows:** `%APPDATA%\Cursor\mcp.json`
```json ```json
@@ -152,7 +152,7 @@ Based on [Cursor community forums](https://forum.cursor.com/t/mcp-servers-no-too
## License ## License
MIT License - see LICENSE file for details. MIT License - see LICENSE file for details.
## Recent Changes ## Recent Changes
@@ -166,7 +166,7 @@ MIT License - see LICENSE file for details.
- Improved error handling and response formatting across all action handlers - Improved error handling and response formatting across all action handlers
- Fixed CORS support by adding the Flask-CORS package - 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 ### March 9, 2024 - Added Local Testing
@@ -255,7 +255,7 @@ TODO: To test the integration without connecting to Composio:
-d '{"tool": "GET_PROJECTS", "params": {}}' -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 ## Quick Setup for Cursor
@@ -291,7 +291,7 @@ TODO: For more detailed documentation on Composio integration, refer to the [off
```bash ```bash
python generate_cursor_config.py python generate_cursor_config.py
``` ```
This script will: This script will:
- Generate the correct MCP configuration with full paths - Generate the correct MCP configuration with full paths
- Automatically detect your virtual environment - 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: 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` **On Windows:** `%APPDATA%\Cursor\mcp.json`
```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" 1. **Full Python executable path** instead of just "python"
2. **Environment variables** for PYTHONPATH and VIRTUAL_ENV 2. **Environment variables** for PYTHONPATH and VIRTUAL_ENV
3. **Proper MCP protocol handling** including the 'initialized' notification 3. **Proper MCP protocol handling** including the 'initialized' notification
4. **Absolute paths** for all configuration values 4. **Absolute paths** for all configuration values

View File

@@ -6,12 +6,12 @@ class BasecampClient:
""" """
Client for interacting with Basecamp 3 API using Basic Authentication or OAuth 2.0. 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"): access_token=None, auth_mode="basic"):
""" """
Initialize the Basecamp client with credentials. Initialize the Basecamp client with credentials.
Args: Args:
username (str, optional): Basecamp username (email) for Basic Auth username (str, optional): Basecamp username (email) for Basic Auth
password (str, optional): Basecamp password 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 environment variables if not provided directly
load_dotenv() load_dotenv()
self.auth_mode = auth_mode.lower() self.auth_mode = auth_mode.lower()
self.account_id = account_id or os.getenv('BASECAMP_ACCOUNT_ID') self.account_id = account_id or os.getenv('BASECAMP_ACCOUNT_ID')
self.user_agent = user_agent or os.getenv('USER_AGENT') self.user_agent = user_agent or os.getenv('USER_AGENT')
# Set up authentication based on mode # Set up authentication based on mode
if self.auth_mode == 'basic': if self.auth_mode == 'basic':
self.username = username or os.getenv('BASECAMP_USERNAME') self.username = username or os.getenv('BASECAMP_USERNAME')
self.password = password or os.getenv('BASECAMP_PASSWORD') self.password = password or os.getenv('BASECAMP_PASSWORD')
if not all([self.username, self.password, self.account_id, self.user_agent]): 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.") 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.auth = (self.username, self.password)
self.headers = { self.headers = {
"User-Agent": self.user_agent, "User-Agent": self.user_agent,
"Content-Type": "application/json" "Content-Type": "application/json"
} }
elif self.auth_mode == 'oauth': elif self.auth_mode == 'oauth':
self.access_token = access_token or os.getenv('BASECAMP_ACCESS_TOKEN') self.access_token = access_token or os.getenv('BASECAMP_ACCESS_TOKEN')
if not all([self.access_token, self.account_id, self.user_agent]): 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.") 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.auth = None # No basic auth needed for OAuth
self.headers = { self.headers = {
"User-Agent": self.user_agent, "User-Agent": self.user_agent,
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.access_token}" "Authorization": f"Bearer {self.access_token}"
} }
else: else:
raise ValueError("Invalid auth_mode. Must be 'basic' or 'oauth'") raise ValueError("Invalid auth_mode. Must be 'basic' or 'oauth'")
# Basecamp 3 uses a different URL structure # Basecamp 3 uses a different URL structure
self.base_url = f"https://3.basecampapi.com/{self.account_id}" self.base_url = f"https://3.basecampapi.com/{self.account_id}"
def test_connection(self): def test_connection(self):
"""Test the connection to Basecamp API.""" """Test the connection to Basecamp API."""
response = self.get('projects.json') response = self.get('projects.json')
@@ -67,27 +67,27 @@ class BasecampClient:
return True, "Connection successful" return True, "Connection successful"
else: else:
return False, f"Connection failed: {response.status_code} - {response.text}" return False, f"Connection failed: {response.status_code} - {response.text}"
def get(self, endpoint, params=None): def get(self, endpoint, params=None):
"""Make a GET request to the Basecamp API.""" """Make a GET request to the Basecamp API."""
url = f"{self.base_url}/{endpoint}" url = f"{self.base_url}/{endpoint}"
return requests.get(url, auth=self.auth, headers=self.headers, params=params) return requests.get(url, auth=self.auth, headers=self.headers, params=params)
def post(self, endpoint, data=None): def post(self, endpoint, data=None):
"""Make a POST request to the Basecamp API.""" """Make a POST request to the Basecamp API."""
url = f"{self.base_url}/{endpoint}" url = f"{self.base_url}/{endpoint}"
return requests.post(url, auth=self.auth, headers=self.headers, json=data) return requests.post(url, auth=self.auth, headers=self.headers, json=data)
def put(self, endpoint, data=None): def put(self, endpoint, data=None):
"""Make a PUT request to the Basecamp API.""" """Make a PUT request to the Basecamp API."""
url = f"{self.base_url}/{endpoint}" url = f"{self.base_url}/{endpoint}"
return requests.put(url, auth=self.auth, headers=self.headers, json=data) return requests.put(url, auth=self.auth, headers=self.headers, json=data)
def delete(self, endpoint): def delete(self, endpoint):
"""Make a DELETE request to the Basecamp API.""" """Make a DELETE request to the Basecamp API."""
url = f"{self.base_url}/{endpoint}" url = f"{self.base_url}/{endpoint}"
return requests.delete(url, auth=self.auth, headers=self.headers) return requests.delete(url, auth=self.auth, headers=self.headers)
# Project methods # Project methods
def get_projects(self): def get_projects(self):
"""Get all projects.""" """Get all projects."""
@@ -96,7 +96,7 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get projects: {response.status_code} - {response.text}") raise Exception(f"Failed to get projects: {response.status_code} - {response.text}")
def get_project(self, project_id): def get_project(self, project_id):
"""Get a specific project by ID.""" """Get a specific project by ID."""
response = self.get(f'projects/{project_id}.json') response = self.get(f'projects/{project_id}.json')
@@ -104,7 +104,7 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get project: {response.status_code} - {response.text}") raise Exception(f"Failed to get project: {response.status_code} - {response.text}")
# To-do list methods # To-do list methods
def get_todosets(self, project_id): def get_todosets(self, project_id):
"""Get the todoset for a project (Basecamp 3 has one todoset per project).""" """Get the todoset for a project (Basecamp 3 has one todoset per project)."""
@@ -113,20 +113,20 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get todoset: {response.status_code} - {response.text}") raise Exception(f"Failed to get todoset: {response.status_code} - {response.text}")
def get_todolists(self, project_id): def get_todolists(self, project_id):
"""Get all todolists for a project.""" """Get all todolists for a project."""
# First get the todoset ID for this project # First get the todoset ID for this project
todoset = self.get_todosets(project_id) todoset = self.get_todosets(project_id)
todoset_id = todoset['id'] todoset_id = todoset['id']
# Then get all todolists in this todoset # Then get all todolists in this todoset
response = self.get(f'todolists.json', {'todoset_id': todoset_id}) response = self.get(f'todolists.json', {'todoset_id': todoset_id})
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get todolists: {response.status_code} - {response.text}") raise Exception(f"Failed to get todolists: {response.status_code} - {response.text}")
def get_todolist(self, todolist_id): def get_todolist(self, todolist_id):
"""Get a specific todolist.""" """Get a specific todolist."""
response = self.get(f'todolists/{todolist_id}.json') response = self.get(f'todolists/{todolist_id}.json')
@@ -134,7 +134,7 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get todolist: {response.status_code} - {response.text}") raise Exception(f"Failed to get todolist: {response.status_code} - {response.text}")
# To-do methods # To-do methods
def get_todos(self, todolist_id): def get_todos(self, todolist_id):
"""Get all todos in a todolist.""" """Get all todos in a todolist."""
@@ -143,7 +143,7 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get todos: {response.status_code} - {response.text}") raise Exception(f"Failed to get todos: {response.status_code} - {response.text}")
def get_todo(self, todo_id): def get_todo(self, todo_id):
"""Get a specific todo.""" """Get a specific todo."""
response = self.get(f'todos/{todo_id}.json') response = self.get(f'todos/{todo_id}.json')
@@ -151,7 +151,7 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get todo: {response.status_code} - {response.text}") raise Exception(f"Failed to get todo: {response.status_code} - {response.text}")
# People methods # People methods
def get_people(self): def get_people(self):
"""Get all people in the account.""" """Get all people in the account."""
@@ -160,7 +160,7 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get people: {response.status_code} - {response.text}") raise Exception(f"Failed to get people: {response.status_code} - {response.text}")
# Campfire (chat) methods # Campfire (chat) methods
def get_campfires(self, project_id): def get_campfires(self, project_id):
"""Get the campfire for a project.""" """Get the campfire for a project."""
@@ -169,7 +169,7 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get campfire: {response.status_code} - {response.text}") raise Exception(f"Failed to get campfire: {response.status_code} - {response.text}")
def get_campfire_lines(self, project_id, campfire_id): def get_campfire_lines(self, project_id, campfire_id):
"""Get chat lines from a campfire.""" """Get chat lines from a campfire."""
response = self.get(f'buckets/{project_id}/chats/{campfire_id}/lines.json') response = self.get(f'buckets/{project_id}/chats/{campfire_id}/lines.json')
@@ -177,7 +177,7 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get campfire lines: {response.status_code} - {response.text}") raise Exception(f"Failed to get campfire lines: {response.status_code} - {response.text}")
# Message board methods # Message board methods
def get_message_board(self, project_id): def get_message_board(self, project_id):
"""Get the message board for a project.""" """Get the message board for a project."""
@@ -186,20 +186,20 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get message board: {response.status_code} - {response.text}") raise Exception(f"Failed to get message board: {response.status_code} - {response.text}")
def get_messages(self, project_id): def get_messages(self, project_id):
"""Get all messages for a project.""" """Get all messages for a project."""
# First get the message board ID # First get the message board ID
message_board = self.get_message_board(project_id) message_board = self.get_message_board(project_id)
message_board_id = message_board['id'] message_board_id = message_board['id']
# Then get all messages # Then get all messages
response = self.get('messages.json', {'message_board_id': message_board_id}) response = self.get('messages.json', {'message_board_id': message_board_id})
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get messages: {response.status_code} - {response.text}") raise Exception(f"Failed to get messages: {response.status_code} - {response.text}")
# Schedule methods # Schedule methods
def get_schedule(self, project_id): def get_schedule(self, project_id):
"""Get the schedule for a project.""" """Get the schedule for a project."""
@@ -208,21 +208,21 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get schedule: {response.status_code} - {response.text}") raise Exception(f"Failed to get schedule: {response.status_code} - {response.text}")
def get_schedule_entries(self, project_id): def get_schedule_entries(self, project_id):
""" """
Get schedule entries for a project. Get schedule entries for a project.
Args: Args:
project_id (int): Project ID project_id (int): Project ID
Returns: Returns:
list: Schedule entries list: Schedule entries
""" """
try: try:
endpoint = f"buckets/{project_id}/schedules.json" endpoint = f"buckets/{project_id}/schedules.json"
schedule = self.get(endpoint) schedule = self.get(endpoint)
if isinstance(schedule, list) and len(schedule) > 0: if isinstance(schedule, list) and len(schedule) > 0:
schedule_id = schedule[0]['id'] schedule_id = schedule[0]['id']
entries_endpoint = f"buckets/{project_id}/schedules/{schedule_id}/entries.json" entries_endpoint = f"buckets/{project_id}/schedules/{schedule_id}/entries.json"
@@ -231,39 +231,39 @@ class BasecampClient:
return [] return []
except Exception as e: except Exception as e:
raise Exception(f"Failed to get schedule: {str(e)}") raise Exception(f"Failed to get schedule: {str(e)}")
# Comments methods # Comments methods
def get_comments(self, recording_id, bucket_id=None): def get_comments(self, recording_id, bucket_id=None):
""" """
Get all comments for a recording (todo, message, etc.). Get all comments for a recording (todo, message, etc.).
Args: Args:
recording_id (int): ID of the recording (todo, message, etc.) 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. bucket_id (int, optional): Project/bucket ID. If not provided, it will be extracted from the recording ID.
Returns: Returns:
list: Comments for the recording list: Comments for the recording
""" """
if bucket_id is None: if bucket_id is None:
# Try to get the recording first to extract the bucket_id # Try to get the recording first to extract the bucket_id
raise ValueError("bucket_id is required") raise ValueError("bucket_id is required")
endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json" endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json"
response = self.get(endpoint) response = self.get(endpoint)
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get comments: {response.status_code} - {response.text}") raise Exception(f"Failed to get comments: {response.status_code} - {response.text}")
def create_comment(self, recording_id, bucket_id, content): def create_comment(self, recording_id, bucket_id, content):
""" """
Create a comment on a recording. Create a comment on a recording.
Args: Args:
recording_id (int): ID of the recording to comment on recording_id (int): ID of the recording to comment on
bucket_id (int): Project/bucket ID bucket_id (int): Project/bucket ID
content (str): Content of the comment in HTML format content (str): Content of the comment in HTML format
Returns: Returns:
dict: The created comment dict: The created comment
""" """
@@ -274,15 +274,15 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to create comment: {response.status_code} - {response.text}") raise Exception(f"Failed to create comment: {response.status_code} - {response.text}")
def get_comment(self, comment_id, bucket_id): def get_comment(self, comment_id, bucket_id):
""" """
Get a specific comment. Get a specific comment.
Args: Args:
comment_id (int): Comment ID comment_id (int): Comment ID
bucket_id (int): Project/bucket ID bucket_id (int): Project/bucket ID
Returns: Returns:
dict: Comment details dict: Comment details
""" """
@@ -292,16 +292,16 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get comment: {response.status_code} - {response.text}") raise Exception(f"Failed to get comment: {response.status_code} - {response.text}")
def update_comment(self, comment_id, bucket_id, content): def update_comment(self, comment_id, bucket_id, content):
""" """
Update a comment. Update a comment.
Args: Args:
comment_id (int): Comment ID comment_id (int): Comment ID
bucket_id (int): Project/bucket ID bucket_id (int): Project/bucket ID
content (str): New content for the comment in HTML format content (str): New content for the comment in HTML format
Returns: Returns:
dict: Updated comment dict: Updated comment
""" """
@@ -312,15 +312,15 @@ class BasecampClient:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to update comment: {response.status_code} - {response.text}") raise Exception(f"Failed to update comment: {response.status_code} - {response.text}")
def delete_comment(self, comment_id, bucket_id): def delete_comment(self, comment_id, bucket_id):
""" """
Delete a comment. Delete a comment.
Args: Args:
comment_id (int): Comment ID comment_id (int): Comment ID
bucket_id (int): Project/bucket ID bucket_id (int): Project/bucket ID
Returns: Returns:
bool: True if successful bool: True if successful
""" """
@@ -329,4 +329,4 @@ class BasecampClient:
if response.status_code == 204: if response.status_code == 204:
return True return True
else: else:
raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}") raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}")

View File

@@ -22,24 +22,24 @@ class BasecampOAuth:
""" """
OAuth 2.0 client for Basecamp 3. OAuth 2.0 client for Basecamp 3.
""" """
def __init__(self, client_id=None, client_secret=None, redirect_uri=None, user_agent=None): def __init__(self, client_id=None, client_secret=None, redirect_uri=None, user_agent=None):
"""Initialize the OAuth client with credentials.""" """Initialize the OAuth client with credentials."""
self.client_id = client_id or os.getenv('BASECAMP_CLIENT_ID') self.client_id = client_id or os.getenv('BASECAMP_CLIENT_ID')
self.client_secret = client_secret or os.getenv('BASECAMP_CLIENT_SECRET') self.client_secret = client_secret or os.getenv('BASECAMP_CLIENT_SECRET')
self.redirect_uri = redirect_uri or os.getenv('BASECAMP_REDIRECT_URI') self.redirect_uri = redirect_uri or os.getenv('BASECAMP_REDIRECT_URI')
self.user_agent = user_agent or os.getenv('USER_AGENT') 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]): 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.") raise ValueError("Missing required OAuth credentials. Set them in .env file or pass them to the constructor.")
def get_authorization_url(self, state=None): def get_authorization_url(self, state=None):
""" """
Get the URL to redirect the user to for authorization. Get the URL to redirect the user to for authorization.
Args: Args:
state (str, optional): A random string to maintain state between requests state (str, optional): A random string to maintain state between requests
Returns: Returns:
str: The authorization URL str: The authorization URL
""" """
@@ -48,19 +48,19 @@ class BasecampOAuth:
'client_id': self.client_id, 'client_id': self.client_id,
'redirect_uri': self.redirect_uri 'redirect_uri': self.redirect_uri
} }
if state: if state:
params['state'] = state params['state'] = state
return f"{AUTH_URL}?{urlencode(params)}" return f"{AUTH_URL}?{urlencode(params)}"
def exchange_code_for_token(self, code): def exchange_code_for_token(self, code):
""" """
Exchange the authorization code for an access token. Exchange the authorization code for an access token.
Args: Args:
code (str): The authorization code received after user grants permission code (str): The authorization code received after user grants permission
Returns: Returns:
dict: The token response containing access_token and refresh_token dict: The token response containing access_token and refresh_token
""" """
@@ -71,25 +71,25 @@ class BasecampOAuth:
'client_secret': self.client_secret, 'client_secret': self.client_secret,
'code': code 'code': code
} }
headers = { headers = {
'User-Agent': self.user_agent 'User-Agent': self.user_agent
} }
response = requests.post(TOKEN_URL, data=data, headers=headers) response = requests.post(TOKEN_URL, data=data, headers=headers)
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to exchange code for token: {response.status_code} - {response.text}") raise Exception(f"Failed to exchange code for token: {response.status_code} - {response.text}")
def refresh_token(self, refresh_token): def refresh_token(self, refresh_token):
""" """
Refresh an expired access token. Refresh an expired access token.
Args: Args:
refresh_token (str): The refresh token from the original token response refresh_token (str): The refresh token from the original token response
Returns: Returns:
dict: The new token response containing a new access_token dict: The new token response containing a new access_token
""" """
@@ -99,25 +99,25 @@ class BasecampOAuth:
'client_secret': self.client_secret, 'client_secret': self.client_secret,
'refresh_token': refresh_token 'refresh_token': refresh_token
} }
headers = { headers = {
'User-Agent': self.user_agent 'User-Agent': self.user_agent
} }
response = requests.post(TOKEN_URL, data=data, headers=headers) response = requests.post(TOKEN_URL, data=data, headers=headers)
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to refresh token: {response.status_code} - {response.text}") raise Exception(f"Failed to refresh token: {response.status_code} - {response.text}")
def get_identity(self, access_token): def get_identity(self, access_token):
""" """
Get the identity and account information for the authenticated user. Get the identity and account information for the authenticated user.
Args: Args:
access_token (str): The OAuth access token access_token (str): The OAuth access token
Returns: Returns:
dict: The identity and account information dict: The identity and account information
""" """
@@ -125,10 +125,10 @@ class BasecampOAuth:
'User-Agent': self.user_agent, 'User-Agent': self.user_agent,
'Authorization': f"Bearer {access_token}" 'Authorization': f"Bearer {access_token}"
} }
response = requests.get(IDENTITY_URL, headers=headers) response = requests.get(IDENTITY_URL, headers=headers)
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
raise Exception(f"Failed to get identity: {response.status_code} - {response.text}") raise Exception(f"Failed to get identity: {response.status_code} - {response.text}")

View File

@@ -17,10 +17,10 @@ def get_python_path():
"""Get the path to the Python executable in the virtual environment.""" """Get the path to the Python executable in the virtual environment."""
project_root = get_project_root() project_root = get_project_root()
venv_python = os.path.join(project_root, "venv", "bin", "python") venv_python = os.path.join(project_root, "venv", "bin", "python")
if os.path.exists(venv_python): if os.path.exists(venv_python):
return venv_python return venv_python
# Fallback to system Python # Fallback to system Python
return sys.executable return sys.executable
@@ -30,7 +30,7 @@ def generate_config():
python_path = get_python_path() python_path = get_python_path()
# Use absolute path to the MCP CLI script to avoid double-slash issues # Use absolute path to the MCP CLI script to avoid double-slash issues
script_path = os.path.join(project_root, "mcp_server_cli.py") script_path = os.path.join(project_root, "mcp_server_cli.py")
# Load .env file from project root to get BASECAMP_ACCOUNT_ID # Load .env file from project root to get BASECAMP_ACCOUNT_ID
dotenv_path = os.path.join(project_root, ".env") dotenv_path = os.path.join(project_root, ".env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
@@ -56,13 +56,13 @@ def generate_config():
} }
} }
} }
return config return config
def get_cursor_config_path(): def get_cursor_config_path():
"""Get the path to the Cursor MCP configuration file.""" """Get the path to the Cursor MCP configuration file."""
home = Path.home() home = Path.home()
if sys.platform == "darwin": # macOS if sys.platform == "darwin": # macOS
return home / ".cursor" / "mcp.json" return home / ".cursor" / "mcp.json"
elif sys.platform == "win32": # Windows elif sys.platform == "win32": # Windows
@@ -74,59 +74,59 @@ def main():
"""Main function.""" """Main function."""
config = generate_config() config = generate_config()
config_path = get_cursor_config_path() config_path = get_cursor_config_path()
print("🔧 Generated Cursor MCP Configuration:") print("🔧 Generated Cursor MCP Configuration:")
print(json.dumps(config, indent=2)) print(json.dumps(config, indent=2))
print() print()
print(f"📁 Configuration should be saved to: {config_path}") print(f"📁 Configuration should be saved to: {config_path}")
print() print()
# Check if the file exists and offer to update it # Check if the file exists and offer to update it
if config_path.exists(): if config_path.exists():
print("⚠️ Configuration file already exists.") print("⚠️ Configuration file already exists.")
response = input("Do you want to update it? (y/N): ").lower().strip() response = input("Do you want to update it? (y/N): ").lower().strip()
if response in ['y', 'yes']: if response in ['y', 'yes']:
# Read existing config # Read existing config
try: try:
with open(config_path, 'r') as f: with open(config_path, 'r') as f:
existing_config = json.load(f) existing_config = json.load(f)
# Update the basecamp server configuration # Update the basecamp server configuration
if "mcpServers" not in existing_config: if "mcpServers" not in existing_config:
existing_config["mcpServers"] = {} existing_config["mcpServers"] = {}
existing_config["mcpServers"]["basecamp"] = config["mcpServers"]["basecamp"] existing_config["mcpServers"]["basecamp"] = config["mcpServers"]["basecamp"]
# Write back the updated config # Write back the updated config
config_path.parent.mkdir(parents=True, exist_ok=True) config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f: with open(config_path, 'w') as f:
json.dump(existing_config, f, indent=2) json.dump(existing_config, f, indent=2)
print("✅ Configuration updated successfully!") print("✅ Configuration updated successfully!")
except Exception as e: except Exception as e:
print(f"❌ Error updating configuration: {e}") print(f"❌ Error updating configuration: {e}")
else: else:
print("Configuration not updated.") print("Configuration not updated.")
else: else:
response = input("Do you want to create the configuration file? (y/N): ").lower().strip() response = input("Do you want to create the configuration file? (y/N): ").lower().strip()
if response in ['y', 'yes']: if response in ['y', 'yes']:
try: try:
config_path.parent.mkdir(parents=True, exist_ok=True) config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f: with open(config_path, 'w') as f:
json.dump(config, f, indent=2) json.dump(config, f, indent=2)
print("✅ Configuration file created successfully!") print("✅ Configuration file created successfully!")
except Exception as e: except Exception as e:
print(f"❌ Error creating configuration file: {e}") print(f"❌ Error creating configuration file: {e}")
else: else:
print("Configuration file not created.") print("Configuration file not created.")
print() print()
print("📋 Next steps:") print("📋 Next steps:")
print("1. Make sure you've authenticated with Basecamp: python oauth_app.py") 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'") print("3. Check Cursor Settings → MCP for a green checkmark next to 'basecamp'")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -37,11 +37,11 @@ logger = logging.getLogger('mcp_cli_server')
class MCPServer: class MCPServer:
"""MCP server implementing the Model Context Protocol for Cursor.""" """MCP server implementing the Model Context Protocol for Cursor."""
def __init__(self): def __init__(self):
self.tools = self._get_available_tools() self.tools = self._get_available_tools()
logger.info("MCP CLI Server initialized") logger.info("MCP CLI Server initialized")
def _get_available_tools(self) -> List[Dict[str, Any]]: def _get_available_tools(self) -> List[Dict[str, Any]]:
"""Get list of available tools for Basecamp.""" """Get list of available tools for Basecamp."""
return [ return [
@@ -58,7 +58,7 @@ class MCPServer:
"name": "get_project", "name": "get_project",
"description": "Get details for a specific project", "description": "Get details for a specific project",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": {"type": "string", "description": "The project ID"} "project_id": {"type": "string", "description": "The project ID"}
}, },
@@ -124,34 +124,34 @@ class MCPServer:
} }
} }
] ]
def _get_basecamp_client(self) -> Optional[BasecampClient]: def _get_basecamp_client(self) -> Optional[BasecampClient]:
"""Get authenticated Basecamp client.""" """Get authenticated Basecamp client."""
try: try:
token_data = token_storage.get_token() token_data = token_storage.get_token()
logger.debug(f"Token data retrieved: {token_data}") logger.debug(f"Token data retrieved: {token_data}")
if not token_data or not token_data.get('access_token'): if not token_data or not token_data.get('access_token'):
logger.error("No OAuth token available") logger.error("No OAuth token available")
return None return None
# Check if token is expired # Check if token is expired
if token_storage.is_token_expired(): if token_storage.is_token_expired():
logger.error("OAuth token has expired") logger.error("OAuth token has expired")
return None return None
# Get account_id from token data first, then fall back to env var # Get account_id from token data first, then fall back to env var
account_id = token_data.get('account_id') or os.getenv('BASECAMP_ACCOUNT_ID') account_id = token_data.get('account_id') or os.getenv('BASECAMP_ACCOUNT_ID')
# Set a default user agent if none is provided # Set a default user agent if none is provided
user_agent = os.getenv('USER_AGENT') or "Basecamp MCP Server (cursor@example.com)" user_agent = os.getenv('USER_AGENT') or "Basecamp MCP Server (cursor@example.com)"
if not account_id: if not account_id:
logger.error(f"Missing account_id. Token data: {token_data}, Env BASECAMP_ACCOUNT_ID: {os.getenv('BASECAMP_ACCOUNT_ID')}") logger.error(f"Missing account_id. Token data: {token_data}, Env BASECAMP_ACCOUNT_ID: {os.getenv('BASECAMP_ACCOUNT_ID')}")
return None return None
logger.debug(f"Creating Basecamp client with account_id: {account_id}, user_agent: {user_agent}") logger.debug(f"Creating Basecamp client with account_id: {account_id}, user_agent: {user_agent}")
return BasecampClient( return BasecampClient(
access_token=token_data['access_token'], access_token=token_data['access_token'],
account_id=account_id, account_id=account_id,
@@ -161,7 +161,7 @@ class MCPServer:
except Exception as e: except Exception as e:
logger.error(f"Error creating Basecamp client: {e}") logger.error(f"Error creating Basecamp client: {e}")
return None return None
def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]: def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
"""Handle an MCP request.""" """Handle an MCP request."""
method = request.get("method") method = request.get("method")
@@ -169,9 +169,9 @@ class MCPServer:
method_lower = method.lower() if isinstance(method, str) else '' method_lower = method.lower() if isinstance(method, str) else ''
params = request.get("params", {}) params = request.get("params", {})
request_id = request.get("id") request_id = request.get("id")
logger.info(f"Handling request: {method}") logger.info(f"Handling request: {method}")
try: try:
if method_lower == "initialize": if method_lower == "initialize":
return { return {
@@ -188,12 +188,12 @@ class MCPServer:
} }
} }
} }
elif method_lower == "initialized": elif method_lower == "initialized":
# This is a notification, no response needed # This is a notification, no response needed
logger.info("Received initialized notification") logger.info("Received initialized notification")
return None return None
elif method_lower in ("tools/list", "listtools"): elif method_lower in ("tools/list", "listtools"):
return { return {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -202,13 +202,13 @@ class MCPServer:
"tools": self.tools "tools": self.tools
} }
} }
elif method_lower in ("tools/call", "toolscall"): elif method_lower in ("tools/call", "toolscall"):
tool_name = params.get("name") tool_name = params.get("name")
arguments = params.get("arguments", {}) arguments = params.get("arguments", {})
result = self._execute_tool(tool_name, arguments) result = self._execute_tool(tool_name, arguments)
return { return {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": request_id, "id": request_id,
@@ -221,7 +221,7 @@ class MCPServer:
] ]
} }
} }
elif method_lower in ("listofferings", "list_offerings", "loffering"): elif method_lower in ("listofferings", "list_offerings", "loffering"):
# Respond to Cursor's ListOfferings UI request # Respond to Cursor's ListOfferings UI request
offerings = [] offerings = []
@@ -238,7 +238,7 @@ class MCPServer:
"offerings": offerings "offerings": offerings
} }
} }
elif method_lower == "ping": elif method_lower == "ping":
# Handle ping requests # Handle ping requests
return { return {
@@ -246,7 +246,7 @@ class MCPServer:
"id": request_id, "id": request_id,
"result": {} "result": {}
} }
else: else:
return { return {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -256,7 +256,7 @@ class MCPServer:
"message": f"Method not found: {method}" "message": f"Method not found: {method}"
} }
} }
except Exception as e: except Exception as e:
logger.error(f"Error handling request: {e}") logger.error(f"Error handling request: {e}")
return { return {
@@ -267,7 +267,7 @@ class MCPServer:
"message": f"Internal error: {str(e)}" "message": f"Internal error: {str(e)}"
} }
} }
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a tool and return the result.""" """Execute a tool and return the result."""
client = self._get_basecamp_client() client = self._get_basecamp_client()
@@ -283,7 +283,7 @@ class MCPServer:
"error": "Authentication required", "error": "Authentication required",
"message": "Please authenticate with Basecamp first. Visit http://localhost:8000 to log in." "message": "Please authenticate with Basecamp first. Visit http://localhost:8000 to log in."
} }
try: try:
if tool_name == "get_projects": if tool_name == "get_projects":
projects = client.get_projects() projects = client.get_projects()
@@ -292,7 +292,7 @@ class MCPServer:
"projects": projects, "projects": projects,
"count": len(projects) "count": len(projects)
} }
elif tool_name == "get_project": elif tool_name == "get_project":
project_id = arguments.get("project_id") project_id = arguments.get("project_id")
project = client.get_project(project_id) project = client.get_project(project_id)
@@ -300,7 +300,7 @@ class MCPServer:
"status": "success", "status": "success",
"project": project "project": project
} }
elif tool_name == "get_todolists": elif tool_name == "get_todolists":
project_id = arguments.get("project_id") project_id = arguments.get("project_id")
todolists = client.get_todolists(project_id) todolists = client.get_todolists(project_id)
@@ -309,7 +309,7 @@ class MCPServer:
"todolists": todolists, "todolists": todolists,
"count": len(todolists) "count": len(todolists)
} }
elif tool_name == "get_todos": elif tool_name == "get_todos":
todolist_id = arguments.get("todolist_id") todolist_id = arguments.get("todolist_id")
todos = client.get_todos(todolist_id) todos = client.get_todos(todolist_id)
@@ -318,14 +318,14 @@ class MCPServer:
"todos": todos, "todos": todos,
"count": len(todos) "count": len(todos)
} }
elif tool_name == "search_basecamp": elif tool_name == "search_basecamp":
query = arguments.get("query") query = arguments.get("query")
project_id = arguments.get("project_id") project_id = arguments.get("project_id")
search = BasecampSearch(client=client) search = BasecampSearch(client=client)
results = {} results = {}
if project_id: if project_id:
# Search within specific project # Search within specific project
results["todolists"] = search.search_todolists(query, project_id) results["todolists"] = search.search_todolists(query, project_id)
@@ -335,13 +335,13 @@ class MCPServer:
results["projects"] = search.search_projects(query) results["projects"] = search.search_projects(query)
results["todos"] = search.search_todos(query) results["todos"] = search.search_todos(query)
results["messages"] = search.search_messages(query) results["messages"] = search.search_messages(query)
return { return {
"status": "success", "status": "success",
"query": query, "query": query,
"results": results "results": results
} }
elif tool_name == "get_comments": elif tool_name == "get_comments":
recording_id = arguments.get("recording_id") recording_id = arguments.get("recording_id")
bucket_id = arguments.get("bucket_id") bucket_id = arguments.get("bucket_id")
@@ -351,7 +351,7 @@ class MCPServer:
"comments": comments, "comments": comments,
"count": len(comments) "count": len(comments)
} }
elif tool_name == "get_campfire_lines": elif tool_name == "get_campfire_lines":
project_id = arguments.get("project_id") project_id = arguments.get("project_id")
campfire_id = arguments.get("campfire_id") campfire_id = arguments.get("campfire_id")
@@ -361,13 +361,13 @@ class MCPServer:
"campfire_lines": lines, "campfire_lines": lines,
"count": len(lines) "count": len(lines)
} }
else: else:
return { return {
"error": "Unknown tool", "error": "Unknown tool",
"message": f"Tool '{tool_name}' is not supported" "message": f"Tool '{tool_name}' is not supported"
} }
except Exception as e: except Exception as e:
logger.error(f"Error executing tool {tool_name}: {e}") logger.error(f"Error executing tool {tool_name}: {e}")
# Check if it's a 401 error (token expired during API call) # Check if it's a 401 error (token expired during API call)
@@ -380,24 +380,24 @@ class MCPServer:
"error": "Execution error", "error": "Execution error",
"message": str(e) "message": str(e)
} }
def run(self): def run(self):
"""Run the MCP server, reading from stdin and writing to stdout.""" """Run the MCP server, reading from stdin and writing to stdout."""
logger.info("Starting MCP CLI server") logger.info("Starting MCP CLI server")
for line in sys.stdin: for line in sys.stdin:
try: try:
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
request = json.loads(line) request = json.loads(line)
response = self.handle_request(request) response = self.handle_request(request)
# Write response to stdout (only if there's a response) # Write response to stdout (only if there's a response)
if response is not None: if response is not None:
print(json.dumps(response), flush=True) print(json.dumps(response), flush=True)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Invalid JSON received: {e}") logger.error(f"Invalid JSON received: {e}")
error_response = { error_response = {
@@ -409,11 +409,11 @@ class MCPServer:
} }
} }
print(json.dumps(error_response), flush=True) print(json.dumps(error_response), flush=True)
except Exception as e: except Exception as e:
logger.error(f"Unexpected error: {e}") logger.error(f"Unexpected error: {e}")
error_response = { error_response = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": None, "id": None,
"error": { "error": {
"code": -32603, "code": -32603,
@@ -424,4 +424,4 @@ class MCPServer:
if __name__ == "__main__": if __name__ == "__main__":
server = MCPServer() server = MCPServer()
server.run() server.run()

View File

@@ -56,13 +56,13 @@ RESULTS_TEMPLATE = """
body { font-family: Arial, sans-serif; margin: 20px; } body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; } h1 { color: #333; }
pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; } pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; }
.button { .button {
display: inline-block; display: inline-block;
background-color: #4CAF50; background-color: #4CAF50;
color: white; color: white;
padding: 10px 20px; padding: 10px 20px;
text-decoration: none; text-decoration: none;
border-radius: 5px; border-radius: 5px;
margin-top: 20px; margin-top: 20px;
} }
.container { max-width: 1000px; margin: 0 auto; } .container { max-width: 1000px; margin: 0 auto; }
@@ -109,9 +109,9 @@ def get_oauth_client():
client_secret = os.getenv('BASECAMP_CLIENT_SECRET') client_secret = os.getenv('BASECAMP_CLIENT_SECRET')
redirect_uri = os.getenv('BASECAMP_REDIRECT_URI') redirect_uri = os.getenv('BASECAMP_REDIRECT_URI')
user_agent = os.getenv('USER_AGENT') user_agent = os.getenv('USER_AGENT')
logger.info("Creating OAuth client with config: %s, %s, %s", client_id, redirect_uri, user_agent) logger.info("Creating OAuth client with config: %s, %s, %s", client_id, redirect_uri, user_agent)
return BasecampOAuth( return BasecampOAuth(
client_id=client_id, client_id=client_id,
client_secret=client_secret, client_secret=client_secret,
@@ -127,13 +127,13 @@ def home():
"""Home page.""" """Home page."""
# Check if we have a stored token # Check if we have a stored token
token_data = token_storage.get_token() token_data = token_storage.get_token()
if token_data and token_data.get('access_token'): if token_data and token_data.get('access_token'):
# We have a token, show token information # We have a token, show token information
access_token = token_data['access_token'] access_token = token_data['access_token']
# Mask the token for security # Mask the token for security
masked_token = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***" masked_token = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
token_info = { token_info = {
"access_token": masked_token, "access_token": masked_token,
"account_id": token_data.get('account_id'), "account_id": token_data.get('account_id'),
@@ -141,9 +141,9 @@ def home():
"expires_at": token_data.get('expires_at'), "expires_at": token_data.get('expires_at'),
"updated_at": token_data.get('updated_at') "updated_at": token_data.get('updated_at')
} }
logger.info("Home page: User is authenticated") logger.info("Home page: User is authenticated")
return render_template_string( return render_template_string(
RESULTS_TEMPLATE, RESULTS_TEMPLATE,
title="Basecamp OAuth Status", title="Basecamp OAuth Status",
@@ -156,9 +156,9 @@ def home():
try: try:
oauth_client = get_oauth_client() oauth_client = get_oauth_client()
auth_url = oauth_client.get_authorization_url() auth_url = oauth_client.get_authorization_url()
logger.info("Home page: User not authenticated, showing login button") logger.info("Home page: User not authenticated, showing login button")
return render_template_string( return render_template_string(
RESULTS_TEMPLATE, RESULTS_TEMPLATE,
title="Basecamp OAuth Demo", title="Basecamp OAuth Demo",
@@ -177,10 +177,10 @@ def home():
def auth_callback(): def auth_callback():
"""Handle the OAuth callback from Basecamp.""" """Handle the OAuth callback from Basecamp."""
logger.info("OAuth callback called with args: %s", request.args) logger.info("OAuth callback called with args: %s", request.args)
code = request.args.get('code') code = request.args.get('code')
error = request.args.get('error') error = request.args.get('error')
if error: if error:
logger.error("OAuth callback error: %s", error) logger.error("OAuth callback error: %s", error)
return render_template_string( return render_template_string(
@@ -189,7 +189,7 @@ def auth_callback():
message=f"Basecamp returned an error: {error}", message=f"Basecamp returned an error: {error}",
show_home=True show_home=True
) )
if not code: if not code:
logger.error("OAuth callback: No code provided") logger.error("OAuth callback: No code provided")
return render_template_string( return render_template_string(
@@ -198,20 +198,20 @@ def auth_callback():
message="No authorization code received.", message="No authorization code received.",
show_home=True show_home=True
) )
try: try:
# Exchange the code for an access token # Exchange the code for an access token
oauth_client = get_oauth_client() oauth_client = get_oauth_client()
logger.info("Exchanging code for token") logger.info("Exchanging code for token")
token_data = oauth_client.exchange_code_for_token(code) token_data = oauth_client.exchange_code_for_token(code)
logger.info(f"Raw token data from Basecamp exchange: {token_data}") logger.info(f"Raw token data from Basecamp exchange: {token_data}")
# Store the token in our secure storage # Store the token in our secure storage
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in') expires_in = token_data.get('expires_in')
account_id = os.getenv('BASECAMP_ACCOUNT_ID') account_id = os.getenv('BASECAMP_ACCOUNT_ID')
if not access_token: if not access_token:
logger.error("OAuth exchange: No access token received") logger.error("OAuth exchange: No access token received")
return render_template_string( return render_template_string(
@@ -220,14 +220,14 @@ def auth_callback():
message="No access token received from Basecamp.", message="No access token received from Basecamp.",
show_home=True show_home=True
) )
# Try to get identity if account_id is not set # Try to get identity if account_id is not set
if not account_id: if not account_id:
try: try:
logger.info("Getting user identity to find account_id") logger.info("Getting user identity to find account_id")
identity = oauth_client.get_identity(access_token) identity = oauth_client.get_identity(access_token)
logger.info("Identity response: %s", identity) logger.info("Identity response: %s", identity)
# Find Basecamp 3 account # Find Basecamp 3 account
if identity.get('accounts'): if identity.get('accounts'):
for account in identity['accounts']: for account in identity['accounts']:
@@ -238,7 +238,7 @@ def auth_callback():
except Exception as identity_error: except Exception as identity_error:
logger.error("Error getting identity: %s", str(identity_error)) logger.error("Error getting identity: %s", str(identity_error))
# Continue with the flow, but log the error # Continue with the flow, but log the error
logger.info("Storing token with account_id: %s", account_id) logger.info("Storing token with account_id: %s", account_id)
stored = token_storage.store_token( stored = token_storage.store_token(
access_token=access_token, access_token=access_token,
@@ -246,7 +246,7 @@ def auth_callback():
expires_in=expires_in, expires_in=expires_in,
account_id=account_id account_id=account_id
) )
if not stored: if not stored:
logger.error("Failed to store token") logger.error("Failed to store token")
return render_template_string( return render_template_string(
@@ -255,16 +255,16 @@ def auth_callback():
message="Failed to store token. Please try again.", message="Failed to store token. Please try again.",
show_home=True show_home=True
) )
# Also keep the access token in session for convenience # Also keep the access token in session for convenience
session['access_token'] = access_token session['access_token'] = access_token
if refresh_token: if refresh_token:
session['refresh_token'] = refresh_token session['refresh_token'] = refresh_token
if account_id: if account_id:
session['account_id'] = account_id session['account_id'] = account_id
logger.info("OAuth flow completed successfully") logger.info("OAuth flow completed successfully")
return redirect(url_for('home')) return redirect(url_for('home'))
except Exception as e: except Exception as e:
logger.error("Error in OAuth callback: %s", str(e), exc_info=True) 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. This should only be accessible by the MCP server.
""" """
logger.info("Token API called with headers: %s", request.headers) logger.info("Token API called with headers: %s", request.headers)
# In production, implement proper authentication for this endpoint # In production, implement proper authentication for this endpoint
# For now, we'll use a simple API key check # For now, we'll use a simple API key check
api_key = request.headers.get('X-API-Key') api_key = request.headers.get('X-API-Key')
@@ -292,7 +292,7 @@ def get_token_api():
"error": "Unauthorized", "error": "Unauthorized",
"message": "Invalid or missing API key" "message": "Invalid or missing API key"
}), 401 }), 401
token_data = token_storage.get_token() token_data = token_storage.get_token()
if not token_data or not token_data.get('access_token'): if not token_data or not token_data.get('access_token'):
logger.error("Token API: No valid token available") logger.error("Token API: No valid token available")
@@ -300,7 +300,7 @@ def get_token_api():
"error": "Not authenticated", "error": "Not authenticated",
"message": "No valid token available" "message": "No valid token available"
}), 404 }), 404
logger.info("Token API: Successfully returned token") logger.info("Token API: Successfully returned token")
return jsonify({ return jsonify({
"access_token": token_data['access_token'], "access_token": token_data['access_token'],
@@ -320,7 +320,7 @@ def token_info():
"""Display information about the stored token.""" """Display information about the stored token."""
logger.info("Token info called") logger.info("Token info called")
token_data = token_storage.get_token() token_data = token_storage.get_token()
if not token_data: if not token_data:
logger.info("Token info: No token stored") logger.info("Token info: No token stored")
return render_template_string( return render_template_string(
@@ -329,14 +329,14 @@ def token_info():
message="No token stored.", message="No token stored.",
show_home=True show_home=True
) )
# Mask the tokens for security # Mask the tokens for security
access_token = token_data.get('access_token', '') access_token = token_data.get('access_token', '')
refresh_token = token_data.get('refresh_token', '') refresh_token = token_data.get('refresh_token', '')
masked_access = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***" 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 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 = { display_info = {
"access_token": masked_access, "access_token": masked_access,
"has_refresh_token": bool(refresh_token), "has_refresh_token": bool(refresh_token),
@@ -344,7 +344,7 @@ def token_info():
"expires_at": token_data.get('expires_at'), "expires_at": token_data.get('expires_at'),
"updated_at": token_data.get('updated_at') "updated_at": token_data.get('updated_at')
} }
logger.info("Token info: Returned token info") logger.info("Token info: Returned token info")
return render_template_string( return render_template_string(
RESULTS_TEMPLATE, RESULTS_TEMPLATE,
@@ -367,12 +367,12 @@ if __name__ == '__main__':
logger.info("Starting OAuth app on port %s", os.environ.get('PORT', 8000)) logger.info("Starting OAuth app on port %s", os.environ.get('PORT', 8000))
# Run the Flask app # Run the Flask app
port = int(os.environ.get('PORT', 8000)) port = int(os.environ.get('PORT', 8000))
# Disable debug and auto-reloader when running in production or background # Disable debug and auto-reloader when running in production or background
is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
logger.info("Running in %s mode", "debug" if is_debug else "production") logger.info("Running in %s mode", "debug" if is_debug else "production")
app.run(host='0.0.0.0', port=port, debug=is_debug, use_reloader=is_debug) app.run(host='0.0.0.0', port=port, debug=is_debug, use_reloader=is_debug)
except Exception as e: except Exception as e:
logger.error("Fatal error: %s", str(e), exc_info=True) logger.error("Fatal error: %s", str(e), exc_info=True)
sys.exit(1) sys.exit(1)

View File

@@ -2,4 +2,4 @@ requests==2.31.0
python-dotenv==1.0.0 python-dotenv==1.0.0
flask==2.3.3 flask==2.3.3
flask-cors==4.0.0 flask-cors==4.0.0
pytest==7.4.0 pytest==7.4.0

View File

@@ -10,65 +10,65 @@ class BasecampSearch:
""" """
Utility for searching across Basecamp 3 projects and to-dos. Utility for searching across Basecamp 3 projects and to-dos.
""" """
def __init__(self, client=None, **kwargs): def __init__(self, client=None, **kwargs):
"""Initialize with either an existing client or credentials.""" """Initialize with either an existing client or credentials."""
if client: if client:
self.client = client self.client = client
else: else:
self.client = BasecampClient(**kwargs) self.client = BasecampClient(**kwargs)
def search_projects(self, query=None): def search_projects(self, query=None):
""" """
Search all projects, optionally filtering by name. Search all projects, optionally filtering by name.
Args: Args:
query (str, optional): Text to search for in project names query (str, optional): Text to search for in project names
Returns: Returns:
list: Filtered list of projects list: Filtered list of projects
""" """
try: try:
projects = self.client.get_projects() projects = self.client.get_projects()
if query and projects: if query and projects:
query = query.lower() query = query.lower()
projects = [ projects = [
project for project in projects project for project in projects
if query in project.get('name', '').lower() or if query in project.get('name', '').lower() or
query in (project.get('description') or '').lower() query in (project.get('description') or '').lower()
] ]
return projects return projects
except Exception as e: except Exception as e:
logger.error(f"Error searching projects: {str(e)}") logger.error(f"Error searching projects: {str(e)}")
return [] return []
def get_all_todolists(self, project_id=None): def get_all_todolists(self, project_id=None):
""" """
Get all todolists, either for a specific project or across all projects. Get all todolists, either for a specific project or across all projects.
Args: Args:
project_id (int, optional): Specific project ID or None for all projects project_id (int, optional): Specific project ID or None for all projects
Returns: Returns:
list: List of todolists with project info list: List of todolists with project info
""" """
all_todolists = [] all_todolists = []
try: try:
if project_id: if project_id:
# Get todolists for a specific project # Get todolists for a specific project
project = self.client.get_project(project_id) project = self.client.get_project(project_id)
todolists = self.client.get_todolists(project_id) todolists = self.client.get_todolists(project_id)
for todolist in todolists: for todolist in todolists:
todolist['project'] = {'id': project['id'], 'name': project['name']} todolist['project'] = {'id': project['id'], 'name': project['name']}
all_todolists.append(todolist) all_todolists.append(todolist)
else: else:
# Get todolists across all projects # Get todolists across all projects
projects = self.client.get_projects() projects = self.client.get_projects()
for project in projects: for project in projects:
project_id = project['id'] project_id = project['id']
try: try:
@@ -80,56 +80,56 @@ class BasecampSearch:
logger.error(f"Error getting todolists for project {project_id}: {str(e)}") logger.error(f"Error getting todolists for project {project_id}: {str(e)}")
except Exception as e: except Exception as e:
logger.error(f"Error getting all todolists: {str(e)}") logger.error(f"Error getting all todolists: {str(e)}")
return all_todolists return all_todolists
def search_todolists(self, query=None, project_id=None): def search_todolists(self, query=None, project_id=None):
""" """
Search all todolists, optionally filtering by name and project. Search all todolists, optionally filtering by name and project.
Args: Args:
query (str, optional): Text to search for in todolist names query (str, optional): Text to search for in todolist names
project_id (int, optional): Specific project ID or None for all projects project_id (int, optional): Specific project ID or None for all projects
Returns: Returns:
list: Filtered list of todolists list: Filtered list of todolists
""" """
todolists = self.get_all_todolists(project_id) todolists = self.get_all_todolists(project_id)
if query and todolists: if query and todolists:
query = query.lower() query = query.lower()
todolists = [ todolists = [
todolist for todolist in todolists todolist for todolist in todolists
if query in todolist.get('name', '').lower() or if query in todolist.get('name', '').lower() or
query in (todolist.get('description') or '').lower() query in (todolist.get('description') or '').lower()
] ]
return todolists return todolists
def get_all_todos(self, project_id=None, todolist_id=None, include_completed=False): def get_all_todos(self, project_id=None, todolist_id=None, include_completed=False):
""" """
Get all todos, with various filtering options. Get all todos, with various filtering options.
Args: Args:
project_id (int, optional): Specific project ID or None for all projects project_id (int, optional): Specific project ID or None for all projects
todolist_id (int, optional): Specific todolist ID or None for all todolists todolist_id (int, optional): Specific todolist ID or None for all todolists
include_completed (bool): Whether to include completed todos include_completed (bool): Whether to include completed todos
Returns: Returns:
list: List of todos with project and todolist info list: List of todos with project and todolist info
""" """
all_todos = [] all_todos = []
try: try:
# Case 1: Specific todolist (regardless of project) # Case 1: Specific todolist (regardless of project)
if todolist_id: if todolist_id:
try: try:
todolist = self.client.get_todolist(todolist_id) todolist = self.client.get_todolist(todolist_id)
todos = self.client.get_todos(todolist_id) todos = self.client.get_todos(todolist_id)
# In Basecamp 3, we need to add project info to the todolist # In Basecamp 3, we need to add project info to the todolist
# Get project ID from the URL # 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 link.get('type') == 'project']
if project_links: if project_links:
project_url = project_links[0].get('href', '') project_url = project_links[0].get('href', '')
@@ -146,46 +146,46 @@ class BasecampSearch:
project_name = 'Unknown Project' project_name = 'Unknown Project'
else: else:
project_name = 'Unknown Project' project_name = 'Unknown Project'
for todo in todos: for todo in todos:
if not include_completed and todo.get('completed'): if not include_completed and todo.get('completed'):
continue continue
todo['project'] = {'id': project_id, 'name': project_name} todo['project'] = {'id': project_id, 'name': project_name}
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']} todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
all_todos.append(todo) all_todos.append(todo)
except Exception as e: except Exception as e:
logger.error(f"Error getting todos for todolist {todolist_id}: {str(e)}") logger.error(f"Error getting todos for todolist {todolist_id}: {str(e)}")
# Case 2: Specific project, all todolists # Case 2: Specific project, all todolists
elif project_id: elif project_id:
project = self.client.get_project(project_id) project = self.client.get_project(project_id)
todolists = self.client.get_todolists(project_id) todolists = self.client.get_todolists(project_id)
for todolist in todolists: for todolist in todolists:
try: try:
todos = self.client.get_todos(todolist['id']) todos = self.client.get_todos(todolist['id'])
for todo in todos: for todo in todos:
if not include_completed and todo.get('completed'): if not include_completed and todo.get('completed'):
continue continue
todo['project'] = {'id': project['id'], 'name': project['name']} todo['project'] = {'id': project['id'], 'name': project['name']}
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']} todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
all_todos.append(todo) all_todos.append(todo)
except Exception as e: except Exception as e:
logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}") logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}")
# Case 3: All projects # Case 3: All projects
else: else:
todolists = self.get_all_todolists() todolists = self.get_all_todolists()
for todolist in todolists: for todolist in todolists:
try: try:
todos = self.client.get_todos(todolist['id']) todos = self.client.get_todos(todolist['id'])
for todo in todos: for todo in todos:
if not include_completed and todo.get('completed'): if not include_completed and todo.get('completed'):
continue continue
todo['project'] = todolist['project'] todo['project'] = todolist['project']
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']} todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
all_todos.append(todo) all_todos.append(todo)
@@ -193,72 +193,72 @@ class BasecampSearch:
logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}") logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}")
except Exception as e: except Exception as e:
logger.error(f"Error getting all todos: {str(e)}") logger.error(f"Error getting all todos: {str(e)}")
return all_todos return all_todos
def search_todos(self, query=None, project_id=None, todolist_id=None, include_completed=False): def search_todos(self, query=None, project_id=None, todolist_id=None, include_completed=False):
""" """
Search all todos, with various filtering options. Search all todos, with various filtering options.
Args: Args:
query (str, optional): Text to search for in todo content query (str, optional): Text to search for in todo content
project_id (int, optional): Specific project ID or None for all projects project_id (int, optional): Specific project ID or None for all projects
todolist_id (int, optional): Specific todolist ID or None for all todolists todolist_id (int, optional): Specific todolist ID or None for all todolists
include_completed (bool): Whether to include completed todos include_completed (bool): Whether to include completed todos
Returns: Returns:
list: Filtered list of todos list: Filtered list of todos
""" """
todos = self.get_all_todos(project_id, todolist_id, include_completed) todos = self.get_all_todos(project_id, todolist_id, include_completed)
if query and todos: if query and todos:
query = query.lower() query = query.lower()
# In Basecamp 3, the todo content is in the 'content' field # In Basecamp 3, the todo content is in the 'content' field
todos = [ todos = [
t for t in todos t for t in todos
if query in t.get('content', '').lower() or if query in t.get('content', '').lower() or
query in (t.get('description') or '').lower() query in (t.get('description') or '').lower()
] ]
return todos return todos
def search_messages(self, query=None, project_id=None): def search_messages(self, query=None, project_id=None):
""" """
Search for messages across all projects or within a specific project. Search for messages across all projects or within a specific project.
Args: Args:
query (str, optional): Search term to filter messages query (str, optional): Search term to filter messages
project_id (int, optional): If provided, only search within this project project_id (int, optional): If provided, only search within this project
Returns: Returns:
list: Matching messages list: Matching messages
""" """
all_messages = [] all_messages = []
try: try:
# Get projects to search in # Get projects to search in
if project_id: if project_id:
projects = [self.client.get_project(project_id)] projects = [self.client.get_project(project_id)]
else: else:
projects = self.client.get_projects() projects = self.client.get_projects()
for project in projects: for project in projects:
project_id = project['id'] project_id = project['id']
logger.info(f"Searching messages in project {project_id} ({project.get('name', 'Unknown')})") logger.info(f"Searching messages in project {project_id} ({project.get('name', 'Unknown')})")
# Check for message boards in the dock # Check for message boards in the dock
has_message_board = False has_message_board = False
message_boards = [] message_boards = []
for dock_item in project.get('dock', []): for dock_item in project.get('dock', []):
if dock_item.get('name') == 'message_board' and dock_item.get('enabled', False): if dock_item.get('name') == 'message_board' and dock_item.get('enabled', False):
has_message_board = True has_message_board = True
message_boards.append(dock_item) message_boards.append(dock_item)
if not has_message_board: if not has_message_board:
logger.info(f"Project {project_id} ({project.get('name', 'Unknown')}) has no enabled message boards") logger.info(f"Project {project_id} ({project.get('name', 'Unknown')}) has no enabled message boards")
continue continue
# Get messages from each message board # Get messages from each message board
for board in message_boards: for board in message_boards:
board_id = board.get('id') board_id = board.get('id')
@@ -267,14 +267,14 @@ class BasecampSearch:
logger.info(f"Fetching message board {board_id} for project {project_id}") logger.info(f"Fetching message board {board_id} for project {project_id}")
board_endpoint = f"buckets/{project_id}/message_boards/{board_id}.json" board_endpoint = f"buckets/{project_id}/message_boards/{board_id}.json"
board_details = self.client.get(board_endpoint) board_details = self.client.get(board_endpoint)
# Then get all messages in the board # Then get all messages in the board
logger.info(f"Fetching messages for board {board_id} in project {project_id}") 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_endpoint = f"buckets/{project_id}/message_boards/{board_id}/messages.json"
messages = self.client.get(messages_endpoint) messages = self.client.get(messages_endpoint)
logger.info(f"Found {len(messages)} messages in board {board_id}") logger.info(f"Found {len(messages)} messages in board {board_id}")
# Now get detailed content for each message # Now get detailed content for each message
for message in messages: for message in messages:
try: try:
@@ -282,13 +282,13 @@ class BasecampSearch:
# Get detailed message content # Get detailed message content
message_endpoint = f"buckets/{project_id}/messages/{message_id}.json" message_endpoint = f"buckets/{project_id}/messages/{message_id}.json"
detailed_message = self.client.get(message_endpoint) detailed_message = self.client.get(message_endpoint)
# Add project info # Add project info
detailed_message['project'] = { detailed_message['project'] = {
'id': project_id, 'id': project_id,
'name': project.get('name', 'Unknown Project') 'name': project.get('name', 'Unknown Project')
} }
# Add to results # Add to results
all_messages.append(detailed_message) all_messages.append(detailed_message)
except Exception as e: except Exception as e:
@@ -301,14 +301,14 @@ class BasecampSearch:
all_messages.append(message) all_messages.append(message)
except Exception as e: except Exception as e:
logger.error(f"Error getting messages for board {board_id} in project {project_id}: {str(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 alternate approach: get messages directly for the project
try: try:
logger.info(f"Trying alternate approach for project {project_id}") logger.info(f"Trying alternate approach for project {project_id}")
messages = self.client.get_messages(project_id) messages = self.client.get_messages(project_id)
logger.info(f"Found {len(messages)} messages in project {project_id} using direct method") logger.info(f"Found {len(messages)} messages in project {project_id} using direct method")
# Add project info to each message # Add project info to each message
for message in messages: for message in messages:
message['project'] = { message['project'] = {
@@ -318,20 +318,20 @@ class BasecampSearch:
all_messages.append(message) all_messages.append(message)
except Exception as e2: except Exception as e2:
logger.error(f"Error getting messages directly for project {project_id}: {str(e2)}") logger.error(f"Error getting messages directly for project {project_id}: {str(e2)}")
# Also check for message categories/topics # Also check for message categories/topics
try: try:
# Try to get message categories # Try to get message categories
categories_endpoint = f"buckets/{project_id}/categories.json" categories_endpoint = f"buckets/{project_id}/categories.json"
categories = self.client.get(categories_endpoint) categories = self.client.get(categories_endpoint)
for category in categories: for category in categories:
category_id = category.get('id') category_id = category.get('id')
try: try:
# Get messages in this category # Get messages in this category
category_messages_endpoint = f"buckets/{project_id}/categories/{category_id}/messages.json" category_messages_endpoint = f"buckets/{project_id}/categories/{category_id}/messages.json"
category_messages = self.client.get(category_messages_endpoint) category_messages = self.client.get(category_messages_endpoint)
# Add project and category info # Add project and category info
for message in category_messages: for message in category_messages:
message['project'] = { message['project'] = {
@@ -347,33 +347,33 @@ class BasecampSearch:
logger.error(f"Error getting messages for category {category_id} in project {project_id}: {str(e)}") logger.error(f"Error getting messages for category {category_id} in project {project_id}: {str(e)}")
except Exception as e: except Exception as e:
logger.info(f"No message categories found for project {project_id}: {str(e)}") logger.info(f"No message categories found for project {project_id}: {str(e)}")
except Exception as e: except Exception as e:
logger.error(f"Error searching messages: {str(e)}") logger.error(f"Error searching messages: {str(e)}")
# Filter by query if provided # Filter by query if provided
if query and all_messages: if query and all_messages:
query = query.lower() query = query.lower()
filtered_messages = [] filtered_messages = []
for message in all_messages: for message in all_messages:
# Search in multiple fields # Search in multiple fields
content_matched = False content_matched = False
# Check title/subject # Check title/subject
if query in (message.get('subject', '') or '').lower(): if query in (message.get('subject', '') or '').lower():
content_matched = True content_matched = True
# Check content field # Check content field
if not content_matched and query in (message.get('content', '') or '').lower(): if not content_matched and query in (message.get('content', '') or '').lower():
content_matched = True content_matched = True
# Check content field with HTML # Check content field with HTML
if not content_matched and 'content' in message: if not content_matched and 'content' in message:
content_html = message.get('content') content_html = message.get('content')
if content_html and query in content_html.lower(): if content_html and query in content_html.lower():
content_matched = True content_matched = True
# Check raw content in various formats # Check raw content in various formats
if not content_matched: if not content_matched:
# Try different content field formats # Try different content field formats
@@ -382,36 +382,36 @@ class BasecampSearch:
if query in str(message[field]).lower(): if query in str(message[field]).lower():
content_matched = True content_matched = True
break break
# Check title field # Check title field
if not content_matched and 'title' in message and message['title']: if not content_matched and 'title' in message and message['title']:
if query in message['title'].lower(): if query in message['title'].lower():
content_matched = True content_matched = True
# Check creator's name # Check creator's name
if not content_matched and 'creator' in message and message['creator']: if not content_matched and 'creator' in message and message['creator']:
creator = message['creator'] creator = message['creator']
creator_name = f"{creator.get('name', '')} {creator.get('first_name', '')} {creator.get('last_name', '')}" creator_name = f"{creator.get('name', '')} {creator.get('first_name', '')} {creator.get('last_name', '')}"
if query in creator_name.lower(): if query in creator_name.lower():
content_matched = True content_matched = True
# Include if content matched # Include if content matched
if content_matched: if content_matched:
filtered_messages.append(message) filtered_messages.append(message)
logger.info(f"Found {len(filtered_messages)} messages matching query '{query}' out of {len(all_messages)} total messages") logger.info(f"Found {len(filtered_messages)} messages matching query '{query}' out of {len(all_messages)} total messages")
return filtered_messages return filtered_messages
return all_messages return all_messages
def search_schedule_entries(self, query=None, project_id=None): def search_schedule_entries(self, query=None, project_id=None):
""" """
Search schedule entries across projects or in a specific project. Search schedule entries across projects or in a specific project.
Args: Args:
query (str, optional): Search term to filter schedule entries query (str, optional): Search term to filter schedule entries
project_id (int, optional): Specific project ID to search in project_id (int, optional): Specific project ID to search in
Returns: Returns:
list: Matching schedule entries list: Matching schedule entries
""" """
@@ -423,7 +423,7 @@ class BasecampSearch:
else: else:
# Get all projects first # Get all projects first
projects = self.client.get_projects() projects = self.client.get_projects()
# Then get schedule entries from each # Then get schedule entries from each
entries = [] entries = []
for project in projects: for project in projects:
@@ -436,7 +436,7 @@ class BasecampSearch:
'name': project['name'] 'name': project['name']
} }
entries.extend(project_entries) entries.extend(project_entries)
# Filter by query if provided # Filter by query if provided
if query and entries: if query and entries:
query = query.lower() query = query.lower()
@@ -446,21 +446,21 @@ class BasecampSearch:
query in (entry.get('description') or '').lower() or query in (entry.get('description') or '').lower() or
(entry.get('creator') and query in entry['creator'].get('name', '').lower()) (entry.get('creator') and query in entry['creator'].get('name', '').lower())
] ]
return entries return entries
except Exception as e: except Exception as e:
logger.error(f"Error searching schedule entries: {str(e)}") logger.error(f"Error searching schedule entries: {str(e)}")
return [] return []
def search_comments(self, query=None, recording_id=None, bucket_id=None): def search_comments(self, query=None, recording_id=None, bucket_id=None):
""" """
Search for comments across resources or for a specific resource. Search for comments across resources or for a specific resource.
Args: Args:
query (str, optional): Search term to filter comments query (str, optional): Search term to filter comments
recording_id (int, optional): ID of the recording (todo, message, etc.) to search in recording_id (int, optional): ID of the recording (todo, message, etc.) to search in
bucket_id (int, optional): Project/bucket ID bucket_id (int, optional): Project/bucket ID
Returns: Returns:
list: Matching comments list: Matching comments
""" """
@@ -476,11 +476,11 @@ class BasecampSearch:
"api_limitation": True, "api_limitation": True,
"title": "Comment Search Limitation" "title": "Comment Search Limitation"
}] }]
# Filter by query if provided # Filter by query if provided
if query and comments: if query and comments:
query = query.lower() query = query.lower()
filtered_comments = [] filtered_comments = []
for comment in comments: for comment in comments:
# Check content # Check content
@@ -488,33 +488,33 @@ class BasecampSearch:
content = comment.get('content', '') content = comment.get('content', '')
if content and query in content.lower(): if content and query in content.lower():
content_matched = True content_matched = True
# Check creator name # Check creator name
if not content_matched and comment.get('creator'): if not content_matched and comment.get('creator'):
creator_name = comment['creator'].get('name', '') creator_name = comment['creator'].get('name', '')
if creator_name and query in creator_name.lower(): if creator_name and query in creator_name.lower():
content_matched = True content_matched = True
# If matched, add to results # If matched, add to results
if content_matched: if content_matched:
filtered_comments.append(comment) filtered_comments.append(comment)
return filtered_comments return filtered_comments
return comments return comments
except Exception as e: except Exception as e:
logger.error(f"Error searching comments: {str(e)}") logger.error(f"Error searching comments: {str(e)}")
return [] return []
def search_campfire_lines(self, query=None, project_id=None, campfire_id=None): def search_campfire_lines(self, query=None, project_id=None, campfire_id=None):
""" """
Search for lines in campfire chats. Search for lines in campfire chats.
Args: Args:
query (str, optional): Search term to filter lines query (str, optional): Search term to filter lines
project_id (int, optional): Project ID project_id (int, optional): Project ID
campfire_id (int, optional): Campfire ID campfire_id (int, optional): Campfire ID
Returns: Returns:
list: Matching chat lines list: Matching chat lines
""" """
@@ -526,12 +526,12 @@ class BasecampSearch:
"api_limitation": True, "api_limitation": True,
"title": "Campfire Search Limitation" "title": "Campfire Search Limitation"
}] }]
lines = self.client.get_campfire_lines(project_id, campfire_id) lines = self.client.get_campfire_lines(project_id, campfire_id)
if query and lines: if query and lines:
query = query.lower() query = query.lower()
filtered_lines = [] filtered_lines = []
for line in lines: for line in lines:
# Check content # Check content
@@ -539,20 +539,20 @@ class BasecampSearch:
content = line.get('content', '') content = line.get('content', '')
if content and query in content.lower(): if content and query in content.lower():
content_matched = True content_matched = True
# Check creator name # Check creator name
if not content_matched and line.get('creator'): if not content_matched and line.get('creator'):
creator_name = line['creator'].get('name', '') creator_name = line['creator'].get('name', '')
if creator_name and query in creator_name.lower(): if creator_name and query in creator_name.lower():
content_matched = True content_matched = True
# If matched, add to results # If matched, add to results
if content_matched: if content_matched:
filtered_lines.append(line) filtered_lines.append(line)
return filtered_lines return filtered_lines
return lines return lines
except Exception as e: except Exception as e:
logger.error(f"Error searching campfire lines: {str(e)}") logger.error(f"Error searching campfire lines: {str(e)}")
return [] return []

View File

@@ -1 +1 @@
# Tests package # Tests package

View File

@@ -18,7 +18,7 @@ def test_cli_server_initialize():
"method": "initialize", "method": "initialize",
"params": {} "params": {}
} }
# Start the CLI server process # Start the CLI server process
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "mcp_server_cli.py"], [sys.executable, "mcp_server_cli.py"],
@@ -27,17 +27,17 @@ def test_cli_server_initialize():
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True text=True
) )
try: try:
# Send the request # Send the request
stdout, stderr = proc.communicate( stdout, stderr = proc.communicate(
input=json.dumps(request) + "\n", input=json.dumps(request) + "\n",
timeout=10 timeout=10
) )
# Parse the response # Parse the response
response = json.loads(stdout.strip()) response = json.loads(stdout.strip())
# Check the response # Check the response
assert response["jsonrpc"] == "2.0" assert response["jsonrpc"] == "2.0"
assert response["id"] == 1 assert response["id"] == 1
@@ -45,7 +45,7 @@ def test_cli_server_initialize():
assert "protocolVersion" in response["result"] assert "protocolVersion" in response["result"]
assert "capabilities" in response["result"] assert "capabilities" in response["result"]
assert "serverInfo" in response["result"] assert "serverInfo" in response["result"]
finally: finally:
if proc.poll() is None: if proc.poll() is None:
proc.terminate() proc.terminate()
@@ -59,14 +59,14 @@ def test_cli_server_tools_list():
"method": "initialize", "method": "initialize",
"params": {} "params": {}
} }
tools_request = { tools_request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 2, "id": 2,
"method": "tools/list", "method": "tools/list",
"params": {} "params": {}
} }
# Start the CLI server process # Start the CLI server process
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "mcp_server_cli.py"], [sys.executable, "mcp_server_cli.py"],
@@ -75,7 +75,7 @@ def test_cli_server_tools_list():
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True text=True
) )
try: try:
# Send both requests # Send both requests
input_data = json.dumps(init_request) + "\n" + json.dumps(tools_request) + "\n" 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, input=input_data,
timeout=10 timeout=10
) )
# Parse responses (we get two lines) # Parse responses (we get two lines)
lines = stdout.strip().split('\n') lines = stdout.strip().split('\n')
assert len(lines) >= 2 assert len(lines) >= 2
# Check the tools list response (second response) # Check the tools list response (second response)
tools_response = json.loads(lines[1]) tools_response = json.loads(lines[1])
assert tools_response["jsonrpc"] == "2.0" assert tools_response["jsonrpc"] == "2.0"
assert tools_response["id"] == 2 assert tools_response["id"] == 2
assert "result" in tools_response assert "result" in tools_response
assert "tools" in tools_response["result"] assert "tools" in tools_response["result"]
tools = tools_response["result"]["tools"] tools = tools_response["result"]["tools"]
assert isinstance(tools, list) assert isinstance(tools, list)
assert len(tools) > 0 assert len(tools) > 0
# Check that expected tools are present # Check that expected tools are present
tool_names = [tool["name"] for tool in tools] tool_names = [tool["name"] for tool in tools]
expected_tools = ["get_projects", "search_basecamp", "get_todos"] expected_tools = ["get_projects", "search_basecamp", "get_todos"]
for expected_tool in expected_tools: for expected_tool in expected_tools:
assert expected_tool in tool_names assert expected_tool in tool_names
finally: finally:
if proc.poll() is None: if proc.poll() is None:
proc.terminate() proc.terminate()
@@ -113,9 +113,9 @@ def test_cli_server_tools_list():
@patch.object(token_storage, 'get_token') @patch.object(token_storage, 'get_token')
def test_cli_server_tool_call_no_auth(mock_get_token): def test_cli_server_tool_call_no_auth(mock_get_token):
"""Test tool call when not authenticated.""" """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 # that the CLI server handles authentication errors gracefully
# Create requests # Create requests
init_request = { init_request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -123,7 +123,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
"method": "initialize", "method": "initialize",
"params": {} "params": {}
} }
tool_request = { tool_request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 2, "id": 2,
@@ -133,7 +133,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
"arguments": {} "arguments": {}
} }
} }
# Start the CLI server process # Start the CLI server process
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "mcp_server_cli.py"], [sys.executable, "mcp_server_cli.py"],
@@ -142,7 +142,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True text=True
) )
try: try:
# Send both requests # Send both requests
input_data = json.dumps(init_request) + "\n" + json.dumps(tool_request) + "\n" 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, input=input_data,
timeout=10 timeout=10
) )
# Parse responses # Parse responses
lines = stdout.strip().split('\n') lines = stdout.strip().split('\n')
assert len(lines) >= 2 assert len(lines) >= 2
# Check the tool call response (second response) # Check the tool call response (second response)
tool_response = json.loads(lines[1]) tool_response = json.loads(lines[1])
assert tool_response["jsonrpc"] == "2.0" assert tool_response["jsonrpc"] == "2.0"
assert tool_response["id"] == 2 assert tool_response["id"] == 2
assert "result" in tool_response assert "result" in tool_response
assert "content" in tool_response["result"] assert "content" in tool_response["result"]
# The content should contain some kind of response (either data or error) # The content should contain some kind of response (either data or error)
content_text = tool_response["result"]["content"][0]["text"] content_text = tool_response["result"]["content"][0]["text"]
content_data = json.loads(content_text) content_data = json.loads(content_text)
# Since we have valid OAuth tokens, this might succeed or fail # Since we have valid OAuth tokens, this might succeed or fail
# We just check that we get a valid JSON response # We just check that we get a valid JSON response
assert isinstance(content_data, dict) assert isinstance(content_data, dict)
finally: finally:
if proc.poll() is None: if proc.poll() is None:
proc.terminate() proc.terminate()
@@ -183,7 +183,7 @@ def test_cli_server_invalid_method():
"method": "invalid_method", "method": "invalid_method",
"params": {} "params": {}
} }
# Start the CLI server process # Start the CLI server process
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "mcp_server_cli.py"], [sys.executable, "mcp_server_cli.py"],
@@ -192,23 +192,23 @@ def test_cli_server_invalid_method():
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True text=True
) )
try: try:
# Send the request # Send the request
stdout, stderr = proc.communicate( stdout, stderr = proc.communicate(
input=json.dumps(request) + "\n", input=json.dumps(request) + "\n",
timeout=10 timeout=10
) )
# Parse the response # Parse the response
response = json.loads(stdout.strip()) response = json.loads(stdout.strip())
# Check the error response # Check the error response
assert response["jsonrpc"] == "2.0" assert response["jsonrpc"] == "2.0"
assert response["id"] == 1 assert response["id"] == 1
assert "error" in response assert "error" in response
assert response["error"]["code"] == -32601 # Method not found assert response["error"]["code"] == -32601 # Method not found
finally: finally:
if proc.poll() is None: if proc.poll() is None:
proc.terminate() proc.terminate()

View File

@@ -42,7 +42,7 @@ def _write_tokens(tokens):
"""Write tokens to storage.""" """Write tokens to storage."""
# Create directory for the token file if it doesn't exist # 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) os.makedirs(os.path.dirname(TOKEN_FILE) if os.path.dirname(TOKEN_FILE) else '.', exist_ok=True)
basecamp_data_to_write = tokens.get('basecamp', {}) basecamp_data_to_write = tokens.get('basecamp', {})
updated_at_to_write = basecamp_data_to_write.get('updated_at') 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}") _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 # Set secure permissions on the file
with open(TOKEN_FILE, 'w') as f: with open(TOKEN_FILE, 'w') as f:
json.dump(tokens, f, indent=2) json.dump(tokens, f, indent=2)
# Set permissions to only allow the current user to read/write # Set permissions to only allow the current user to read/write
try: try:
os.chmod(TOKEN_FILE, 0o600) 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): def store_token(access_token, refresh_token=None, expires_in=None, account_id=None):
""" """
Store OAuth tokens securely. Store OAuth tokens securely.
Args: Args:
access_token (str): The OAuth access token access_token (str): The OAuth access token
refresh_token (str, optional): The OAuth refresh token refresh_token (str, optional): The OAuth refresh token
expires_in (int, optional): Token expiration time in seconds expires_in (int, optional): Token expiration time in seconds
account_id (str, optional): The Basecamp account ID account_id (str, optional): The Basecamp account ID
Returns: Returns:
bool: True if the token was stored successfully bool: True if the token was stored successfully
""" """
if not access_token: if not access_token:
return False # Don't store empty tokens return False # Don't store empty tokens
with _lock: with _lock:
tokens = _read_tokens() tokens = _read_tokens()
# Calculate expiration time # Calculate expiration time
expires_at = None expires_at = None
if expires_in: if expires_in:
expires_at = (datetime.now() + timedelta(seconds=expires_in)).isoformat() expires_at = (datetime.now() + timedelta(seconds=expires_in)).isoformat()
# Store the token with metadata # Store the token with metadata
tokens['basecamp'] = { tokens['basecamp'] = {
'access_token': access_token, '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, 'expires_at': expires_at,
'updated_at': datetime.now().isoformat() 'updated_at': datetime.now().isoformat()
} }
_write_tokens(tokens) _write_tokens(tokens)
return True return True
def get_token(): def get_token():
""" """
Get the stored OAuth token. Get the stored OAuth token.
Returns: Returns:
dict: Token information or None if not found dict: Token information or None if not found
""" """
@@ -107,17 +107,17 @@ def get_token():
def is_token_expired(): def is_token_expired():
""" """
Check if the stored token is expired. Check if the stored token is expired.
Returns: Returns:
bool: True if the token is expired or not found bool: True if the token is expired or not found
""" """
with _lock: with _lock:
tokens = _read_tokens() tokens = _read_tokens()
token_data = tokens.get('basecamp') token_data = tokens.get('basecamp')
if not token_data or not token_data.get('expires_at'): if not token_data or not token_data.get('expires_at'):
return True return True
try: try:
expires_at = datetime.fromisoformat(token_data['expires_at']) expires_at = datetime.fromisoformat(token_data['expires_at'])
# Add a buffer of 5 minutes to account for clock differences # Add a buffer of 5 minutes to account for clock differences
@@ -130,4 +130,4 @@ def clear_tokens():
with _lock: with _lock:
if os.path.exists(TOKEN_FILE): if os.path.exists(TOKEN_FILE):
os.remove(TOKEN_FILE) os.remove(TOKEN_FILE)
return True return True