Initial commit: Basecamp MCP server with OAuth authentication

This commit is contained in:
George Antonopoulos
2025-03-09 16:42:28 +00:00
commit 9c49ce02b1
14 changed files with 3054 additions and 0 deletions

15
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
requests==2.31.0
python-dotenv==1.0.0
flask==2.3.3

507
search_utils.py Normal file
View 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
View 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
View 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
View 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