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