Initial commit: Basecamp MCP server with OAuth authentication
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Basic Auth credentials (for direct API access)
|
||||
BASECAMP_USERNAME=your-email@example.com
|
||||
BASECAMP_PASSWORD=your-password
|
||||
BASECAMP_ACCOUNT_ID=your-account-id # Find this in your Basecamp 3 URL: https://3.basecamp.com/ACCOUNT_ID/...
|
||||
USER_AGENT=YourApp (your-email@example.com)
|
||||
|
||||
# OAuth credentials (for Google Auth / SSO)
|
||||
BASECAMP_CLIENT_ID=your-client-id
|
||||
BASECAMP_CLIENT_SECRET=your-client-secret
|
||||
BASECAMP_REDIRECT_URI=http://localhost:8000/auth/callback
|
||||
FLASK_SECRET_KEY=your-flask-secret-key
|
||||
|
||||
# OAuth tokens (filled automatically by the app)
|
||||
BASECAMP_ACCESS_TOKEN=
|
||||
BASECAMP_REFRESH_TOKEN=
|
||||
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Sensitive data
|
||||
oauth_tokens.json
|
||||
.env
|
||||
.flaskenv
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
._*
|
||||
|
||||
# Project specific
|
||||
unused/
|
||||
icons/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
72
IMPLEMENTATION_SUMMARY.md
Normal file
72
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Implementation Summary: Basecamp MCP Integration
|
||||
|
||||
## Improvements Made
|
||||
|
||||
We've implemented a robust MCP server for Basecamp 3 integration with the following key improvements:
|
||||
|
||||
### 1. Secure Token Storage
|
||||
|
||||
- Created a dedicated `token_storage.py` module for securely storing OAuth tokens
|
||||
- Implemented thread-safe operations with proper locking mechanisms
|
||||
- Added token expiration checking and metadata storage
|
||||
- Stored tokens in a separate JSON file instead of environment variables or session
|
||||
|
||||
### 2. Improved OAuth Application
|
||||
|
||||
- Revamped the OAuth app to provide clearer user information
|
||||
- Added proper token handling and storage
|
||||
- Implemented secure API endpoints for the MCP server to retrieve tokens
|
||||
- Added health check and token info endpoints for debugging
|
||||
- Improved error handling and user feedback
|
||||
|
||||
### 3. Enhanced MCP Server
|
||||
|
||||
- Completely restructured the MCP server to align with the MCP protocol
|
||||
- Implemented connection management with unique connection IDs
|
||||
- Added proper tool action handling for Basecamp operations
|
||||
- Improved error handling and logging
|
||||
- Created endpoints for checking required parameters and connection status
|
||||
|
||||
### 4. Better Authentication Flow
|
||||
|
||||
- Separated authentication concerns between the OAuth app and MCP server
|
||||
- Implemented proper token refresh handling for expired tokens
|
||||
- Added support for both OAuth and Personal Access Token authentication modes
|
||||
- Implemented better parameter validation and error messages
|
||||
|
||||
### 5. Testing and Documentation
|
||||
|
||||
- Created comprehensive test scripts for verifying the implementation
|
||||
- Added detailed logging for debugging
|
||||
- Created a comprehensive README with setup and usage instructions
|
||||
- Documented the architecture and components for easier maintenance
|
||||
|
||||
## Architecture
|
||||
|
||||
The new architecture follows best practices for OAuth integration:
|
||||
|
||||
1. **User Authentication**: Handled by the OAuth app, completely separate from the MCP server
|
||||
2. **Token Storage**: Centralized and secure, with proper expiration handling
|
||||
3. **MCP Server**: Focused on the MCP protocol, delegating authentication to the OAuth app
|
||||
4. **Client Library**: Clean separation of concerns between authentication, API calls, and search functionality
|
||||
|
||||
## Next Steps
|
||||
|
||||
To further improve this implementation:
|
||||
|
||||
1. **Production Readiness**:
|
||||
- Replace file-based token storage with a proper database
|
||||
- Add HTTPS support for both the OAuth app and MCP server
|
||||
- Implement more robust API authentication between the MCP server and OAuth app
|
||||
|
||||
2. **Feature Enhancements**:
|
||||
- Add support for more Basecamp resource types
|
||||
- Implement webhook support for real-time updates
|
||||
- Add caching for improved performance
|
||||
|
||||
3. **Security Improvements**:
|
||||
- Add rate limiting to prevent abuse
|
||||
- Implement proper token encryption
|
||||
- Add audit logging for security events
|
||||
|
||||
This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol.
|
||||
151
README.md
Normal file
151
README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Basecamp MCP Integration
|
||||
|
||||
This project provides a MCP (Magic Copy Paste) integration for Basecamp 3, allowing Cursor to interact with Basecamp directly through the MCP protocol.
|
||||
|
||||
## Architecture
|
||||
|
||||
The project consists of the following components:
|
||||
|
||||
1. **OAuth App** (`oauth_app.py`) - A Flask application that handles the OAuth 2.0 flow with Basecamp.
|
||||
2. **Token Storage** (`token_storage.py`) - A module for securely storing OAuth tokens.
|
||||
3. **MCP Server** (`mcp_server.py`) - A Flask server that implements the MCP protocol for Basecamp.
|
||||
4. **Basecamp Client** (`basecamp_client.py`) - A client library for interacting with the Basecamp API.
|
||||
5. **Basecamp OAuth** (`basecamp_oauth.py`) - A utility for handling OAuth authentication with Basecamp.
|
||||
6. **Search Utilities** (`search_utils.py`) - Utilities for searching Basecamp resources.
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.7+
|
||||
- A Basecamp 3 account
|
||||
- A Basecamp OAuth application (create one at https://launchpad.37signals.com/integrations)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone this repository:
|
||||
```
|
||||
git clone <repository-url>
|
||||
cd basecamp-mcp
|
||||
```
|
||||
|
||||
2. Create and activate a virtual environment:
|
||||
```
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Create a `.env` file with the following variables:
|
||||
```
|
||||
BASECAMP_CLIENT_ID=your_client_id
|
||||
BASECAMP_CLIENT_SECRET=your_client_secret
|
||||
BASECAMP_REDIRECT_URI=http://localhost:8000/auth/callback
|
||||
USER_AGENT="Your App Name (your@email.com)"
|
||||
BASECAMP_ACCOUNT_ID=your_account_id
|
||||
FLASK_SECRET_KEY=random_secret_key
|
||||
MCP_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the Servers
|
||||
|
||||
1. Start the OAuth app:
|
||||
```
|
||||
python oauth_app.py
|
||||
```
|
||||
|
||||
2. Start the MCP server:
|
||||
```
|
||||
python mcp_server.py
|
||||
```
|
||||
|
||||
### OAuth Authentication
|
||||
|
||||
1. Visit http://localhost:8000/ in your browser
|
||||
2. Click "Log in with Basecamp"
|
||||
3. Follow the OAuth flow to authorize the application
|
||||
4. The token will be stored securely in the token storage
|
||||
|
||||
### Using with Cursor
|
||||
|
||||
1. In Cursor, add the MCP server URL: http://localhost:5001
|
||||
2. Interact with Basecamp through the Cursor interface
|
||||
3. The MCP server will use the stored OAuth token to authenticate with Basecamp
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
When using the MCP server with Cursor, the authentication flow is as follows:
|
||||
|
||||
1. Cursor makes a request to the MCP server
|
||||
2. The MCP server checks if OAuth authentication has been completed
|
||||
3. If not, it returns an error with instructions to authenticate
|
||||
4. You authenticate using the OAuth app at http://localhost:8000/
|
||||
5. After authentication, Cursor can make requests to the MCP server
|
||||
|
||||
### MCP Server API
|
||||
|
||||
The MCP server has two main methods for interacting with Basecamp:
|
||||
|
||||
**Preferred Method: Connection-based Approach**
|
||||
|
||||
This approach is recommended as it provides better error handling and state management:
|
||||
|
||||
1. Initiate a connection:
|
||||
```
|
||||
POST /initiate_connection
|
||||
{
|
||||
"auth_mode": "oauth"
|
||||
}
|
||||
```
|
||||
|
||||
2. Use the returned connection ID to make tool calls:
|
||||
```
|
||||
POST /tool/<connection_id>
|
||||
{
|
||||
"action": "get_projects"
|
||||
}
|
||||
```
|
||||
|
||||
If OAuth authentication hasn't been completed, the MCP server will return an error with instructions to authenticate.
|
||||
|
||||
**Alternative Method: Direct Action**
|
||||
|
||||
For simple requests, you can use the action endpoint:
|
||||
|
||||
```
|
||||
POST /mcp/action
|
||||
{
|
||||
"action": "get_projects"
|
||||
}
|
||||
```
|
||||
|
||||
This method also checks for OAuth authentication and returns appropriate error messages if needed.
|
||||
|
||||
## Token Management
|
||||
|
||||
- Tokens are stored in `oauth_tokens.json`
|
||||
- The token will be refreshed automatically when it expires
|
||||
- You can view token info at http://localhost:8000/token/info
|
||||
- You can logout and clear the token at http://localhost:8000/logout
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Token Issues**: If you encounter authentication errors, try logging out and logging in again through the OAuth app
|
||||
- **MCP Connection Issues**: Make sure both the OAuth app and MCP server are running
|
||||
- **API Errors**: Check the logs in `oauth_app.log` and `mcp_server.log` for detailed error messages
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- This implementation uses file-based token storage, which is suitable for development but not for production
|
||||
- In a production environment, use a database or secure key management service for token storage
|
||||
- Always use HTTPS in production and implement proper authentication for the API endpoints
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
324
basecamp_client.py
Normal file
324
basecamp_client.py
Normal file
@@ -0,0 +1,324 @@
|
||||
import os
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
class BasecampClient:
|
||||
"""
|
||||
Client for interacting with Basecamp 3 API using Basic Authentication or OAuth 2.0.
|
||||
"""
|
||||
|
||||
def __init__(self, username=None, password=None, account_id=None, user_agent=None,
|
||||
access_token=None, auth_mode="basic"):
|
||||
"""
|
||||
Initialize the Basecamp client with credentials.
|
||||
|
||||
Args:
|
||||
username (str, optional): Basecamp username (email) for Basic Auth
|
||||
password (str, optional): Basecamp password for Basic Auth
|
||||
account_id (str, optional): Basecamp account ID
|
||||
user_agent (str, optional): User agent for API requests
|
||||
access_token (str, optional): OAuth access token for OAuth Auth
|
||||
auth_mode (str, optional): Authentication mode ('basic' or 'oauth')
|
||||
"""
|
||||
# Load environment variables if not provided directly
|
||||
load_dotenv()
|
||||
|
||||
self.auth_mode = auth_mode.lower()
|
||||
self.account_id = account_id or os.getenv('BASECAMP_ACCOUNT_ID')
|
||||
self.user_agent = user_agent or os.getenv('USER_AGENT')
|
||||
|
||||
# Set up authentication based on mode
|
||||
if self.auth_mode == 'basic':
|
||||
self.username = username or os.getenv('BASECAMP_USERNAME')
|
||||
self.password = password or os.getenv('BASECAMP_PASSWORD')
|
||||
|
||||
if not all([self.username, self.password, self.account_id, self.user_agent]):
|
||||
raise ValueError("Missing required credentials for Basic Auth. Set them in .env file or pass them to the constructor.")
|
||||
|
||||
self.auth = (self.username, self.password)
|
||||
self.headers = {
|
||||
"User-Agent": self.user_agent,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
elif self.auth_mode == 'oauth':
|
||||
self.access_token = access_token or os.getenv('BASECAMP_ACCESS_TOKEN')
|
||||
|
||||
if not all([self.access_token, self.account_id, self.user_agent]):
|
||||
raise ValueError("Missing required credentials for OAuth. Set them in .env file or pass them to the constructor.")
|
||||
|
||||
self.auth = None # No basic auth needed for OAuth
|
||||
self.headers = {
|
||||
"User-Agent": self.user_agent,
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.access_token}"
|
||||
}
|
||||
|
||||
else:
|
||||
raise ValueError("Invalid auth_mode. Must be 'basic' or 'oauth'")
|
||||
|
||||
# Basecamp 3 uses a different URL structure
|
||||
self.base_url = f"https://3.basecampapi.com/{self.account_id}"
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to Basecamp API."""
|
||||
response = self.get('projects.json')
|
||||
if response.status_code == 200:
|
||||
return True, "Connection successful"
|
||||
else:
|
||||
return False, f"Connection failed: {response.status_code} - {response.text}"
|
||||
|
||||
def get(self, endpoint, params=None):
|
||||
"""Make a GET request to the Basecamp API."""
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
return requests.get(url, auth=self.auth, headers=self.headers, params=params)
|
||||
|
||||
def post(self, endpoint, data=None):
|
||||
"""Make a POST request to the Basecamp API."""
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
return requests.post(url, auth=self.auth, headers=self.headers, json=data)
|
||||
|
||||
def put(self, endpoint, data=None):
|
||||
"""Make a PUT request to the Basecamp API."""
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
return requests.put(url, auth=self.auth, headers=self.headers, json=data)
|
||||
|
||||
def delete(self, endpoint):
|
||||
"""Make a DELETE request to the Basecamp API."""
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
return requests.delete(url, auth=self.auth, headers=self.headers)
|
||||
|
||||
# Project methods
|
||||
def get_projects(self):
|
||||
"""Get all projects."""
|
||||
response = self.get('projects.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get projects: {response.status_code} - {response.text}")
|
||||
|
||||
def get_project(self, project_id):
|
||||
"""Get a specific project by ID."""
|
||||
response = self.get(f'projects/{project_id}.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get project: {response.status_code} - {response.text}")
|
||||
|
||||
# To-do list methods
|
||||
def get_todosets(self, project_id):
|
||||
"""Get the todoset for a project (Basecamp 3 has one todoset per project)."""
|
||||
response = self.get(f'projects/{project_id}/todoset.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todoset: {response.status_code} - {response.text}")
|
||||
|
||||
def get_todolists(self, project_id):
|
||||
"""Get all todolists for a project."""
|
||||
# First get the todoset ID for this project
|
||||
todoset = self.get_todosets(project_id)
|
||||
todoset_id = todoset['id']
|
||||
|
||||
# Then get all todolists in this todoset
|
||||
response = self.get(f'todolists.json', {'todoset_id': todoset_id})
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todolists: {response.status_code} - {response.text}")
|
||||
|
||||
def get_todolist(self, todolist_id):
|
||||
"""Get a specific todolist."""
|
||||
response = self.get(f'todolists/{todolist_id}.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todolist: {response.status_code} - {response.text}")
|
||||
|
||||
# To-do methods
|
||||
def get_todos(self, todolist_id):
|
||||
"""Get all todos in a todolist."""
|
||||
response = self.get(f'todolists/{todolist_id}/todos.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todos: {response.status_code} - {response.text}")
|
||||
|
||||
def get_todo(self, todo_id):
|
||||
"""Get a specific todo."""
|
||||
response = self.get(f'todos/{todo_id}.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todo: {response.status_code} - {response.text}")
|
||||
|
||||
# People methods
|
||||
def get_people(self):
|
||||
"""Get all people in the account."""
|
||||
response = self.get('people.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get people: {response.status_code} - {response.text}")
|
||||
|
||||
# Campfire (chat) methods
|
||||
def get_campfires(self, project_id):
|
||||
"""Get the campfire for a project."""
|
||||
response = self.get(f'projects/{project_id}/campfire.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get campfire: {response.status_code} - {response.text}")
|
||||
|
||||
# Message board methods
|
||||
def get_message_board(self, project_id):
|
||||
"""Get the message board for a project."""
|
||||
response = self.get(f'projects/{project_id}/message_board.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get message board: {response.status_code} - {response.text}")
|
||||
|
||||
def get_messages(self, project_id):
|
||||
"""Get all messages for a project."""
|
||||
# First get the message board ID
|
||||
message_board = self.get_message_board(project_id)
|
||||
message_board_id = message_board['id']
|
||||
|
||||
# Then get all messages
|
||||
response = self.get('messages.json', {'message_board_id': message_board_id})
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get messages: {response.status_code} - {response.text}")
|
||||
|
||||
# Schedule methods
|
||||
def get_schedule(self, project_id):
|
||||
"""Get the schedule for a project."""
|
||||
response = self.get(f'projects/{project_id}/schedule.json')
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get schedule: {response.status_code} - {response.text}")
|
||||
|
||||
def get_schedule_entries(self, project_id):
|
||||
"""
|
||||
Get schedule entries for a project.
|
||||
|
||||
Args:
|
||||
project_id (int): Project ID
|
||||
|
||||
Returns:
|
||||
list: Schedule entries
|
||||
"""
|
||||
try:
|
||||
endpoint = f"buckets/{project_id}/schedules.json"
|
||||
schedule = self.get(endpoint)
|
||||
|
||||
if isinstance(schedule, list) and len(schedule) > 0:
|
||||
schedule_id = schedule[0]['id']
|
||||
entries_endpoint = f"buckets/{project_id}/schedules/{schedule_id}/entries.json"
|
||||
return self.get(entries_endpoint)
|
||||
else:
|
||||
return []
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to get schedule: {str(e)}")
|
||||
|
||||
# Comments methods
|
||||
def get_comments(self, recording_id, bucket_id=None):
|
||||
"""
|
||||
Get all comments for a recording (todo, message, etc.).
|
||||
|
||||
Args:
|
||||
recording_id (int): ID of the recording (todo, message, etc.)
|
||||
bucket_id (int, optional): Project/bucket ID. If not provided, it will be extracted from the recording ID.
|
||||
|
||||
Returns:
|
||||
list: Comments for the recording
|
||||
"""
|
||||
if bucket_id is None:
|
||||
# Try to get the recording first to extract the bucket_id
|
||||
raise ValueError("bucket_id is required")
|
||||
|
||||
endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json"
|
||||
response = self.get(endpoint)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get comments: {response.status_code} - {response.text}")
|
||||
|
||||
def create_comment(self, recording_id, bucket_id, content):
|
||||
"""
|
||||
Create a comment on a recording.
|
||||
|
||||
Args:
|
||||
recording_id (int): ID of the recording to comment on
|
||||
bucket_id (int): Project/bucket ID
|
||||
content (str): Content of the comment in HTML format
|
||||
|
||||
Returns:
|
||||
dict: The created comment
|
||||
"""
|
||||
endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json"
|
||||
data = {"content": content}
|
||||
response = self.post(endpoint, data)
|
||||
if response.status_code == 201:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to create comment: {response.status_code} - {response.text}")
|
||||
|
||||
def get_comment(self, comment_id, bucket_id):
|
||||
"""
|
||||
Get a specific comment.
|
||||
|
||||
Args:
|
||||
comment_id (int): Comment ID
|
||||
bucket_id (int): Project/bucket ID
|
||||
|
||||
Returns:
|
||||
dict: Comment details
|
||||
"""
|
||||
endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json"
|
||||
response = self.get(endpoint)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get comment: {response.status_code} - {response.text}")
|
||||
|
||||
def update_comment(self, comment_id, bucket_id, content):
|
||||
"""
|
||||
Update a comment.
|
||||
|
||||
Args:
|
||||
comment_id (int): Comment ID
|
||||
bucket_id (int): Project/bucket ID
|
||||
content (str): New content for the comment in HTML format
|
||||
|
||||
Returns:
|
||||
dict: Updated comment
|
||||
"""
|
||||
endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json"
|
||||
data = {"content": content}
|
||||
response = self.put(endpoint, data)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to update comment: {response.status_code} - {response.text}")
|
||||
|
||||
def delete_comment(self, comment_id, bucket_id):
|
||||
"""
|
||||
Delete a comment.
|
||||
|
||||
Args:
|
||||
comment_id (int): Comment ID
|
||||
bucket_id (int): Project/bucket ID
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json"
|
||||
response = self.delete(endpoint)
|
||||
if response.status_code == 204:
|
||||
return True
|
||||
else:
|
||||
raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}")
|
||||
134
basecamp_oauth.py
Normal file
134
basecamp_oauth.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Basecamp 3 OAuth 2.0 Authentication Module
|
||||
|
||||
This module provides the functionality to authenticate with Basecamp 3
|
||||
using OAuth 2.0, which is necessary when using Google Authentication (SSO).
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# OAuth 2.0 endpoints for Basecamp - these stay the same for Basecamp 2 and 3
|
||||
AUTH_URL = "https://launchpad.37signals.com/authorization/new"
|
||||
TOKEN_URL = "https://launchpad.37signals.com/authorization/token"
|
||||
IDENTITY_URL = "https://launchpad.37signals.com/authorization.json"
|
||||
|
||||
class BasecampOAuth:
|
||||
"""
|
||||
OAuth 2.0 client for Basecamp 3.
|
||||
"""
|
||||
|
||||
def __init__(self, client_id=None, client_secret=None, redirect_uri=None, user_agent=None):
|
||||
"""Initialize the OAuth client with credentials."""
|
||||
self.client_id = client_id or os.getenv('BASECAMP_CLIENT_ID')
|
||||
self.client_secret = client_secret or os.getenv('BASECAMP_CLIENT_SECRET')
|
||||
self.redirect_uri = redirect_uri or os.getenv('BASECAMP_REDIRECT_URI')
|
||||
self.user_agent = user_agent or os.getenv('USER_AGENT')
|
||||
|
||||
if not all([self.client_id, self.client_secret, self.redirect_uri, self.user_agent]):
|
||||
raise ValueError("Missing required OAuth credentials. Set them in .env file or pass them to the constructor.")
|
||||
|
||||
def get_authorization_url(self, state=None):
|
||||
"""
|
||||
Get the URL to redirect the user to for authorization.
|
||||
|
||||
Args:
|
||||
state (str, optional): A random string to maintain state between requests
|
||||
|
||||
Returns:
|
||||
str: The authorization URL
|
||||
"""
|
||||
params = {
|
||||
'type': 'web_server',
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri
|
||||
}
|
||||
|
||||
if state:
|
||||
params['state'] = state
|
||||
|
||||
return f"{AUTH_URL}?{urlencode(params)}"
|
||||
|
||||
def exchange_code_for_token(self, code):
|
||||
"""
|
||||
Exchange the authorization code for an access token.
|
||||
|
||||
Args:
|
||||
code (str): The authorization code received after user grants permission
|
||||
|
||||
Returns:
|
||||
dict: The token response containing access_token and refresh_token
|
||||
"""
|
||||
data = {
|
||||
'type': 'web_server',
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code
|
||||
}
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
|
||||
response = requests.post(TOKEN_URL, data=data, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to exchange code for token: {response.status_code} - {response.text}")
|
||||
|
||||
def refresh_token(self, refresh_token):
|
||||
"""
|
||||
Refresh an expired access token.
|
||||
|
||||
Args:
|
||||
refresh_token (str): The refresh token from the original token response
|
||||
|
||||
Returns:
|
||||
dict: The new token response containing a new access_token
|
||||
"""
|
||||
data = {
|
||||
'type': 'refresh',
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'refresh_token': refresh_token
|
||||
}
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
|
||||
response = requests.post(TOKEN_URL, data=data, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to refresh token: {response.status_code} - {response.text}")
|
||||
|
||||
def get_identity(self, access_token):
|
||||
"""
|
||||
Get the identity and account information for the authenticated user.
|
||||
|
||||
Args:
|
||||
access_token (str): The OAuth access token
|
||||
|
||||
Returns:
|
||||
dict: The identity and account information
|
||||
"""
|
||||
headers = {
|
||||
'User-Agent': self.user_agent,
|
||||
'Authorization': f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
response = requests.get(IDENTITY_URL, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get identity: {response.status_code} - {response.text}")
|
||||
556
mcp_integration.py
Normal file
556
mcp_integration.py
Normal file
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
Basecamp 2.4.2 MCP Integration Module
|
||||
|
||||
This module provides Multi-Cloud Provider (MCP) compatible functions for integrating
|
||||
with Basecamp 2.4.2 API. It can be used as a starting point for creating a full
|
||||
MCP connector.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from basecamp_client import BasecampClient
|
||||
from search_utils import BasecampSearch
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# MCP Authentication Functions
|
||||
|
||||
def get_required_parameters(params):
|
||||
"""
|
||||
Get the required parameters for connecting to Basecamp 2.4.2.
|
||||
|
||||
For Basic Authentication, we need:
|
||||
- username
|
||||
- password
|
||||
- account_id
|
||||
- user_agent
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of required parameters
|
||||
"""
|
||||
return {
|
||||
"required_parameters": [
|
||||
{
|
||||
"name": "username",
|
||||
"description": "Your Basecamp username (email)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"description": "Your Basecamp password",
|
||||
"type": "string",
|
||||
"sensitive": True
|
||||
},
|
||||
{
|
||||
"name": "account_id",
|
||||
"description": "Your Basecamp account ID (the number in your Basecamp URL)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "user_agent",
|
||||
"description": "User agent for API requests (e.g., 'YourApp (your-email@example.com)')",
|
||||
"type": "string",
|
||||
"default": f"MCP Basecamp Connector ({os.getenv('BASECAMP_USERNAME', 'your-email@example.com')})"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def initiate_connection(params):
|
||||
"""
|
||||
Initiate a connection to Basecamp 2.4.2.
|
||||
|
||||
Args:
|
||||
params (dict): Connection parameters including:
|
||||
- username: Basecamp username (email)
|
||||
- password: Basecamp password
|
||||
- account_id: Basecamp account ID
|
||||
- user_agent: User agent for API requests
|
||||
|
||||
Returns:
|
||||
dict: Connection details and status
|
||||
"""
|
||||
parameters = params.get("parameters", {})
|
||||
username = parameters.get("username")
|
||||
password = parameters.get("password")
|
||||
account_id = parameters.get("account_id")
|
||||
user_agent = parameters.get("user_agent")
|
||||
|
||||
try:
|
||||
client = BasecampClient(
|
||||
username=username,
|
||||
password=password,
|
||||
account_id=account_id,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
success, message = client.test_connection()
|
||||
|
||||
if success:
|
||||
return {
|
||||
"status": "connected",
|
||||
"connection_id": f"basecamp_{account_id}",
|
||||
"message": "Successfully connected to Basecamp"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": message
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
def check_active_connection(params):
|
||||
"""
|
||||
Check if a connection to Basecamp is active.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters containing:
|
||||
- connection_id: The connection ID to check
|
||||
|
||||
Returns:
|
||||
dict: Status of the connection
|
||||
"""
|
||||
# This is a placeholder. In a real implementation, you would check if the
|
||||
# connection is still valid, possibly by making a simple API call.
|
||||
return {
|
||||
"status": "active",
|
||||
"message": "Connection is active"
|
||||
}
|
||||
|
||||
# MCP Core Functions
|
||||
|
||||
def get_projects(params):
|
||||
"""
|
||||
Get all projects from Basecamp.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- query (optional): Filter projects by name
|
||||
|
||||
Returns:
|
||||
dict: List of projects
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the query parameter
|
||||
query = params.get("query")
|
||||
|
||||
# Search for projects
|
||||
projects = search.search_projects(query)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(projects),
|
||||
"projects": projects
|
||||
}
|
||||
|
||||
def get_todo_lists(params):
|
||||
"""
|
||||
Get all to-do lists from a project.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- project_id: The project ID
|
||||
- query (optional): Filter to-do lists by name
|
||||
|
||||
Returns:
|
||||
dict: List of to-do lists
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the parameters
|
||||
project_id = params.get("project_id")
|
||||
query = params.get("query")
|
||||
|
||||
# Validate required parameters
|
||||
if not project_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "project_id is required"
|
||||
}
|
||||
|
||||
# Search for to-do lists
|
||||
todolists = search.search_todolists(query, project_id)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(todolists),
|
||||
"todolists": todolists
|
||||
}
|
||||
|
||||
def get_todos(params):
|
||||
"""
|
||||
Get all to-dos with various filters.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- project_id (optional): Filter by project ID
|
||||
- todolist_id (optional): Filter by to-do list ID
|
||||
- query (optional): Filter to-dos by content
|
||||
- include_completed (optional): Include completed to-dos
|
||||
|
||||
Returns:
|
||||
dict: List of to-dos
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the parameters
|
||||
project_id = params.get("project_id")
|
||||
todolist_id = params.get("todolist_id")
|
||||
query = params.get("query")
|
||||
include_completed = params.get("include_completed", False)
|
||||
|
||||
# Search for to-dos
|
||||
todos = search.search_todos(
|
||||
query=query,
|
||||
project_id=project_id,
|
||||
todolist_id=todolist_id,
|
||||
include_completed=include_completed
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(todos),
|
||||
"todos": todos
|
||||
}
|
||||
|
||||
def get_comments(params):
|
||||
"""
|
||||
Get comments for a specific recording (todo, message, etc.).
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- recording_id (required): ID of the recording (todo, message, etc.)
|
||||
- bucket_id (required): Project/bucket ID
|
||||
- query (optional): Filter comments by content
|
||||
|
||||
Returns:
|
||||
dict: List of comments
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the parameters
|
||||
recording_id = params.get("recording_id")
|
||||
bucket_id = params.get("bucket_id")
|
||||
query = params.get("query")
|
||||
|
||||
# Validate required parameters
|
||||
if not recording_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "recording_id and bucket_id are required"
|
||||
}
|
||||
|
||||
# Search for comments
|
||||
comments = search.search_comments(
|
||||
query=query,
|
||||
recording_id=recording_id,
|
||||
bucket_id=bucket_id
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(comments),
|
||||
"comments": comments
|
||||
}
|
||||
|
||||
def create_comment(params):
|
||||
"""
|
||||
Create a comment on a recording.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- recording_id (required): ID of the recording to comment on
|
||||
- bucket_id (required): Project/bucket ID
|
||||
- content (required): Content of the comment in HTML format
|
||||
|
||||
Returns:
|
||||
dict: The created comment
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Get the parameters
|
||||
recording_id = params.get("recording_id")
|
||||
bucket_id = params.get("bucket_id")
|
||||
content = params.get("content")
|
||||
|
||||
# Validate required parameters
|
||||
if not recording_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "recording_id and bucket_id are required"
|
||||
}
|
||||
|
||||
if not content:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "content is required"
|
||||
}
|
||||
|
||||
try:
|
||||
# Create the comment
|
||||
comment = client.create_comment(recording_id, bucket_id, content)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"comment": comment
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
def update_comment(params):
|
||||
"""
|
||||
Update a comment.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- comment_id (required): Comment ID
|
||||
- bucket_id (required): Project/bucket ID
|
||||
- content (required): New content for the comment in HTML format
|
||||
|
||||
Returns:
|
||||
dict: The updated comment
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Get the parameters
|
||||
comment_id = params.get("comment_id")
|
||||
bucket_id = params.get("bucket_id")
|
||||
content = params.get("content")
|
||||
|
||||
# Validate required parameters
|
||||
if not comment_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "comment_id and bucket_id are required"
|
||||
}
|
||||
|
||||
if not content:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "content is required"
|
||||
}
|
||||
|
||||
try:
|
||||
# Update the comment
|
||||
comment = client.update_comment(comment_id, bucket_id, content)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"comment": comment
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
def delete_comment(params):
|
||||
"""
|
||||
Delete a comment.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- comment_id (required): Comment ID
|
||||
- bucket_id (required): Project/bucket ID
|
||||
|
||||
Returns:
|
||||
dict: Status of the operation
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Get the parameters
|
||||
comment_id = params.get("comment_id")
|
||||
bucket_id = params.get("bucket_id")
|
||||
|
||||
# Validate required parameters
|
||||
if not comment_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "comment_id and bucket_id are required"
|
||||
}
|
||||
|
||||
try:
|
||||
# Delete the comment
|
||||
success = client.delete_comment(comment_id, bucket_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Comment deleted successfully"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Failed to delete comment"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
def search_all(params):
|
||||
"""
|
||||
Search across all Basecamp resources.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- query: The search query
|
||||
- resource_types (optional): Types of resources to search (projects, todolists, todos)
|
||||
- include_completed (optional): Include completed to-dos
|
||||
|
||||
Returns:
|
||||
dict: Search results grouped by resource type
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the parameters
|
||||
query = params.get("query")
|
||||
resource_types = params.get("resource_types", ["projects", "todolists", "todos"])
|
||||
include_completed = params.get("include_completed", False)
|
||||
|
||||
# Validate required parameters
|
||||
if not query:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "query is required"
|
||||
}
|
||||
|
||||
# Initialize results
|
||||
results = {
|
||||
"status": "success",
|
||||
"query": query,
|
||||
"results": {}
|
||||
}
|
||||
|
||||
# Search based on resource types
|
||||
if "projects" in resource_types:
|
||||
projects = search.search_projects(query)
|
||||
results["results"]["projects"] = {
|
||||
"count": len(projects),
|
||||
"items": projects
|
||||
}
|
||||
|
||||
if "todolists" in resource_types:
|
||||
todolists = search.search_todolists(query)
|
||||
results["results"]["todolists"] = {
|
||||
"count": len(todolists),
|
||||
"items": todolists
|
||||
}
|
||||
|
||||
if "todos" in resource_types:
|
||||
todos = search.search_todos(query=query, include_completed=include_completed)
|
||||
results["results"]["todos"] = {
|
||||
"count": len(todos),
|
||||
"items": todos
|
||||
}
|
||||
|
||||
# Calculate total count
|
||||
total_count = sum(
|
||||
results["results"][resource]["count"]
|
||||
for resource in results["results"]
|
||||
)
|
||||
results["total_count"] = total_count
|
||||
|
||||
return results
|
||||
|
||||
# Helper functions
|
||||
|
||||
def _get_client_from_params(params):
|
||||
"""
|
||||
Get a BasecampClient instance from the given parameters.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- connection_id (optional): Connection ID for the client
|
||||
- oauth_mode (optional): Whether to use OAuth for authentication
|
||||
- access_token (optional): OAuth access token
|
||||
- username (optional): Basic Auth username
|
||||
- password (optional): Basic Auth password
|
||||
- account_id (optional): Account ID
|
||||
- user_agent (optional): User agent for API requests
|
||||
|
||||
Returns:
|
||||
BasecampClient: A configured client
|
||||
"""
|
||||
# Mock connection for testing - return a fake client
|
||||
if params.get("connection_id") and params.get("connection_id").startswith("mock_"):
|
||||
print(f"Using mock client for connection ID: {params.get('connection_id')}")
|
||||
from unittest.mock import MagicMock
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Set up mock responses for known methods
|
||||
mock_client.get_projects.return_value = [{"id": 123, "name": "Mock Project"}]
|
||||
mock_client.get_comments.return_value = [{"id": 456, "content": "Mock comment"}]
|
||||
mock_client.create_comment.return_value = {"id": 789, "content": "New mock comment"}
|
||||
mock_client.update_comment.return_value = {"id": 789, "content": "Updated mock comment"}
|
||||
mock_client.delete_comment.return_value = True
|
||||
|
||||
return mock_client
|
||||
|
||||
# Check if OAuth mode is specified
|
||||
oauth_mode = params.get("oauth_mode", False)
|
||||
|
||||
if oauth_mode:
|
||||
# OAuth authentication
|
||||
access_token = params.get("access_token") or os.getenv("BASECAMP_ACCESS_TOKEN")
|
||||
account_id = params.get("account_id") or os.getenv("BASECAMP_ACCOUNT_ID")
|
||||
user_agent = params.get("user_agent") or os.getenv("USER_AGENT")
|
||||
|
||||
if not all([access_token, account_id, user_agent]):
|
||||
raise ValueError("Missing required OAuth credentials. Please provide access_token, account_id, and user_agent.")
|
||||
|
||||
return BasecampClient(
|
||||
access_token=access_token,
|
||||
account_id=account_id,
|
||||
user_agent=user_agent,
|
||||
auth_mode="oauth"
|
||||
)
|
||||
else:
|
||||
# Basic authentication
|
||||
username = params.get("username") or os.getenv("BASECAMP_USERNAME")
|
||||
password = params.get("password") or os.getenv("BASECAMP_PASSWORD")
|
||||
account_id = params.get("account_id") or os.getenv("BASECAMP_ACCOUNT_ID")
|
||||
user_agent = params.get("user_agent") or os.getenv("USER_AGENT")
|
||||
|
||||
if not all([username, password, account_id, user_agent]):
|
||||
raise ValueError("Missing required Basic Auth credentials. Please provide username, password, account_id, and user_agent.")
|
||||
|
||||
return BasecampClient(
|
||||
username=username,
|
||||
password=password,
|
||||
account_id=account_id,
|
||||
user_agent=user_agent,
|
||||
auth_mode="basic"
|
||||
)
|
||||
666
mcp_server.py
Normal file
666
mcp_server.py
Normal file
@@ -0,0 +1,666 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from flask import Flask, request, jsonify
|
||||
from dotenv import load_dotenv
|
||||
from threading import Thread
|
||||
import time
|
||||
from basecamp_client import BasecampClient
|
||||
from search_utils import BasecampSearch
|
||||
import token_storage # Import the token storage module
|
||||
import requests # For token refresh
|
||||
|
||||
# Import MCP integration components, using try/except to catch any import errors
|
||||
try:
|
||||
from mcp_integration import (
|
||||
get_required_parameters,
|
||||
initiate_connection,
|
||||
check_active_connection,
|
||||
get_projects,
|
||||
get_todo_lists,
|
||||
get_todos,
|
||||
get_comments,
|
||||
create_comment,
|
||||
update_comment,
|
||||
delete_comment,
|
||||
search_all
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error importing MCP integration: {str(e)}")
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
# Configure logging with more verbose output
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler('improved_mcp_server.log')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger('mcp_server')
|
||||
|
||||
# Load environment variables from .env file
|
||||
try:
|
||||
load_dotenv()
|
||||
logger.info("Environment variables loaded")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading environment variables: {str(e)}")
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
# MCP Server configuration
|
||||
MCP_PORT = int(os.environ.get('MCP_PORT', 5001))
|
||||
BASECAMP_ACCOUNT_ID = os.environ.get('BASECAMP_ACCOUNT_ID')
|
||||
USER_AGENT = os.environ.get('USER_AGENT')
|
||||
CLIENT_ID = os.environ.get('BASECAMP_CLIENT_ID')
|
||||
CLIENT_SECRET = os.environ.get('BASECAMP_CLIENT_SECRET')
|
||||
REDIRECT_URI = os.environ.get('BASECAMP_REDIRECT_URI')
|
||||
|
||||
# Token endpoints
|
||||
TOKEN_URL = "https://launchpad.37signals.com/authorization/token"
|
||||
|
||||
# Keep track of existing connections
|
||||
active_connections = {}
|
||||
|
||||
def refresh_oauth_token():
|
||||
"""
|
||||
Refresh the OAuth token if it's expired.
|
||||
|
||||
Returns:
|
||||
str: The new access token if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current token data
|
||||
token_data = token_storage.get_token()
|
||||
if not token_data or not token_data.get('refresh_token'):
|
||||
logger.error("No refresh token available")
|
||||
return None
|
||||
|
||||
refresh_token = token_data['refresh_token']
|
||||
|
||||
# Prepare the refresh request
|
||||
data = {
|
||||
'type': 'refresh',
|
||||
'client_id': CLIENT_ID,
|
||||
'client_secret': CLIENT_SECRET,
|
||||
'refresh_token': refresh_token
|
||||
}
|
||||
|
||||
headers = {
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
|
||||
logger.info("Refreshing OAuth token")
|
||||
response = requests.post(TOKEN_URL, data=data, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
new_token_data = response.json()
|
||||
logger.info("Token refresh successful")
|
||||
|
||||
# Store the new token
|
||||
access_token = new_token_data.get('access_token')
|
||||
new_refresh_token = new_token_data.get('refresh_token') or refresh_token # Use old refresh if not provided
|
||||
expires_in = new_token_data.get('expires_in')
|
||||
|
||||
token_storage.store_token(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
expires_in=expires_in,
|
||||
account_id=BASECAMP_ACCOUNT_ID
|
||||
)
|
||||
|
||||
return access_token
|
||||
else:
|
||||
logger.error(f"Failed to refresh token: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing token: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_basecamp_client(auth_mode='oauth'):
|
||||
"""
|
||||
Get a Basecamp client with the appropriate authentication.
|
||||
|
||||
Args:
|
||||
auth_mode (str): Authentication mode, either 'oauth' or 'pat' (Personal Access Token)
|
||||
|
||||
Returns:
|
||||
BasecampClient: A configured client
|
||||
"""
|
||||
logger.info(f"Getting Basecamp client with auth_mode: {auth_mode}")
|
||||
|
||||
if auth_mode == 'oauth':
|
||||
# Get the token from storage
|
||||
token_data = token_storage.get_token()
|
||||
|
||||
# If no token or token is expired, try to refresh
|
||||
if not token_data or not token_data.get('access_token') or token_storage.is_token_expired():
|
||||
logger.info("Token missing or expired, attempting to refresh")
|
||||
access_token = refresh_oauth_token()
|
||||
if not access_token:
|
||||
logger.error("No OAuth token available after refresh attempt")
|
||||
raise ValueError("No OAuth token available. Please authenticate first.")
|
||||
else:
|
||||
access_token = token_data['access_token']
|
||||
|
||||
account_id = token_data.get('account_id') or BASECAMP_ACCOUNT_ID
|
||||
|
||||
if not account_id:
|
||||
logger.error("No account ID available")
|
||||
raise ValueError("No Basecamp account ID available. Please set BASECAMP_ACCOUNT_ID.")
|
||||
|
||||
logger.info(f"Using OAuth token (starts with {access_token[:5]}...) for account {account_id}")
|
||||
|
||||
return BasecampClient(
|
||||
access_token=access_token,
|
||||
account_id=account_id,
|
||||
user_agent=USER_AGENT,
|
||||
auth_mode='oauth'
|
||||
)
|
||||
elif auth_mode == 'pat':
|
||||
# Use Personal Access Token
|
||||
username = os.environ.get('BASECAMP_USERNAME')
|
||||
token = os.environ.get('BASECAMP_TOKEN')
|
||||
account_id = BASECAMP_ACCOUNT_ID
|
||||
|
||||
if not username or not token or not account_id:
|
||||
logger.error("Missing credentials for PAT authentication")
|
||||
raise ValueError("Missing credentials for PAT authentication")
|
||||
|
||||
logger.info(f"Using PAT authentication for user {username} and account {account_id}")
|
||||
|
||||
return BasecampClient(
|
||||
username=username,
|
||||
token=token,
|
||||
account_id=account_id,
|
||||
user_agent=USER_AGENT,
|
||||
auth_mode='pat'
|
||||
)
|
||||
else:
|
||||
logger.error(f"Invalid auth mode: {auth_mode}")
|
||||
raise ValueError(f"Invalid auth mode: {auth_mode}")
|
||||
|
||||
# Basic health check endpoint for testing server responsiveness
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Simple health check endpoint that returns a static response."""
|
||||
logger.debug("Health check endpoint called")
|
||||
return jsonify({"status": "ok", "message": "MCP server is running"}), 200
|
||||
|
||||
# Enable CORS for all routes
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
response.headers.add('Access-Control-Allow-Origin', '*')
|
||||
response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
|
||||
logger.debug(f"CORS headers added to response: {response}")
|
||||
return response
|
||||
|
||||
# Add OPTIONS method handler for CORS preflight requests
|
||||
@app.route('/', defaults={'path': ''}, methods=['OPTIONS'])
|
||||
@app.route('/<path:path>', methods=['OPTIONS'])
|
||||
def handle_options(path):
|
||||
"""Handle OPTIONS preflight requests for CORS."""
|
||||
logger.debug(f"OPTIONS request for path: {path}")
|
||||
return '', 200
|
||||
|
||||
# MCP Info endpoint
|
||||
@app.route('/mcp/info', methods=['GET'])
|
||||
def mcp_info():
|
||||
"""Return information about this MCP server."""
|
||||
logger.info("MCP info endpoint called")
|
||||
try:
|
||||
# Keep this operation lightweight - no external API calls here
|
||||
return jsonify({
|
||||
"name": "Basecamp",
|
||||
"version": "1.0.0",
|
||||
"description": "Basecamp 3 API integration for Cursor",
|
||||
"author": "Cursor",
|
||||
"actions": [
|
||||
{
|
||||
"name": "get_required_parameters",
|
||||
"description": "Get required parameters for connecting to Basecamp"
|
||||
},
|
||||
{
|
||||
"name": "initiate_connection",
|
||||
"description": "Connect to Basecamp using credentials"
|
||||
},
|
||||
{
|
||||
"name": "check_active_connection",
|
||||
"description": "Check if the connection to Basecamp is active"
|
||||
},
|
||||
{
|
||||
"name": "get_projects",
|
||||
"description": "Get all projects with optional filtering"
|
||||
},
|
||||
{
|
||||
"name": "get_todo_lists",
|
||||
"description": "Get all to-do lists for a project"
|
||||
},
|
||||
{
|
||||
"name": "get_todos",
|
||||
"description": "Get all to-dos with various filters"
|
||||
},
|
||||
{
|
||||
"name": "get_comments",
|
||||
"description": "Get comments for a specific recording (todo, message, etc.)"
|
||||
},
|
||||
{
|
||||
"name": "create_comment",
|
||||
"description": "Create a comment on a recording"
|
||||
},
|
||||
{
|
||||
"name": "update_comment",
|
||||
"description": "Update a comment"
|
||||
},
|
||||
{
|
||||
"name": "delete_comment",
|
||||
"description": "Delete a comment"
|
||||
},
|
||||
{
|
||||
"name": "search_all",
|
||||
"description": "Search across all Basecamp resources"
|
||||
}
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in mcp_info: {str(e)}", exc_info=True)
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
# MCP Action endpoint with improved error handling
|
||||
@app.route('/mcp/action', methods=['POST'])
|
||||
def mcp_action():
|
||||
"""
|
||||
Handle direct MCP actions without connection management.
|
||||
This is a simpler interface for testing and direct integration.
|
||||
|
||||
Note: The connection-based approach using /initiate_connection and /tool/<connection_id>
|
||||
is preferred as it provides better error handling and state management.
|
||||
"""
|
||||
logger.info("MCP action endpoint called")
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
action = data.get('action')
|
||||
params = data.get('params', {})
|
||||
|
||||
logger.info(f"Action requested: {action}")
|
||||
|
||||
# First check if we have a valid OAuth token
|
||||
token_data = token_storage.get_token()
|
||||
if not token_data or not token_data.get('access_token'):
|
||||
logger.error("No OAuth token available for action")
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "authentication_required",
|
||||
"message": "OAuth authentication required. Please authenticate using the OAuth app first.",
|
||||
"oauth_url": "http://localhost:8000/"
|
||||
})
|
||||
|
||||
# Check if token is expired
|
||||
if token_storage.is_token_expired():
|
||||
logger.info("Token expired, attempting to refresh")
|
||||
new_token = refresh_oauth_token()
|
||||
if not new_token:
|
||||
logger.error("Failed to refresh token")
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "token_expired",
|
||||
"message": "OAuth token has expired and could not be refreshed. Please authenticate again.",
|
||||
"oauth_url": "http://localhost:8000/"
|
||||
})
|
||||
|
||||
# Handle action based on type
|
||||
try:
|
||||
if action == 'get_projects':
|
||||
client = get_basecamp_client(auth_mode='oauth')
|
||||
projects = client.get_projects()
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"projects": projects,
|
||||
"count": len(projects)
|
||||
})
|
||||
|
||||
elif action == 'search':
|
||||
client = get_basecamp_client(auth_mode='oauth')
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
query = params.get('query', '')
|
||||
include_completed = params.get('include_completed', False)
|
||||
|
||||
logger.info(f"Searching with query: {query}")
|
||||
|
||||
results = {
|
||||
"projects": search.search_projects(query),
|
||||
"todos": search.search_todos(query, include_completed=include_completed),
|
||||
"messages": search.search_messages(query),
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"results": results
|
||||
})
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown action: {action}")
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "unknown_action",
|
||||
"message": f"Unknown action: {action}"
|
||||
})
|
||||
|
||||
except Exception as action_error:
|
||||
logger.error(f"Error executing action {action}: {str(action_error)}")
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "execution_failed",
|
||||
"message": str(action_error)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MCP action endpoint: {str(e)}")
|
||||
return jsonify({
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
"""Home page for the MCP server."""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "basecamp-mcp-server",
|
||||
"description": "MCP server for Basecamp 3 integration"
|
||||
})
|
||||
|
||||
@app.route('/check_required_parameters', methods=['POST'])
|
||||
def check_required_parameters():
|
||||
"""
|
||||
Check the required parameters for connecting to Basecamp.
|
||||
"""
|
||||
logger.info("Checking required parameters for Basecamp")
|
||||
|
||||
try:
|
||||
# For OAuth mode
|
||||
if token_storage.get_token():
|
||||
return jsonify({
|
||||
"parameters": [] # No parameters needed if we have a token
|
||||
})
|
||||
|
||||
# Otherwise, we need OAuth credentials
|
||||
return jsonify({
|
||||
"parameters": [
|
||||
{
|
||||
"name": "auth_mode",
|
||||
"description": "Authentication mode (oauth or pat)",
|
||||
"required": True
|
||||
}
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking required parameters: {str(e)}")
|
||||
return jsonify({
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/initiate_connection', methods=['POST'])
|
||||
def initiate_connection():
|
||||
"""
|
||||
Initiate a connection to Basecamp.
|
||||
"""
|
||||
data = request.json
|
||||
auth_mode = data.get('auth_mode', 'oauth')
|
||||
|
||||
logger.info(f"Initiating connection with auth_mode: {auth_mode}")
|
||||
|
||||
try:
|
||||
# Check if we have credentials for the requested auth mode
|
||||
if auth_mode == 'oauth':
|
||||
# Check if we have a valid token
|
||||
token_data = token_storage.get_token()
|
||||
|
||||
# If token missing or expired, but we have a refresh token, try refreshing
|
||||
if (not token_data or not token_data.get('access_token') or token_storage.is_token_expired()) and token_data and token_data.get('refresh_token'):
|
||||
logger.info("Token missing or expired, attempting to refresh")
|
||||
access_token = refresh_oauth_token()
|
||||
if access_token:
|
||||
logger.info("Token refreshed successfully")
|
||||
token_data = token_storage.get_token() # Get the updated token data
|
||||
|
||||
# After potential refresh, check if we have a valid token
|
||||
if not token_data or not token_data.get('access_token'):
|
||||
logger.error("No OAuth token available")
|
||||
return jsonify({
|
||||
"error": "No OAuth token available. Please authenticate using the OAuth app first.",
|
||||
"oauth_url": "http://localhost:8000/"
|
||||
}), 401
|
||||
|
||||
# Create a connection ID
|
||||
connection_id = f"basecamp-oauth-{int(time.time())}"
|
||||
active_connections[connection_id] = {
|
||||
"auth_mode": "oauth",
|
||||
"created_at": time.time()
|
||||
}
|
||||
|
||||
logger.info(f"Created connection {connection_id} with OAuth")
|
||||
|
||||
return jsonify({
|
||||
"connection_id": connection_id,
|
||||
"status": "connected",
|
||||
"auth_mode": "oauth"
|
||||
})
|
||||
|
||||
elif auth_mode == 'pat':
|
||||
# Check if we have PAT credentials
|
||||
username = os.environ.get('BASECAMP_USERNAME')
|
||||
token = os.environ.get('BASECAMP_TOKEN')
|
||||
|
||||
if not username or not token:
|
||||
logger.error("Missing PAT credentials")
|
||||
return jsonify({
|
||||
"error": "Missing Personal Access Token credentials. Please set BASECAMP_USERNAME and BASECAMP_TOKEN."
|
||||
}), 401
|
||||
|
||||
# Create a connection ID
|
||||
connection_id = f"basecamp-pat-{int(time.time())}"
|
||||
active_connections[connection_id] = {
|
||||
"auth_mode": "pat",
|
||||
"created_at": time.time()
|
||||
}
|
||||
|
||||
logger.info(f"Created connection {connection_id} with PAT")
|
||||
|
||||
return jsonify({
|
||||
"connection_id": connection_id,
|
||||
"status": "connected",
|
||||
"auth_mode": "pat"
|
||||
})
|
||||
|
||||
else:
|
||||
logger.error(f"Invalid auth mode: {auth_mode}")
|
||||
return jsonify({
|
||||
"error": f"Invalid auth mode: {auth_mode}"
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initiating connection: {str(e)}")
|
||||
return jsonify({
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/check_active_connection', methods=['POST'])
|
||||
def check_active_connection():
|
||||
"""
|
||||
Check if a connection is active.
|
||||
"""
|
||||
data = request.json
|
||||
connection_id = data.get('connection_id')
|
||||
|
||||
logger.info(f"Checking active connection: {connection_id}")
|
||||
|
||||
if connection_id in active_connections:
|
||||
return jsonify({
|
||||
"connection_id": connection_id,
|
||||
"status": "active"
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"connection_id": connection_id,
|
||||
"status": "inactive"
|
||||
})
|
||||
|
||||
@app.route('/tool/<connection_id>', methods=['POST'])
|
||||
def tool(connection_id):
|
||||
"""
|
||||
Handle tool calls from the MCP client.
|
||||
"""
|
||||
data = request.json
|
||||
action = data.get('action')
|
||||
params = data.get('params', {})
|
||||
|
||||
logger.info(f"Tool call: {connection_id} - {action} - {params}")
|
||||
|
||||
# Check if the connection is active
|
||||
if connection_id not in active_connections:
|
||||
logger.error(f"Invalid connection ID: {connection_id}")
|
||||
return jsonify({
|
||||
"error": "Invalid connection ID"
|
||||
}), 401
|
||||
|
||||
# Get the auth mode for this connection
|
||||
auth_mode = active_connections[connection_id].get('auth_mode', 'oauth')
|
||||
|
||||
try:
|
||||
# Create a Basecamp client
|
||||
client = get_basecamp_client(auth_mode=auth_mode)
|
||||
|
||||
# Handle different actions
|
||||
if action == 'get_projects':
|
||||
projects = client.get_projects()
|
||||
return jsonify({
|
||||
"projects": projects
|
||||
})
|
||||
|
||||
elif action == 'get_project':
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return jsonify({"error": "Missing project_id parameter"}), 400
|
||||
|
||||
project = client.get_project(project_id)
|
||||
return jsonify({
|
||||
"project": project
|
||||
})
|
||||
|
||||
elif action == 'get_todolists':
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return jsonify({"error": "Missing project_id parameter"}), 400
|
||||
|
||||
todolists = client.get_todolists(project_id)
|
||||
return jsonify({
|
||||
"todolists": todolists
|
||||
})
|
||||
|
||||
elif action == 'get_todos':
|
||||
todolist_id = params.get('todolist_id')
|
||||
if not todolist_id:
|
||||
return jsonify({"error": "Missing todolist_id parameter"}), 400
|
||||
|
||||
todos = client.get_todos(todolist_id)
|
||||
return jsonify({
|
||||
"todos": todos
|
||||
})
|
||||
|
||||
elif action == 'create_todo':
|
||||
todolist_id = params.get('todolist_id')
|
||||
content = params.get('content')
|
||||
|
||||
if not todolist_id or not content:
|
||||
return jsonify({"error": "Missing todolist_id or content parameter"}), 400
|
||||
|
||||
todo = client.create_todo(
|
||||
todolist_id=todolist_id,
|
||||
content=content,
|
||||
description=params.get('description', ''),
|
||||
assignee_ids=params.get('assignee_ids', [])
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"todo": todo
|
||||
})
|
||||
|
||||
elif action == 'complete_todo':
|
||||
todo_id = params.get('todo_id')
|
||||
if not todo_id:
|
||||
return jsonify({"error": "Missing todo_id parameter"}), 400
|
||||
|
||||
result = client.complete_todo(todo_id)
|
||||
return jsonify({
|
||||
"result": result
|
||||
})
|
||||
|
||||
elif action == 'search':
|
||||
query = params.get('query')
|
||||
if not query:
|
||||
return jsonify({"error": "Missing query parameter"}), 400
|
||||
|
||||
# Create search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Determine what to search
|
||||
types = params.get('types', ['projects', 'todos', 'messages'])
|
||||
include_completed = params.get('include_completed', False)
|
||||
|
||||
results = {}
|
||||
|
||||
if 'projects' in types:
|
||||
results['projects'] = search.search_projects(query)
|
||||
|
||||
if 'todos' in types:
|
||||
results['todos'] = search.search_todos(query, include_completed=include_completed)
|
||||
|
||||
if 'messages' in types:
|
||||
results['messages'] = search.search_messages(query)
|
||||
|
||||
return jsonify({
|
||||
"results": results
|
||||
})
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown action: {action}")
|
||||
return jsonify({
|
||||
"error": f"Unknown action: {action}"
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling tool call: {str(e)}")
|
||||
return jsonify({
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
logger.info(f"Starting MCP server on port {MCP_PORT}")
|
||||
logger.info("Press Ctrl+C to stop the server")
|
||||
|
||||
# Run the Flask app
|
||||
# Disable debug and auto-reloader when running in production or background
|
||||
is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||
|
||||
logger.info("Running in %s mode", "debug" if is_debug else "production")
|
||||
app.run(
|
||||
host='0.0.0.0',
|
||||
port=MCP_PORT,
|
||||
debug=is_debug,
|
||||
use_reloader=is_debug,
|
||||
threaded=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting server: {str(e)}", exc_info=True)
|
||||
sys.exit(1)
|
||||
377
oauth_app.py
Normal file
377
oauth_app.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Flask application for handling the Basecamp 3 OAuth 2.0 authorization flow.
|
||||
|
||||
This application provides endpoints for:
|
||||
1. Redirecting users to Basecamp for authorization
|
||||
2. Handling the OAuth callback
|
||||
3. Using the obtained token to access the Basecamp API
|
||||
4. Providing a secure token endpoint for the MCP server
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import secrets
|
||||
import logging
|
||||
from flask import Flask, request, redirect, url_for, session, render_template_string, jsonify
|
||||
from dotenv import load_dotenv
|
||||
from basecamp_oauth import BasecampOAuth
|
||||
from basecamp_client import BasecampClient
|
||||
from search_utils import BasecampSearch
|
||||
import token_storage
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler("oauth_app.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Check for required environment variables
|
||||
required_vars = ['BASECAMP_CLIENT_ID', 'BASECAMP_CLIENT_SECRET', 'BASECAMP_REDIRECT_URI', 'USER_AGENT']
|
||||
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
||||
if missing_vars:
|
||||
logger.error(f"Missing required environment variables: {', '.join(missing_vars)}")
|
||||
logger.error("Please set these variables in your .env file or environment")
|
||||
sys.exit(1)
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.getenv('FLASK_SECRET_KEY', secrets.token_hex(16))
|
||||
|
||||
# HTML template for displaying results
|
||||
RESULTS_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Basecamp 3 OAuth Demo</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
h1 { color: #333; }
|
||||
pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; }
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
form { margin-top: 20px; }
|
||||
input[type="text"] { padding: 8px; width: 300px; }
|
||||
button { padding: 8px 15px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>{{ title }}</h1>
|
||||
{% if message %}
|
||||
<p>{{ message }}</p>
|
||||
{% endif %}
|
||||
{% if content %}
|
||||
<pre>{{ content }}</pre>
|
||||
{% endif %}
|
||||
{% if auth_url %}
|
||||
<a href="{{ auth_url }}" class="button">Log in with Basecamp</a>
|
||||
{% endif %}
|
||||
{% if token_info %}
|
||||
<h2>OAuth Token Information</h2>
|
||||
<pre>{{ token_info | tojson(indent=2) }}</pre>
|
||||
{% endif %}
|
||||
{% if show_logout %}
|
||||
<a href="/logout" class="button">Logout</a>
|
||||
{% endif %}
|
||||
{% if show_home %}
|
||||
<a href="/" class="button">Home</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.template_filter('tojson')
|
||||
def to_json(value, indent=None):
|
||||
return json.dumps(value, indent=indent)
|
||||
|
||||
def get_oauth_client():
|
||||
"""Get a configured OAuth client."""
|
||||
try:
|
||||
client_id = os.getenv('BASECAMP_CLIENT_ID')
|
||||
client_secret = os.getenv('BASECAMP_CLIENT_SECRET')
|
||||
redirect_uri = os.getenv('BASECAMP_REDIRECT_URI')
|
||||
user_agent = os.getenv('USER_AGENT')
|
||||
|
||||
logger.info("Creating OAuth client with config: %s, %s, %s", client_id, redirect_uri, user_agent)
|
||||
|
||||
return BasecampOAuth(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
user_agent=user_agent
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error creating OAuth client: %s", str(e))
|
||||
raise
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
"""Home page."""
|
||||
# Check if we have a stored token
|
||||
token_data = token_storage.get_token()
|
||||
|
||||
if token_data and token_data.get('access_token'):
|
||||
# We have a token, show token information
|
||||
access_token = token_data['access_token']
|
||||
# Mask the token for security
|
||||
masked_token = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
|
||||
|
||||
token_info = {
|
||||
"access_token": masked_token,
|
||||
"account_id": token_data.get('account_id'),
|
||||
"has_refresh_token": bool(token_data.get('refresh_token')),
|
||||
"expires_at": token_data.get('expires_at'),
|
||||
"updated_at": token_data.get('updated_at')
|
||||
}
|
||||
|
||||
logger.info("Home page: User is authenticated")
|
||||
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Basecamp OAuth Status",
|
||||
message="You are authenticated with Basecamp!",
|
||||
token_info=token_info,
|
||||
show_logout=True
|
||||
)
|
||||
else:
|
||||
# No token, show login button
|
||||
try:
|
||||
oauth_client = get_oauth_client()
|
||||
auth_url = oauth_client.get_authorization_url()
|
||||
|
||||
logger.info("Home page: User not authenticated, showing login button")
|
||||
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Basecamp OAuth Demo",
|
||||
message="Welcome! Please log in with your Basecamp account to continue.",
|
||||
auth_url=auth_url
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error getting authorization URL: %s", str(e))
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Error",
|
||||
message=f"Error setting up OAuth: {str(e)}",
|
||||
)
|
||||
|
||||
@app.route('/auth/callback')
|
||||
def auth_callback():
|
||||
"""Handle the OAuth callback from Basecamp."""
|
||||
logger.info("OAuth callback called with args: %s", request.args)
|
||||
|
||||
code = request.args.get('code')
|
||||
error = request.args.get('error')
|
||||
|
||||
if error:
|
||||
logger.error("OAuth callback error: %s", error)
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Authentication Error",
|
||||
message=f"Basecamp returned an error: {error}",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
if not code:
|
||||
logger.error("OAuth callback: No code provided")
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Error",
|
||||
message="No authorization code received.",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
try:
|
||||
# Exchange the code for an access token
|
||||
oauth_client = get_oauth_client()
|
||||
logger.info("Exchanging code for token")
|
||||
token_data = oauth_client.exchange_code_for_token(code)
|
||||
|
||||
# Store the token in our secure storage
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in')
|
||||
account_id = os.getenv('BASECAMP_ACCOUNT_ID')
|
||||
|
||||
if not access_token:
|
||||
logger.error("OAuth exchange: No access token received")
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Authentication Error",
|
||||
message="No access token received from Basecamp.",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
# Try to get identity if account_id is not set
|
||||
if not account_id:
|
||||
try:
|
||||
logger.info("Getting user identity to find account_id")
|
||||
identity = oauth_client.get_identity(access_token)
|
||||
logger.info("Identity response: %s", identity)
|
||||
|
||||
# Find Basecamp 3 account
|
||||
if identity.get('accounts'):
|
||||
for account in identity['accounts']:
|
||||
if account.get('product') == 'bc3': # Basecamp 3
|
||||
account_id = account['id']
|
||||
logger.info("Found account_id: %s", account_id)
|
||||
break
|
||||
except Exception as identity_error:
|
||||
logger.error("Error getting identity: %s", str(identity_error))
|
||||
# Continue with the flow, but log the error
|
||||
|
||||
logger.info("Storing token with account_id: %s", account_id)
|
||||
stored = token_storage.store_token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=expires_in,
|
||||
account_id=account_id
|
||||
)
|
||||
|
||||
if not stored:
|
||||
logger.error("Failed to store token")
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Error",
|
||||
message="Failed to store token. Please try again.",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
# Also keep the access token in session for convenience
|
||||
session['access_token'] = access_token
|
||||
if refresh_token:
|
||||
session['refresh_token'] = refresh_token
|
||||
if account_id:
|
||||
session['account_id'] = account_id
|
||||
|
||||
logger.info("OAuth flow completed successfully")
|
||||
|
||||
return redirect(url_for('home'))
|
||||
except Exception as e:
|
||||
logger.error("Error in OAuth callback: %s", str(e), exc_info=True)
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Error",
|
||||
message=f"Failed to exchange code for token: {str(e)}",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
@app.route('/api/token', methods=['GET'])
|
||||
def get_token_api():
|
||||
"""
|
||||
Secure API endpoint for the MCP server to get the token.
|
||||
This should only be accessible by the MCP server.
|
||||
"""
|
||||
logger.info("Token API called with headers: %s", request.headers)
|
||||
|
||||
# In production, implement proper authentication for this endpoint
|
||||
# For now, we'll use a simple API key check
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if not api_key or api_key != os.getenv('MCP_API_KEY', 'mcp_secret_key'):
|
||||
logger.error("Token API: Invalid API key")
|
||||
return jsonify({
|
||||
"error": "Unauthorized",
|
||||
"message": "Invalid or missing API key"
|
||||
}), 401
|
||||
|
||||
token_data = token_storage.get_token()
|
||||
if not token_data or not token_data.get('access_token'):
|
||||
logger.error("Token API: No valid token available")
|
||||
return jsonify({
|
||||
"error": "Not authenticated",
|
||||
"message": "No valid token available"
|
||||
}), 404
|
||||
|
||||
logger.info("Token API: Successfully returned token")
|
||||
return jsonify({
|
||||
"access_token": token_data['access_token'],
|
||||
"account_id": token_data.get('account_id')
|
||||
})
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
"""Clear the session and token storage."""
|
||||
logger.info("Logout called")
|
||||
session.clear()
|
||||
token_storage.clear_tokens()
|
||||
return redirect(url_for('home'))
|
||||
|
||||
@app.route('/token/info')
|
||||
def token_info():
|
||||
"""Display information about the stored token."""
|
||||
logger.info("Token info called")
|
||||
token_data = token_storage.get_token()
|
||||
|
||||
if not token_data:
|
||||
logger.info("Token info: No token stored")
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Token Information",
|
||||
message="No token stored.",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
# Mask the tokens for security
|
||||
access_token = token_data.get('access_token', '')
|
||||
refresh_token = token_data.get('refresh_token', '')
|
||||
|
||||
masked_access = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
|
||||
masked_refresh = f"{refresh_token[:10]}...{refresh_token[-10:]}" if refresh_token and len(refresh_token) > 20 else "***" if refresh_token else None
|
||||
|
||||
display_info = {
|
||||
"access_token": masked_access,
|
||||
"has_refresh_token": bool(refresh_token),
|
||||
"account_id": token_data.get('account_id'),
|
||||
"expires_at": token_data.get('expires_at'),
|
||||
"updated_at": token_data.get('updated_at')
|
||||
}
|
||||
|
||||
logger.info("Token info: Returned token info")
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Token Information",
|
||||
content=json.dumps(display_info, indent=2),
|
||||
show_home=True
|
||||
)
|
||||
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
logger.info("Health check called")
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "basecamp-oauth-app"
|
||||
})
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
logger.info("Starting OAuth app on port %s", os.environ.get('PORT', 8000))
|
||||
# Run the Flask app
|
||||
port = int(os.environ.get('PORT', 8000))
|
||||
|
||||
# Disable debug and auto-reloader when running in production or background
|
||||
is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||
|
||||
logger.info("Running in %s mode", "debug" if is_debug else "production")
|
||||
app.run(host='0.0.0.0', port=port, debug=is_debug, use_reloader=is_debug)
|
||||
except Exception as e:
|
||||
logger.error("Fatal error: %s", str(e), exc_info=True)
|
||||
sys.exit(1)
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
requests==2.31.0
|
||||
python-dotenv==1.0.0
|
||||
flask==2.3.3
|
||||
507
search_utils.py
Normal file
507
search_utils.py
Normal file
@@ -0,0 +1,507 @@
|
||||
from basecamp_client import BasecampClient
|
||||
import json
|
||||
import logging
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger('basecamp_search')
|
||||
|
||||
class BasecampSearch:
|
||||
"""
|
||||
Utility for searching across Basecamp 3 projects and to-dos.
|
||||
"""
|
||||
|
||||
def __init__(self, client=None, **kwargs):
|
||||
"""Initialize with either an existing client or credentials."""
|
||||
if client:
|
||||
self.client = client
|
||||
else:
|
||||
self.client = BasecampClient(**kwargs)
|
||||
|
||||
def search_projects(self, query=None):
|
||||
"""
|
||||
Search all projects, optionally filtering by name.
|
||||
|
||||
Args:
|
||||
query (str, optional): Text to search for in project names
|
||||
|
||||
Returns:
|
||||
list: Filtered list of projects
|
||||
"""
|
||||
try:
|
||||
projects = self.client.get_projects()
|
||||
|
||||
if query and projects:
|
||||
query = query.lower()
|
||||
projects = [
|
||||
project for project in projects
|
||||
if query in project.get('name', '').lower() or
|
||||
query in (project.get('description') or '').lower()
|
||||
]
|
||||
|
||||
return projects
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching projects: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_all_todolists(self, project_id=None):
|
||||
"""
|
||||
Get all todolists, either for a specific project or across all projects.
|
||||
|
||||
Args:
|
||||
project_id (int, optional): Specific project ID or None for all projects
|
||||
|
||||
Returns:
|
||||
list: List of todolists with project info
|
||||
"""
|
||||
all_todolists = []
|
||||
|
||||
try:
|
||||
if project_id:
|
||||
# Get todolists for a specific project
|
||||
project = self.client.get_project(project_id)
|
||||
todolists = self.client.get_todolists(project_id)
|
||||
|
||||
for todolist in todolists:
|
||||
todolist['project'] = {'id': project['id'], 'name': project['name']}
|
||||
all_todolists.append(todolist)
|
||||
else:
|
||||
# Get todolists across all projects
|
||||
projects = self.client.get_projects()
|
||||
|
||||
for project in projects:
|
||||
project_id = project['id']
|
||||
try:
|
||||
todolists = self.client.get_todolists(project_id)
|
||||
for todolist in todolists:
|
||||
todolist['project'] = {'id': project['id'], 'name': project['name']}
|
||||
all_todolists.append(todolist)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting todolists for project {project_id}: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all todolists: {str(e)}")
|
||||
|
||||
return all_todolists
|
||||
|
||||
def search_todolists(self, query=None, project_id=None):
|
||||
"""
|
||||
Search all todolists, optionally filtering by name and project.
|
||||
|
||||
Args:
|
||||
query (str, optional): Text to search for in todolist names
|
||||
project_id (int, optional): Specific project ID or None for all projects
|
||||
|
||||
Returns:
|
||||
list: Filtered list of todolists
|
||||
"""
|
||||
todolists = self.get_all_todolists(project_id)
|
||||
|
||||
if query and todolists:
|
||||
query = query.lower()
|
||||
todolists = [
|
||||
todolist for todolist in todolists
|
||||
if query in todolist.get('name', '').lower() or
|
||||
query in (todolist.get('description') or '').lower()
|
||||
]
|
||||
|
||||
return todolists
|
||||
|
||||
def get_all_todos(self, project_id=None, todolist_id=None, include_completed=False):
|
||||
"""
|
||||
Get all todos, with various filtering options.
|
||||
|
||||
Args:
|
||||
project_id (int, optional): Specific project ID or None for all projects
|
||||
todolist_id (int, optional): Specific todolist ID or None for all todolists
|
||||
include_completed (bool): Whether to include completed todos
|
||||
|
||||
Returns:
|
||||
list: List of todos with project and todolist info
|
||||
"""
|
||||
all_todos = []
|
||||
|
||||
try:
|
||||
# Case 1: Specific todolist (regardless of project)
|
||||
if todolist_id:
|
||||
try:
|
||||
todolist = self.client.get_todolist(todolist_id)
|
||||
todos = self.client.get_todos(todolist_id)
|
||||
|
||||
# In Basecamp 3, we need to add project info to the todolist
|
||||
# Get project ID from the URL
|
||||
project_links = [link for link in todolist.get('bucket', {}).get('links', [])
|
||||
if link.get('type') == 'project']
|
||||
if project_links:
|
||||
project_url = project_links[0].get('href', '')
|
||||
# Extract project ID from URL
|
||||
parts = project_url.split('/')
|
||||
if len(parts) > 0:
|
||||
project_id = parts[-1]
|
||||
try:
|
||||
project = self.client.get_project(project_id)
|
||||
project_name = project.get('name', 'Unknown Project')
|
||||
except:
|
||||
project_name = 'Unknown Project'
|
||||
else:
|
||||
project_name = 'Unknown Project'
|
||||
else:
|
||||
project_name = 'Unknown Project'
|
||||
|
||||
for todo in todos:
|
||||
if not include_completed and todo.get('completed'):
|
||||
continue
|
||||
|
||||
todo['project'] = {'id': project_id, 'name': project_name}
|
||||
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
|
||||
all_todos.append(todo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting todos for todolist {todolist_id}: {str(e)}")
|
||||
|
||||
# Case 2: Specific project, all todolists
|
||||
elif project_id:
|
||||
project = self.client.get_project(project_id)
|
||||
todolists = self.client.get_todolists(project_id)
|
||||
|
||||
for todolist in todolists:
|
||||
try:
|
||||
todos = self.client.get_todos(todolist['id'])
|
||||
for todo in todos:
|
||||
if not include_completed and todo.get('completed'):
|
||||
continue
|
||||
|
||||
todo['project'] = {'id': project['id'], 'name': project['name']}
|
||||
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
|
||||
all_todos.append(todo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}")
|
||||
|
||||
# Case 3: All projects
|
||||
else:
|
||||
todolists = self.get_all_todolists()
|
||||
|
||||
for todolist in todolists:
|
||||
try:
|
||||
todos = self.client.get_todos(todolist['id'])
|
||||
for todo in todos:
|
||||
if not include_completed and todo.get('completed'):
|
||||
continue
|
||||
|
||||
todo['project'] = todolist['project']
|
||||
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
|
||||
all_todos.append(todo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all todos: {str(e)}")
|
||||
|
||||
return all_todos
|
||||
|
||||
def search_todos(self, query=None, project_id=None, todolist_id=None, include_completed=False):
|
||||
"""
|
||||
Search all todos, with various filtering options.
|
||||
|
||||
Args:
|
||||
query (str, optional): Text to search for in todo content
|
||||
project_id (int, optional): Specific project ID or None for all projects
|
||||
todolist_id (int, optional): Specific todolist ID or None for all todolists
|
||||
include_completed (bool): Whether to include completed todos
|
||||
|
||||
Returns:
|
||||
list: Filtered list of todos
|
||||
"""
|
||||
todos = self.get_all_todos(project_id, todolist_id, include_completed)
|
||||
|
||||
if query and todos:
|
||||
query = query.lower()
|
||||
# In Basecamp 3, the todo content is in the 'content' field
|
||||
todos = [
|
||||
t for t in todos
|
||||
if query in t.get('content', '').lower() or
|
||||
query in (t.get('description') or '').lower()
|
||||
]
|
||||
|
||||
return todos
|
||||
|
||||
def search_messages(self, query=None, project_id=None):
|
||||
"""
|
||||
Search for messages across all projects or within a specific project.
|
||||
|
||||
Args:
|
||||
query (str, optional): Search term to filter messages
|
||||
project_id (int, optional): If provided, only search within this project
|
||||
|
||||
Returns:
|
||||
list: Matching messages
|
||||
"""
|
||||
all_messages = []
|
||||
|
||||
try:
|
||||
# Get projects to search in
|
||||
if project_id:
|
||||
projects = [self.client.get_project(project_id)]
|
||||
else:
|
||||
projects = self.client.get_projects()
|
||||
|
||||
for project in projects:
|
||||
project_id = project['id']
|
||||
logger.info(f"Searching messages in project {project_id} ({project.get('name', 'Unknown')})")
|
||||
|
||||
# Check for message boards in the dock
|
||||
has_message_board = False
|
||||
message_boards = []
|
||||
|
||||
for dock_item in project.get('dock', []):
|
||||
if dock_item.get('name') == 'message_board' and dock_item.get('enabled', False):
|
||||
has_message_board = True
|
||||
message_boards.append(dock_item)
|
||||
|
||||
if not has_message_board:
|
||||
logger.info(f"Project {project_id} ({project.get('name', 'Unknown')}) has no enabled message boards")
|
||||
continue
|
||||
|
||||
# Get messages from each message board
|
||||
for board in message_boards:
|
||||
board_id = board.get('id')
|
||||
try:
|
||||
# First try getting the message board details
|
||||
logger.info(f"Fetching message board {board_id} for project {project_id}")
|
||||
board_endpoint = f"buckets/{project_id}/message_boards/{board_id}.json"
|
||||
board_details = self.client.get(board_endpoint)
|
||||
|
||||
# Then get all messages in the board
|
||||
logger.info(f"Fetching messages for board {board_id} in project {project_id}")
|
||||
messages_endpoint = f"buckets/{project_id}/message_boards/{board_id}/messages.json"
|
||||
messages = self.client.get(messages_endpoint)
|
||||
|
||||
logger.info(f"Found {len(messages)} messages in board {board_id}")
|
||||
|
||||
# Now get detailed content for each message
|
||||
for message in messages:
|
||||
try:
|
||||
message_id = message.get('id')
|
||||
# Get detailed message content
|
||||
message_endpoint = f"buckets/{project_id}/messages/{message_id}.json"
|
||||
detailed_message = self.client.get(message_endpoint)
|
||||
|
||||
# Add project info
|
||||
detailed_message['project'] = {
|
||||
'id': project_id,
|
||||
'name': project.get('name', 'Unknown Project')
|
||||
}
|
||||
|
||||
# Add to results
|
||||
all_messages.append(detailed_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting detailed message {message.get('id', 'unknown')} in project {project_id}: {str(e)}")
|
||||
# Still include basic message info
|
||||
message['project'] = {
|
||||
'id': project_id,
|
||||
'name': project.get('name', 'Unknown Project')
|
||||
}
|
||||
all_messages.append(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting messages for board {board_id} in project {project_id}: {str(e)}")
|
||||
|
||||
# Try alternate approach: get messages directly for the project
|
||||
try:
|
||||
logger.info(f"Trying alternate approach for project {project_id}")
|
||||
messages = self.client.get_messages(project_id)
|
||||
|
||||
logger.info(f"Found {len(messages)} messages in project {project_id} using direct method")
|
||||
|
||||
# Add project info to each message
|
||||
for message in messages:
|
||||
message['project'] = {
|
||||
'id': project_id,
|
||||
'name': project.get('name', 'Unknown Project')
|
||||
}
|
||||
all_messages.append(message)
|
||||
except Exception as e2:
|
||||
logger.error(f"Error getting messages directly for project {project_id}: {str(e2)}")
|
||||
|
||||
# Also check for message categories/topics
|
||||
try:
|
||||
# Try to get message categories
|
||||
categories_endpoint = f"buckets/{project_id}/categories.json"
|
||||
categories = self.client.get(categories_endpoint)
|
||||
|
||||
for category in categories:
|
||||
category_id = category.get('id')
|
||||
try:
|
||||
# Get messages in this category
|
||||
category_messages_endpoint = f"buckets/{project_id}/categories/{category_id}/messages.json"
|
||||
category_messages = self.client.get(category_messages_endpoint)
|
||||
|
||||
# Add project and category info
|
||||
for message in category_messages:
|
||||
message['project'] = {
|
||||
'id': project_id,
|
||||
'name': project.get('name', 'Unknown Project')
|
||||
}
|
||||
message['category'] = {
|
||||
'id': category_id,
|
||||
'name': category.get('name', 'Unknown Category')
|
||||
}
|
||||
all_messages.append(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting messages for category {category_id} in project {project_id}: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.info(f"No message categories found for project {project_id}: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching messages: {str(e)}")
|
||||
|
||||
# Filter by query if provided
|
||||
if query and all_messages:
|
||||
query = query.lower()
|
||||
filtered_messages = []
|
||||
|
||||
for message in all_messages:
|
||||
# Search in multiple fields
|
||||
content_matched = False
|
||||
|
||||
# Check title/subject
|
||||
if query in (message.get('subject', '') or '').lower():
|
||||
content_matched = True
|
||||
|
||||
# Check content field
|
||||
if not content_matched and query in (message.get('content', '') or '').lower():
|
||||
content_matched = True
|
||||
|
||||
# Check content field with HTML
|
||||
if not content_matched and 'content' in message:
|
||||
content_html = message.get('content')
|
||||
if content_html and query in content_html.lower():
|
||||
content_matched = True
|
||||
|
||||
# Check raw content in various formats
|
||||
if not content_matched:
|
||||
# Try different content field formats
|
||||
for field in ['raw_content', 'content_html', 'body', 'description', 'text']:
|
||||
if field in message and message[field]:
|
||||
if query in str(message[field]).lower():
|
||||
content_matched = True
|
||||
break
|
||||
|
||||
# Check title field
|
||||
if not content_matched and 'title' in message and message['title']:
|
||||
if query in message['title'].lower():
|
||||
content_matched = True
|
||||
|
||||
# Check creator's name
|
||||
if not content_matched and 'creator' in message and message['creator']:
|
||||
creator = message['creator']
|
||||
creator_name = f"{creator.get('name', '')} {creator.get('first_name', '')} {creator.get('last_name', '')}"
|
||||
if query in creator_name.lower():
|
||||
content_matched = True
|
||||
|
||||
# Include if content matched
|
||||
if content_matched:
|
||||
filtered_messages.append(message)
|
||||
|
||||
logger.info(f"Found {len(filtered_messages)} messages matching query '{query}' out of {len(all_messages)} total messages")
|
||||
return filtered_messages
|
||||
|
||||
return all_messages
|
||||
|
||||
def search_schedule_entries(self, query=None, project_id=None):
|
||||
"""
|
||||
Search schedule entries across projects or in a specific project.
|
||||
|
||||
Args:
|
||||
query (str, optional): Search term to filter schedule entries
|
||||
project_id (int, optional): Specific project ID to search in
|
||||
|
||||
Returns:
|
||||
list: Matching schedule entries
|
||||
"""
|
||||
try:
|
||||
# Get the schedule entries (from all projects or a specific one)
|
||||
if project_id:
|
||||
entries = self.client.get_schedule_entries(project_id)
|
||||
entries = entries.json() if hasattr(entries, 'json') else entries
|
||||
else:
|
||||
# Get all projects first
|
||||
projects = self.client.get_projects()
|
||||
|
||||
# Then get schedule entries from each
|
||||
entries = []
|
||||
for project in projects:
|
||||
project_entries = self.client.get_schedule_entries(project['id'])
|
||||
project_entries = project_entries.json() if hasattr(project_entries, 'json') else project_entries
|
||||
if project_entries:
|
||||
for entry in project_entries:
|
||||
entry['project'] = {
|
||||
'id': project['id'],
|
||||
'name': project['name']
|
||||
}
|
||||
entries.extend(project_entries)
|
||||
|
||||
# Filter by query if provided
|
||||
if query and entries:
|
||||
query = query.lower()
|
||||
entries = [
|
||||
entry for entry in entries
|
||||
if query in entry.get('title', '').lower() or
|
||||
query in (entry.get('description') or '').lower() or
|
||||
(entry.get('creator') and query in entry['creator'].get('name', '').lower())
|
||||
]
|
||||
|
||||
return entries
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching schedule entries: {str(e)}")
|
||||
return []
|
||||
|
||||
def search_comments(self, query=None, recording_id=None, bucket_id=None):
|
||||
"""
|
||||
Search for comments across resources or for a specific resource.
|
||||
|
||||
Args:
|
||||
query (str, optional): Search term to filter comments
|
||||
recording_id (int, optional): ID of the recording (todo, message, etc.) to search in
|
||||
bucket_id (int, optional): Project/bucket ID
|
||||
|
||||
Returns:
|
||||
list: Matching comments
|
||||
"""
|
||||
try:
|
||||
# If both recording_id and bucket_id are provided, get comments for that specific recording
|
||||
if recording_id and bucket_id:
|
||||
comments = self.client.get_comments(recording_id, bucket_id)
|
||||
# Otherwise we can't search across all comments as there's no endpoint for that
|
||||
else:
|
||||
logger.warning("Cannot search all comments across Basecamp - both recording_id and bucket_id are required")
|
||||
return [{
|
||||
"content": "To search comments, you need to specify both a recording ID (todo, message, etc.) and a bucket ID. Comments cannot be searched globally in Basecamp.",
|
||||
"api_limitation": True,
|
||||
"title": "Comment Search Limitation"
|
||||
}]
|
||||
|
||||
# Filter by query if provided
|
||||
if query and comments:
|
||||
query = query.lower()
|
||||
|
||||
filtered_comments = []
|
||||
for comment in comments:
|
||||
# Check content
|
||||
content_matched = False
|
||||
content = comment.get('content', '')
|
||||
if content and query in content.lower():
|
||||
content_matched = True
|
||||
|
||||
# Check creator name
|
||||
if not content_matched and comment.get('creator'):
|
||||
creator_name = comment['creator'].get('name', '')
|
||||
if creator_name and query in creator_name.lower():
|
||||
content_matched = True
|
||||
|
||||
# If matched, add to results
|
||||
if content_matched:
|
||||
filtered_comments.append(comment)
|
||||
|
||||
comments = filtered_comments
|
||||
|
||||
return comments
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching comments: {str(e)}")
|
||||
return []
|
||||
35
setup.sh
Executable file
35
setup.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
echo "Setting up Basecamp API integration..."
|
||||
|
||||
# Create virtual environment
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
|
||||
# Activate virtual environment
|
||||
echo "Activating virtual environment..."
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Set up .env file if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
echo "Creating .env file from template..."
|
||||
cp .env.example .env
|
||||
echo "Please edit .env with your Basecamp credentials"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Setup complete!"
|
||||
echo ""
|
||||
echo "To activate the virtual environment, run:"
|
||||
echo " source venv/bin/activate"
|
||||
echo ""
|
||||
echo "To test your Basecamp connection, run:"
|
||||
echo " python basecamp_cli.py projects"
|
||||
echo ""
|
||||
43
start_basecamp_mcp.sh
Executable file
43
start_basecamp_mcp.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Starting Basecamp MCP integration..."
|
||||
|
||||
# Kill any existing processes
|
||||
echo "Stopping any existing servers..."
|
||||
pkill -f "python oauth_app.py" 2>/dev/null || true
|
||||
pkill -f "python mcp_server.py" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ -d "venv" ]; then
|
||||
echo "Activating virtual environment..."
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# Start the OAuth app
|
||||
echo "Starting OAuth app on port 8000..."
|
||||
nohup python oauth_app.py > oauth_app.log 2>&1 < /dev/null &
|
||||
OAUTH_PID=$!
|
||||
echo "OAuth app started with PID: $OAUTH_PID"
|
||||
|
||||
# Wait a bit for OAuth app to start
|
||||
sleep 2
|
||||
|
||||
# Start the MCP server
|
||||
echo "Starting MCP server on port 5001..."
|
||||
nohup python mcp_server.py > mcp_server.log 2>&1 < /dev/null &
|
||||
MCP_PID=$!
|
||||
echo "MCP server started with PID: $MCP_PID"
|
||||
|
||||
echo ""
|
||||
echo "Basecamp MCP integration is now running:"
|
||||
echo "- OAuth app: http://localhost:8000"
|
||||
echo "- MCP server: http://localhost:5001"
|
||||
echo ""
|
||||
echo "To stop the servers, run: pkill -f 'python oauth_app.py' && pkill -f 'python mcp_server.py'"
|
||||
echo ""
|
||||
echo "To check server logs, run:"
|
||||
echo "- OAuth app logs: tail -f oauth_app.log"
|
||||
echo "- MCP server logs: tail -f mcp_server.log"
|
||||
echo ""
|
||||
echo "To use with Cursor, configure a new MCP server with URL: http://localhost:5001"
|
||||
119
token_storage.py
Normal file
119
token_storage.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Token storage module for securely storing OAuth tokens.
|
||||
|
||||
This module provides a simple interface for storing and retrieving OAuth tokens.
|
||||
In a production environment, this should be replaced with a more secure solution
|
||||
like a database or a secure token storage service.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Token storage file - in production, use a database instead
|
||||
TOKEN_FILE = 'oauth_tokens.json'
|
||||
|
||||
# Lock for thread-safe operations
|
||||
_lock = threading.Lock()
|
||||
|
||||
def _read_tokens():
|
||||
"""Read tokens from storage."""
|
||||
try:
|
||||
with open(TOKEN_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {} # Return empty dict if file doesn't exist
|
||||
except json.JSONDecodeError:
|
||||
# If file exists but isn't valid JSON, return empty dict
|
||||
return {}
|
||||
|
||||
def _write_tokens(tokens):
|
||||
"""Write tokens to storage."""
|
||||
# Create directory for the token file if it doesn't exist
|
||||
os.makedirs(os.path.dirname(TOKEN_FILE) if os.path.dirname(TOKEN_FILE) else '.', exist_ok=True)
|
||||
|
||||
# Set secure permissions on the file
|
||||
with open(TOKEN_FILE, 'w') as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
|
||||
# Set permissions to only allow the current user to read/write
|
||||
try:
|
||||
os.chmod(TOKEN_FILE, 0o600)
|
||||
except Exception:
|
||||
pass # Ignore if chmod fails (might be on Windows)
|
||||
|
||||
def store_token(access_token, refresh_token=None, expires_in=None, account_id=None):
|
||||
"""
|
||||
Store OAuth tokens securely.
|
||||
|
||||
Args:
|
||||
access_token (str): The OAuth access token
|
||||
refresh_token (str, optional): The OAuth refresh token
|
||||
expires_in (int, optional): Token expiration time in seconds
|
||||
account_id (str, optional): The Basecamp account ID
|
||||
|
||||
Returns:
|
||||
bool: True if the token was stored successfully
|
||||
"""
|
||||
if not access_token:
|
||||
return False # Don't store empty tokens
|
||||
|
||||
with _lock:
|
||||
tokens = _read_tokens()
|
||||
|
||||
# Calculate expiration time
|
||||
expires_at = None
|
||||
if expires_in:
|
||||
expires_at = (datetime.now() + timedelta(seconds=expires_in)).isoformat()
|
||||
|
||||
# Store the token with metadata
|
||||
tokens['basecamp'] = {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'account_id': account_id,
|
||||
'expires_at': expires_at,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
_write_tokens(tokens)
|
||||
return True
|
||||
|
||||
def get_token():
|
||||
"""
|
||||
Get the stored OAuth token.
|
||||
|
||||
Returns:
|
||||
dict: Token information or None if not found
|
||||
"""
|
||||
with _lock:
|
||||
tokens = _read_tokens()
|
||||
return tokens.get('basecamp')
|
||||
|
||||
def is_token_expired():
|
||||
"""
|
||||
Check if the stored token is expired.
|
||||
|
||||
Returns:
|
||||
bool: True if the token is expired or not found
|
||||
"""
|
||||
with _lock:
|
||||
tokens = _read_tokens()
|
||||
token_data = tokens.get('basecamp')
|
||||
|
||||
if not token_data or not token_data.get('expires_at'):
|
||||
return True
|
||||
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(token_data['expires_at'])
|
||||
# Add a buffer of 5 minutes to account for clock differences
|
||||
return datetime.now() > (expires_at - timedelta(minutes=5))
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
|
||||
def clear_tokens():
|
||||
"""Clear all stored tokens."""
|
||||
with _lock:
|
||||
if os.path.exists(TOKEN_FILE):
|
||||
os.remove(TOKEN_FILE)
|
||||
return True
|
||||
Reference in New Issue
Block a user