chore: remove trailing spaces and ensure newline
This commit is contained in:
@@ -12,4 +12,4 @@ FLASK_SECRET_KEY=your-flask-secret-key
|
||||
|
||||
# OAuth tokens (filled automatically by the app)
|
||||
BASECAMP_ACCESS_TOKEN=
|
||||
BASECAMP_REFRESH_TOKEN=
|
||||
BASECAMP_REFRESH_TOKEN=
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,4 +49,4 @@ icons/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*.swo
|
||||
|
||||
@@ -138,4 +138,4 @@ Claude's servers need to be able to reach your MCP server over the internet.
|
||||
5. **Firewall/Network:**
|
||||
* If deploying to a server with a firewall, ensure the port your MCP server is running on (e.g., 5001) allows inbound HTTPS traffic.
|
||||
|
||||
By following these steps, you should be able to successfully integrate your Basecamp MCP server with Anthropic Claude, empowering Claude with your custom Basecamp functionalities.
|
||||
By following these steps, you should be able to successfully integrate your Basecamp MCP server with Anthropic Claude, empowering Claude with your custom Basecamp functionalities.
|
||||
|
||||
@@ -69,4 +69,4 @@ To further improve this implementation:
|
||||
- Implement proper token encryption
|
||||
- Add audit logging for security events
|
||||
|
||||
This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol.
|
||||
This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol.
|
||||
|
||||
24
README.md
24
README.md
@@ -7,7 +7,7 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3,
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.7+
|
||||
- A Basecamp 3 account
|
||||
- A Basecamp 3 account
|
||||
- A Basecamp OAuth application (create one at https://launchpad.37signals.com/integrations)
|
||||
|
||||
### Step-by-Step Instructions
|
||||
@@ -42,10 +42,10 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3,
|
||||
```bash
|
||||
python generate_cursor_config.py
|
||||
```
|
||||
|
||||
|
||||
This script will:
|
||||
- Generate the correct MCP configuration with full paths
|
||||
- Automatically detect your virtual environment
|
||||
- Automatically detect your virtual environment
|
||||
- Include the BASECAMP_ACCOUNT_ID environment variable
|
||||
- Update your Cursor configuration file automatically
|
||||
|
||||
@@ -71,7 +71,7 @@ python -m pytest tests/ -v
|
||||
Once configured, you can use these tools in Cursor:
|
||||
|
||||
- `get_projects` - Get all Basecamp projects
|
||||
- `get_project` - Get details for a specific project
|
||||
- `get_project` - Get details for a specific project
|
||||
- `get_todolists` - Get todo lists for a project
|
||||
- `get_todos` - Get todos from a todo list
|
||||
- `search_basecamp` - Search across projects, todos, and messages
|
||||
@@ -90,7 +90,7 @@ Ask Cursor things like:
|
||||
The project consists of:
|
||||
|
||||
1. **OAuth App** (`oauth_app.py`) - Handles OAuth 2.0 flow with Basecamp
|
||||
2. **MCP Server** (`mcp_server_cli.py`) - Implements MCP protocol for Cursor
|
||||
2. **MCP Server** (`mcp_server_cli.py`) - Implements MCP protocol for Cursor
|
||||
3. **Token Storage** (`token_storage.py`) - Securely stores OAuth tokens
|
||||
4. **Basecamp Client** (`basecamp_client.py`) - Basecamp API client library
|
||||
5. **Search Utilities** (`search_utils.py`) - Search across Basecamp resources
|
||||
@@ -108,7 +108,7 @@ The project consists of:
|
||||
|
||||
If automatic configuration doesn't work, manually edit your Cursor MCP configuration:
|
||||
|
||||
**On macOS/Linux:** `~/.cursor/mcp.json`
|
||||
**On macOS/Linux:** `~/.cursor/mcp.json`
|
||||
**On Windows:** `%APPDATA%\Cursor\mcp.json`
|
||||
|
||||
```json
|
||||
@@ -152,7 +152,7 @@ Based on [Cursor community forums](https://forum.cursor.com/t/mcp-servers-no-too
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
@@ -166,7 +166,7 @@ MIT License - see LICENSE file for details.
|
||||
- Improved error handling and response formatting across all action handlers
|
||||
- Fixed CORS support by adding the Flask-CORS package
|
||||
|
||||
These changes ensure more reliable and consistent responses from the MCP server, particularly for Campfire chat functionality.
|
||||
These changes ensure more reliable and consistent responses from the MCP server, particularly for Campfire chat functionality.
|
||||
|
||||
### March 9, 2024 - Added Local Testing
|
||||
|
||||
@@ -255,7 +255,7 @@ TODO: To test the integration without connecting to Composio:
|
||||
-d '{"tool": "GET_PROJECTS", "params": {}}'
|
||||
```
|
||||
|
||||
TODO: For more detailed documentation on Composio integration, refer to the [official Composio documentation](https://docs.composio.dev).
|
||||
TODO: For more detailed documentation on Composio integration, refer to the [official Composio documentation](https://docs.composio.dev).
|
||||
|
||||
## Quick Setup for Cursor
|
||||
|
||||
@@ -291,7 +291,7 @@ TODO: For more detailed documentation on Composio integration, refer to the [off
|
||||
```bash
|
||||
python generate_cursor_config.py
|
||||
```
|
||||
|
||||
|
||||
This script will:
|
||||
- Generate the correct MCP configuration with full paths
|
||||
- Automatically detect your virtual environment
|
||||
@@ -323,7 +323,7 @@ Based on the [Cursor community forum](https://forum.cursor.com/t/mcp-servers-no-
|
||||
|
||||
If the automatic configuration doesn't work, manually edit your Cursor MCP configuration:
|
||||
|
||||
**On macOS/Linux:** `~/.cursor/mcp.json`
|
||||
**On macOS/Linux:** `~/.cursor/mcp.json`
|
||||
**On Windows:** `%APPDATA%\Cursor\mcp.json`
|
||||
|
||||
```json
|
||||
@@ -366,4 +366,4 @@ The key fixes that resolve the "yellow indicator" and "tool not found" issues me
|
||||
1. **Full Python executable path** instead of just "python"
|
||||
2. **Environment variables** for PYTHONPATH and VIRTUAL_ENV
|
||||
3. **Proper MCP protocol handling** including the 'initialized' notification
|
||||
4. **Absolute paths** for all configuration values
|
||||
4. **Absolute paths** for all configuration values
|
||||
|
||||
@@ -6,12 +6,12 @@ class BasecampClient:
|
||||
"""
|
||||
Client for interacting with Basecamp 3 API using Basic Authentication or OAuth 2.0.
|
||||
"""
|
||||
|
||||
def __init__(self, username=None, password=None, account_id=None, user_agent=None,
|
||||
|
||||
def __init__(self, username=None, password=None, account_id=None, user_agent=None,
|
||||
access_token=None, auth_mode="basic"):
|
||||
"""
|
||||
Initialize the Basecamp client with credentials.
|
||||
|
||||
|
||||
Args:
|
||||
username (str, optional): Basecamp username (email) for Basic Auth
|
||||
password (str, optional): Basecamp password for Basic Auth
|
||||
@@ -22,44 +22,44 @@ class BasecampClient:
|
||||
"""
|
||||
# Load environment variables if not provided directly
|
||||
load_dotenv()
|
||||
|
||||
|
||||
self.auth_mode = auth_mode.lower()
|
||||
self.account_id = account_id or os.getenv('BASECAMP_ACCOUNT_ID')
|
||||
self.user_agent = user_agent or os.getenv('USER_AGENT')
|
||||
|
||||
|
||||
# Set up authentication based on mode
|
||||
if self.auth_mode == 'basic':
|
||||
self.username = username or os.getenv('BASECAMP_USERNAME')
|
||||
self.password = password or os.getenv('BASECAMP_PASSWORD')
|
||||
|
||||
|
||||
if not all([self.username, self.password, self.account_id, self.user_agent]):
|
||||
raise ValueError("Missing required credentials for Basic Auth. Set them in .env file or pass them to the constructor.")
|
||||
|
||||
|
||||
self.auth = (self.username, self.password)
|
||||
self.headers = {
|
||||
"User-Agent": self.user_agent,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
elif self.auth_mode == 'oauth':
|
||||
self.access_token = access_token or os.getenv('BASECAMP_ACCESS_TOKEN')
|
||||
|
||||
|
||||
if not all([self.access_token, self.account_id, self.user_agent]):
|
||||
raise ValueError("Missing required credentials for OAuth. Set them in .env file or pass them to the constructor.")
|
||||
|
||||
|
||||
self.auth = None # No basic auth needed for OAuth
|
||||
self.headers = {
|
||||
"User-Agent": self.user_agent,
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.access_token}"
|
||||
}
|
||||
|
||||
|
||||
else:
|
||||
raise ValueError("Invalid auth_mode. Must be 'basic' or 'oauth'")
|
||||
|
||||
|
||||
# Basecamp 3 uses a different URL structure
|
||||
self.base_url = f"https://3.basecampapi.com/{self.account_id}"
|
||||
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to Basecamp API."""
|
||||
response = self.get('projects.json')
|
||||
@@ -67,27 +67,27 @@ class BasecampClient:
|
||||
return True, "Connection successful"
|
||||
else:
|
||||
return False, f"Connection failed: {response.status_code} - {response.text}"
|
||||
|
||||
|
||||
def get(self, endpoint, params=None):
|
||||
"""Make a GET request to the Basecamp API."""
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
return requests.get(url, auth=self.auth, headers=self.headers, params=params)
|
||||
|
||||
|
||||
def post(self, endpoint, data=None):
|
||||
"""Make a POST request to the Basecamp API."""
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
return requests.post(url, auth=self.auth, headers=self.headers, json=data)
|
||||
|
||||
|
||||
def put(self, endpoint, data=None):
|
||||
"""Make a PUT request to the Basecamp API."""
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
return requests.put(url, auth=self.auth, headers=self.headers, json=data)
|
||||
|
||||
|
||||
def delete(self, endpoint):
|
||||
"""Make a DELETE request to the Basecamp API."""
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
return requests.delete(url, auth=self.auth, headers=self.headers)
|
||||
|
||||
|
||||
# Project methods
|
||||
def get_projects(self):
|
||||
"""Get all projects."""
|
||||
@@ -96,7 +96,7 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get projects: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_project(self, project_id):
|
||||
"""Get a specific project by ID."""
|
||||
response = self.get(f'projects/{project_id}.json')
|
||||
@@ -104,7 +104,7 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get project: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
# To-do list methods
|
||||
def get_todosets(self, project_id):
|
||||
"""Get the todoset for a project (Basecamp 3 has one todoset per project)."""
|
||||
@@ -113,20 +113,20 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todoset: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_todolists(self, project_id):
|
||||
"""Get all todolists for a project."""
|
||||
# First get the todoset ID for this project
|
||||
todoset = self.get_todosets(project_id)
|
||||
todoset_id = todoset['id']
|
||||
|
||||
|
||||
# Then get all todolists in this todoset
|
||||
response = self.get(f'todolists.json', {'todoset_id': todoset_id})
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todolists: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_todolist(self, todolist_id):
|
||||
"""Get a specific todolist."""
|
||||
response = self.get(f'todolists/{todolist_id}.json')
|
||||
@@ -134,7 +134,7 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todolist: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
# To-do methods
|
||||
def get_todos(self, todolist_id):
|
||||
"""Get all todos in a todolist."""
|
||||
@@ -143,7 +143,7 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todos: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_todo(self, todo_id):
|
||||
"""Get a specific todo."""
|
||||
response = self.get(f'todos/{todo_id}.json')
|
||||
@@ -151,7 +151,7 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get todo: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
# People methods
|
||||
def get_people(self):
|
||||
"""Get all people in the account."""
|
||||
@@ -160,7 +160,7 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get people: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
# Campfire (chat) methods
|
||||
def get_campfires(self, project_id):
|
||||
"""Get the campfire for a project."""
|
||||
@@ -169,7 +169,7 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get campfire: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_campfire_lines(self, project_id, campfire_id):
|
||||
"""Get chat lines from a campfire."""
|
||||
response = self.get(f'buckets/{project_id}/chats/{campfire_id}/lines.json')
|
||||
@@ -177,7 +177,7 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get campfire lines: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
# Message board methods
|
||||
def get_message_board(self, project_id):
|
||||
"""Get the message board for a project."""
|
||||
@@ -186,20 +186,20 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get message board: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_messages(self, project_id):
|
||||
"""Get all messages for a project."""
|
||||
# First get the message board ID
|
||||
message_board = self.get_message_board(project_id)
|
||||
message_board_id = message_board['id']
|
||||
|
||||
|
||||
# Then get all messages
|
||||
response = self.get('messages.json', {'message_board_id': message_board_id})
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get messages: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
# Schedule methods
|
||||
def get_schedule(self, project_id):
|
||||
"""Get the schedule for a project."""
|
||||
@@ -208,21 +208,21 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get schedule: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_schedule_entries(self, project_id):
|
||||
"""
|
||||
Get schedule entries for a project.
|
||||
|
||||
|
||||
Args:
|
||||
project_id (int): Project ID
|
||||
|
||||
|
||||
Returns:
|
||||
list: Schedule entries
|
||||
"""
|
||||
try:
|
||||
endpoint = f"buckets/{project_id}/schedules.json"
|
||||
schedule = self.get(endpoint)
|
||||
|
||||
|
||||
if isinstance(schedule, list) and len(schedule) > 0:
|
||||
schedule_id = schedule[0]['id']
|
||||
entries_endpoint = f"buckets/{project_id}/schedules/{schedule_id}/entries.json"
|
||||
@@ -231,39 +231,39 @@ class BasecampClient:
|
||||
return []
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to get schedule: {str(e)}")
|
||||
|
||||
|
||||
# Comments methods
|
||||
def get_comments(self, recording_id, bucket_id=None):
|
||||
"""
|
||||
Get all comments for a recording (todo, message, etc.).
|
||||
|
||||
|
||||
Args:
|
||||
recording_id (int): ID of the recording (todo, message, etc.)
|
||||
bucket_id (int, optional): Project/bucket ID. If not provided, it will be extracted from the recording ID.
|
||||
|
||||
|
||||
Returns:
|
||||
list: Comments for the recording
|
||||
"""
|
||||
if bucket_id is None:
|
||||
# Try to get the recording first to extract the bucket_id
|
||||
raise ValueError("bucket_id is required")
|
||||
|
||||
|
||||
endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json"
|
||||
response = self.get(endpoint)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get comments: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def create_comment(self, recording_id, bucket_id, content):
|
||||
"""
|
||||
Create a comment on a recording.
|
||||
|
||||
|
||||
Args:
|
||||
recording_id (int): ID of the recording to comment on
|
||||
bucket_id (int): Project/bucket ID
|
||||
content (str): Content of the comment in HTML format
|
||||
|
||||
|
||||
Returns:
|
||||
dict: The created comment
|
||||
"""
|
||||
@@ -274,15 +274,15 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to create comment: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_comment(self, comment_id, bucket_id):
|
||||
"""
|
||||
Get a specific comment.
|
||||
|
||||
|
||||
Args:
|
||||
comment_id (int): Comment ID
|
||||
bucket_id (int): Project/bucket ID
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Comment details
|
||||
"""
|
||||
@@ -292,16 +292,16 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get comment: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def update_comment(self, comment_id, bucket_id, content):
|
||||
"""
|
||||
Update a comment.
|
||||
|
||||
|
||||
Args:
|
||||
comment_id (int): Comment ID
|
||||
bucket_id (int): Project/bucket ID
|
||||
content (str): New content for the comment in HTML format
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Updated comment
|
||||
"""
|
||||
@@ -312,15 +312,15 @@ class BasecampClient:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to update comment: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def delete_comment(self, comment_id, bucket_id):
|
||||
"""
|
||||
Delete a comment.
|
||||
|
||||
|
||||
Args:
|
||||
comment_id (int): Comment ID
|
||||
bucket_id (int): Project/bucket ID
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
@@ -329,4 +329,4 @@ class BasecampClient:
|
||||
if response.status_code == 204:
|
||||
return True
|
||||
else:
|
||||
raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}")
|
||||
raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}")
|
||||
|
||||
@@ -22,24 +22,24 @@ class BasecampOAuth:
|
||||
"""
|
||||
OAuth 2.0 client for Basecamp 3.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, client_id=None, client_secret=None, redirect_uri=None, user_agent=None):
|
||||
"""Initialize the OAuth client with credentials."""
|
||||
self.client_id = client_id or os.getenv('BASECAMP_CLIENT_ID')
|
||||
self.client_secret = client_secret or os.getenv('BASECAMP_CLIENT_SECRET')
|
||||
self.redirect_uri = redirect_uri or os.getenv('BASECAMP_REDIRECT_URI')
|
||||
self.user_agent = user_agent or os.getenv('USER_AGENT')
|
||||
|
||||
|
||||
if not all([self.client_id, self.client_secret, self.redirect_uri, self.user_agent]):
|
||||
raise ValueError("Missing required OAuth credentials. Set them in .env file or pass them to the constructor.")
|
||||
|
||||
|
||||
def get_authorization_url(self, state=None):
|
||||
"""
|
||||
Get the URL to redirect the user to for authorization.
|
||||
|
||||
|
||||
Args:
|
||||
state (str, optional): A random string to maintain state between requests
|
||||
|
||||
|
||||
Returns:
|
||||
str: The authorization URL
|
||||
"""
|
||||
@@ -48,19 +48,19 @@ class BasecampOAuth:
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri
|
||||
}
|
||||
|
||||
|
||||
if state:
|
||||
params['state'] = state
|
||||
|
||||
|
||||
return f"{AUTH_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
def exchange_code_for_token(self, code):
|
||||
"""
|
||||
Exchange the authorization code for an access token.
|
||||
|
||||
|
||||
Args:
|
||||
code (str): The authorization code received after user grants permission
|
||||
|
||||
|
||||
Returns:
|
||||
dict: The token response containing access_token and refresh_token
|
||||
"""
|
||||
@@ -71,25 +71,25 @@ class BasecampOAuth:
|
||||
'client_secret': self.client_secret,
|
||||
'code': code
|
||||
}
|
||||
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
|
||||
|
||||
response = requests.post(TOKEN_URL, data=data, headers=headers)
|
||||
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to exchange code for token: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def refresh_token(self, refresh_token):
|
||||
"""
|
||||
Refresh an expired access token.
|
||||
|
||||
|
||||
Args:
|
||||
refresh_token (str): The refresh token from the original token response
|
||||
|
||||
|
||||
Returns:
|
||||
dict: The new token response containing a new access_token
|
||||
"""
|
||||
@@ -99,25 +99,25 @@ class BasecampOAuth:
|
||||
'client_secret': self.client_secret,
|
||||
'refresh_token': refresh_token
|
||||
}
|
||||
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
|
||||
|
||||
response = requests.post(TOKEN_URL, data=data, headers=headers)
|
||||
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to refresh token: {response.status_code} - {response.text}")
|
||||
|
||||
|
||||
def get_identity(self, access_token):
|
||||
"""
|
||||
Get the identity and account information for the authenticated user.
|
||||
|
||||
|
||||
Args:
|
||||
access_token (str): The OAuth access token
|
||||
|
||||
|
||||
Returns:
|
||||
dict: The identity and account information
|
||||
"""
|
||||
@@ -125,10 +125,10 @@ class BasecampOAuth:
|
||||
'User-Agent': self.user_agent,
|
||||
'Authorization': f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
|
||||
response = requests.get(IDENTITY_URL, headers=headers)
|
||||
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to get identity: {response.status_code} - {response.text}")
|
||||
raise Exception(f"Failed to get identity: {response.status_code} - {response.text}")
|
||||
|
||||
@@ -17,10 +17,10 @@ def get_python_path():
|
||||
"""Get the path to the Python executable in the virtual environment."""
|
||||
project_root = get_project_root()
|
||||
venv_python = os.path.join(project_root, "venv", "bin", "python")
|
||||
|
||||
|
||||
if os.path.exists(venv_python):
|
||||
return venv_python
|
||||
|
||||
|
||||
# Fallback to system Python
|
||||
return sys.executable
|
||||
|
||||
@@ -30,7 +30,7 @@ def generate_config():
|
||||
python_path = get_python_path()
|
||||
# Use absolute path to the MCP CLI script to avoid double-slash issues
|
||||
script_path = os.path.join(project_root, "mcp_server_cli.py")
|
||||
|
||||
|
||||
# Load .env file from project root to get BASECAMP_ACCOUNT_ID
|
||||
dotenv_path = os.path.join(project_root, ".env")
|
||||
load_dotenv(dotenv_path)
|
||||
@@ -56,13 +56,13 @@ def generate_config():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return config
|
||||
|
||||
def get_cursor_config_path():
|
||||
"""Get the path to the Cursor MCP configuration file."""
|
||||
home = Path.home()
|
||||
|
||||
|
||||
if sys.platform == "darwin": # macOS
|
||||
return home / ".cursor" / "mcp.json"
|
||||
elif sys.platform == "win32": # Windows
|
||||
@@ -74,59 +74,59 @@ def main():
|
||||
"""Main function."""
|
||||
config = generate_config()
|
||||
config_path = get_cursor_config_path()
|
||||
|
||||
|
||||
print("🔧 Generated Cursor MCP Configuration:")
|
||||
print(json.dumps(config, indent=2))
|
||||
print()
|
||||
|
||||
|
||||
print(f"📁 Configuration should be saved to: {config_path}")
|
||||
print()
|
||||
|
||||
|
||||
# Check if the file exists and offer to update it
|
||||
if config_path.exists():
|
||||
print("⚠️ Configuration file already exists.")
|
||||
response = input("Do you want to update it? (y/N): ").lower().strip()
|
||||
|
||||
|
||||
if response in ['y', 'yes']:
|
||||
# Read existing config
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
existing_config = json.load(f)
|
||||
|
||||
|
||||
# Update the basecamp server configuration
|
||||
if "mcpServers" not in existing_config:
|
||||
existing_config["mcpServers"] = {}
|
||||
|
||||
|
||||
existing_config["mcpServers"]["basecamp"] = config["mcpServers"]["basecamp"]
|
||||
|
||||
|
||||
# Write back the updated config
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(existing_config, f, indent=2)
|
||||
|
||||
|
||||
print("✅ Configuration updated successfully!")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error updating configuration: {e}")
|
||||
|
||||
|
||||
else:
|
||||
print("Configuration not updated.")
|
||||
else:
|
||||
response = input("Do you want to create the configuration file? (y/N): ").lower().strip()
|
||||
|
||||
|
||||
if response in ['y', 'yes']:
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
|
||||
print("✅ Configuration file created successfully!")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating configuration file: {e}")
|
||||
else:
|
||||
print("Configuration file not created.")
|
||||
|
||||
|
||||
print()
|
||||
print("📋 Next steps:")
|
||||
print("1. Make sure you've authenticated with Basecamp: python oauth_app.py")
|
||||
@@ -134,4 +134,4 @@ def main():
|
||||
print("3. Check Cursor Settings → MCP for a green checkmark next to 'basecamp'")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -37,11 +37,11 @@ logger = logging.getLogger('mcp_cli_server')
|
||||
|
||||
class MCPServer:
|
||||
"""MCP server implementing the Model Context Protocol for Cursor."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.tools = self._get_available_tools()
|
||||
logger.info("MCP CLI Server initialized")
|
||||
|
||||
|
||||
def _get_available_tools(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of available tools for Basecamp."""
|
||||
return [
|
||||
@@ -58,7 +58,7 @@ class MCPServer:
|
||||
"name": "get_project",
|
||||
"description": "Get details for a specific project",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "The project ID"}
|
||||
},
|
||||
@@ -124,34 +124,34 @@ class MCPServer:
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _get_basecamp_client(self) -> Optional[BasecampClient]:
|
||||
"""Get authenticated Basecamp client."""
|
||||
try:
|
||||
token_data = token_storage.get_token()
|
||||
logger.debug(f"Token data retrieved: {token_data}")
|
||||
|
||||
|
||||
if not token_data or not token_data.get('access_token'):
|
||||
logger.error("No OAuth token available")
|
||||
return None
|
||||
|
||||
|
||||
# Check if token is expired
|
||||
if token_storage.is_token_expired():
|
||||
logger.error("OAuth token has expired")
|
||||
return None
|
||||
|
||||
|
||||
# Get account_id from token data first, then fall back to env var
|
||||
account_id = token_data.get('account_id') or os.getenv('BASECAMP_ACCOUNT_ID')
|
||||
|
||||
|
||||
# Set a default user agent if none is provided
|
||||
user_agent = os.getenv('USER_AGENT') or "Basecamp MCP Server (cursor@example.com)"
|
||||
|
||||
|
||||
if not account_id:
|
||||
logger.error(f"Missing account_id. Token data: {token_data}, Env BASECAMP_ACCOUNT_ID: {os.getenv('BASECAMP_ACCOUNT_ID')}")
|
||||
return None
|
||||
|
||||
|
||||
logger.debug(f"Creating Basecamp client with account_id: {account_id}, user_agent: {user_agent}")
|
||||
|
||||
|
||||
return BasecampClient(
|
||||
access_token=token_data['access_token'],
|
||||
account_id=account_id,
|
||||
@@ -161,7 +161,7 @@ class MCPServer:
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Basecamp client: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle an MCP request."""
|
||||
method = request.get("method")
|
||||
@@ -169,9 +169,9 @@ class MCPServer:
|
||||
method_lower = method.lower() if isinstance(method, str) else ''
|
||||
params = request.get("params", {})
|
||||
request_id = request.get("id")
|
||||
|
||||
|
||||
logger.info(f"Handling request: {method}")
|
||||
|
||||
|
||||
try:
|
||||
if method_lower == "initialize":
|
||||
return {
|
||||
@@ -188,12 +188,12 @@ class MCPServer:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
elif method_lower == "initialized":
|
||||
# This is a notification, no response needed
|
||||
logger.info("Received initialized notification")
|
||||
return None
|
||||
|
||||
|
||||
elif method_lower in ("tools/list", "listtools"):
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
@@ -202,13 +202,13 @@ class MCPServer:
|
||||
"tools": self.tools
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
elif method_lower in ("tools/call", "toolscall"):
|
||||
tool_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
|
||||
result = self._execute_tool(tool_name, arguments)
|
||||
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
@@ -221,7 +221,7 @@ class MCPServer:
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
elif method_lower in ("listofferings", "list_offerings", "loffering"):
|
||||
# Respond to Cursor's ListOfferings UI request
|
||||
offerings = []
|
||||
@@ -238,7 +238,7 @@ class MCPServer:
|
||||
"offerings": offerings
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
elif method_lower == "ping":
|
||||
# Handle ping requests
|
||||
return {
|
||||
@@ -246,7 +246,7 @@ class MCPServer:
|
||||
"id": request_id,
|
||||
"result": {}
|
||||
}
|
||||
|
||||
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
@@ -256,7 +256,7 @@ class MCPServer:
|
||||
"message": f"Method not found: {method}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling request: {e}")
|
||||
return {
|
||||
@@ -267,7 +267,7 @@ class MCPServer:
|
||||
"message": f"Internal error: {str(e)}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Execute a tool and return the result."""
|
||||
client = self._get_basecamp_client()
|
||||
@@ -283,7 +283,7 @@ class MCPServer:
|
||||
"error": "Authentication required",
|
||||
"message": "Please authenticate with Basecamp first. Visit http://localhost:8000 to log in."
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
if tool_name == "get_projects":
|
||||
projects = client.get_projects()
|
||||
@@ -292,7 +292,7 @@ class MCPServer:
|
||||
"projects": projects,
|
||||
"count": len(projects)
|
||||
}
|
||||
|
||||
|
||||
elif tool_name == "get_project":
|
||||
project_id = arguments.get("project_id")
|
||||
project = client.get_project(project_id)
|
||||
@@ -300,7 +300,7 @@ class MCPServer:
|
||||
"status": "success",
|
||||
"project": project
|
||||
}
|
||||
|
||||
|
||||
elif tool_name == "get_todolists":
|
||||
project_id = arguments.get("project_id")
|
||||
todolists = client.get_todolists(project_id)
|
||||
@@ -309,7 +309,7 @@ class MCPServer:
|
||||
"todolists": todolists,
|
||||
"count": len(todolists)
|
||||
}
|
||||
|
||||
|
||||
elif tool_name == "get_todos":
|
||||
todolist_id = arguments.get("todolist_id")
|
||||
todos = client.get_todos(todolist_id)
|
||||
@@ -318,14 +318,14 @@ class MCPServer:
|
||||
"todos": todos,
|
||||
"count": len(todos)
|
||||
}
|
||||
|
||||
|
||||
elif tool_name == "search_basecamp":
|
||||
query = arguments.get("query")
|
||||
project_id = arguments.get("project_id")
|
||||
|
||||
|
||||
search = BasecampSearch(client=client)
|
||||
results = {}
|
||||
|
||||
|
||||
if project_id:
|
||||
# Search within specific project
|
||||
results["todolists"] = search.search_todolists(query, project_id)
|
||||
@@ -335,13 +335,13 @@ class MCPServer:
|
||||
results["projects"] = search.search_projects(query)
|
||||
results["todos"] = search.search_todos(query)
|
||||
results["messages"] = search.search_messages(query)
|
||||
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"query": query,
|
||||
"results": results
|
||||
}
|
||||
|
||||
|
||||
elif tool_name == "get_comments":
|
||||
recording_id = arguments.get("recording_id")
|
||||
bucket_id = arguments.get("bucket_id")
|
||||
@@ -351,7 +351,7 @@ class MCPServer:
|
||||
"comments": comments,
|
||||
"count": len(comments)
|
||||
}
|
||||
|
||||
|
||||
elif tool_name == "get_campfire_lines":
|
||||
project_id = arguments.get("project_id")
|
||||
campfire_id = arguments.get("campfire_id")
|
||||
@@ -361,13 +361,13 @@ class MCPServer:
|
||||
"campfire_lines": lines,
|
||||
"count": len(lines)
|
||||
}
|
||||
|
||||
|
||||
else:
|
||||
return {
|
||||
"error": "Unknown tool",
|
||||
"message": f"Tool '{tool_name}' is not supported"
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing tool {tool_name}: {e}")
|
||||
# Check if it's a 401 error (token expired during API call)
|
||||
@@ -380,24 +380,24 @@ class MCPServer:
|
||||
"error": "Execution error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
|
||||
def run(self):
|
||||
"""Run the MCP server, reading from stdin and writing to stdout."""
|
||||
logger.info("Starting MCP CLI server")
|
||||
|
||||
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
|
||||
request = json.loads(line)
|
||||
response = self.handle_request(request)
|
||||
|
||||
|
||||
# Write response to stdout (only if there's a response)
|
||||
if response is not None:
|
||||
print(json.dumps(response), flush=True)
|
||||
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON received: {e}")
|
||||
error_response = {
|
||||
@@ -409,11 +409,11 @@ class MCPServer:
|
||||
}
|
||||
}
|
||||
print(json.dumps(error_response), flush=True)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
error_response = {
|
||||
"jsonrpc": "2.0",
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {
|
||||
"code": -32603,
|
||||
@@ -424,4 +424,4 @@ class MCPServer:
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = MCPServer()
|
||||
server.run()
|
||||
server.run()
|
||||
|
||||
78
oauth_app.py
78
oauth_app.py
@@ -56,13 +56,13 @@ RESULTS_TEMPLATE = """
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
h1 { color: #333; }
|
||||
pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; }
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
@@ -109,9 +109,9 @@ def get_oauth_client():
|
||||
client_secret = os.getenv('BASECAMP_CLIENT_SECRET')
|
||||
redirect_uri = os.getenv('BASECAMP_REDIRECT_URI')
|
||||
user_agent = os.getenv('USER_AGENT')
|
||||
|
||||
|
||||
logger.info("Creating OAuth client with config: %s, %s, %s", client_id, redirect_uri, user_agent)
|
||||
|
||||
|
||||
return BasecampOAuth(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
@@ -127,13 +127,13 @@ def home():
|
||||
"""Home page."""
|
||||
# Check if we have a stored token
|
||||
token_data = token_storage.get_token()
|
||||
|
||||
|
||||
if token_data and token_data.get('access_token'):
|
||||
# We have a token, show token information
|
||||
access_token = token_data['access_token']
|
||||
# Mask the token for security
|
||||
masked_token = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
|
||||
|
||||
|
||||
token_info = {
|
||||
"access_token": masked_token,
|
||||
"account_id": token_data.get('account_id'),
|
||||
@@ -141,9 +141,9 @@ def home():
|
||||
"expires_at": token_data.get('expires_at'),
|
||||
"updated_at": token_data.get('updated_at')
|
||||
}
|
||||
|
||||
|
||||
logger.info("Home page: User is authenticated")
|
||||
|
||||
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Basecamp OAuth Status",
|
||||
@@ -156,9 +156,9 @@ def home():
|
||||
try:
|
||||
oauth_client = get_oauth_client()
|
||||
auth_url = oauth_client.get_authorization_url()
|
||||
|
||||
|
||||
logger.info("Home page: User not authenticated, showing login button")
|
||||
|
||||
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
title="Basecamp OAuth Demo",
|
||||
@@ -177,10 +177,10 @@ def home():
|
||||
def auth_callback():
|
||||
"""Handle the OAuth callback from Basecamp."""
|
||||
logger.info("OAuth callback called with args: %s", request.args)
|
||||
|
||||
|
||||
code = request.args.get('code')
|
||||
error = request.args.get('error')
|
||||
|
||||
|
||||
if error:
|
||||
logger.error("OAuth callback error: %s", error)
|
||||
return render_template_string(
|
||||
@@ -189,7 +189,7 @@ def auth_callback():
|
||||
message=f"Basecamp returned an error: {error}",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
|
||||
if not code:
|
||||
logger.error("OAuth callback: No code provided")
|
||||
return render_template_string(
|
||||
@@ -198,20 +198,20 @@ def auth_callback():
|
||||
message="No authorization code received.",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Exchange the code for an access token
|
||||
oauth_client = get_oauth_client()
|
||||
logger.info("Exchanging code for token")
|
||||
token_data = oauth_client.exchange_code_for_token(code)
|
||||
logger.info(f"Raw token data from Basecamp exchange: {token_data}")
|
||||
|
||||
|
||||
# Store the token in our secure storage
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in')
|
||||
account_id = os.getenv('BASECAMP_ACCOUNT_ID')
|
||||
|
||||
|
||||
if not access_token:
|
||||
logger.error("OAuth exchange: No access token received")
|
||||
return render_template_string(
|
||||
@@ -220,14 +220,14 @@ def auth_callback():
|
||||
message="No access token received from Basecamp.",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
|
||||
# Try to get identity if account_id is not set
|
||||
if not account_id:
|
||||
try:
|
||||
logger.info("Getting user identity to find account_id")
|
||||
identity = oauth_client.get_identity(access_token)
|
||||
logger.info("Identity response: %s", identity)
|
||||
|
||||
|
||||
# Find Basecamp 3 account
|
||||
if identity.get('accounts'):
|
||||
for account in identity['accounts']:
|
||||
@@ -238,7 +238,7 @@ def auth_callback():
|
||||
except Exception as identity_error:
|
||||
logger.error("Error getting identity: %s", str(identity_error))
|
||||
# Continue with the flow, but log the error
|
||||
|
||||
|
||||
logger.info("Storing token with account_id: %s", account_id)
|
||||
stored = token_storage.store_token(
|
||||
access_token=access_token,
|
||||
@@ -246,7 +246,7 @@ def auth_callback():
|
||||
expires_in=expires_in,
|
||||
account_id=account_id
|
||||
)
|
||||
|
||||
|
||||
if not stored:
|
||||
logger.error("Failed to store token")
|
||||
return render_template_string(
|
||||
@@ -255,16 +255,16 @@ def auth_callback():
|
||||
message="Failed to store token. Please try again.",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
|
||||
# Also keep the access token in session for convenience
|
||||
session['access_token'] = access_token
|
||||
if refresh_token:
|
||||
session['refresh_token'] = refresh_token
|
||||
if account_id:
|
||||
session['account_id'] = account_id
|
||||
|
||||
|
||||
logger.info("OAuth flow completed successfully")
|
||||
|
||||
|
||||
return redirect(url_for('home'))
|
||||
except Exception as e:
|
||||
logger.error("Error in OAuth callback: %s", str(e), exc_info=True)
|
||||
@@ -282,7 +282,7 @@ def get_token_api():
|
||||
This should only be accessible by the MCP server.
|
||||
"""
|
||||
logger.info("Token API called with headers: %s", request.headers)
|
||||
|
||||
|
||||
# In production, implement proper authentication for this endpoint
|
||||
# For now, we'll use a simple API key check
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
@@ -292,7 +292,7 @@ def get_token_api():
|
||||
"error": "Unauthorized",
|
||||
"message": "Invalid or missing API key"
|
||||
}), 401
|
||||
|
||||
|
||||
token_data = token_storage.get_token()
|
||||
if not token_data or not token_data.get('access_token'):
|
||||
logger.error("Token API: No valid token available")
|
||||
@@ -300,7 +300,7 @@ def get_token_api():
|
||||
"error": "Not authenticated",
|
||||
"message": "No valid token available"
|
||||
}), 404
|
||||
|
||||
|
||||
logger.info("Token API: Successfully returned token")
|
||||
return jsonify({
|
||||
"access_token": token_data['access_token'],
|
||||
@@ -320,7 +320,7 @@ def token_info():
|
||||
"""Display information about the stored token."""
|
||||
logger.info("Token info called")
|
||||
token_data = token_storage.get_token()
|
||||
|
||||
|
||||
if not token_data:
|
||||
logger.info("Token info: No token stored")
|
||||
return render_template_string(
|
||||
@@ -329,14 +329,14 @@ def token_info():
|
||||
message="No token stored.",
|
||||
show_home=True
|
||||
)
|
||||
|
||||
|
||||
# Mask the tokens for security
|
||||
access_token = token_data.get('access_token', '')
|
||||
refresh_token = token_data.get('refresh_token', '')
|
||||
|
||||
|
||||
masked_access = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
|
||||
masked_refresh = f"{refresh_token[:10]}...{refresh_token[-10:]}" if refresh_token and len(refresh_token) > 20 else "***" if refresh_token else None
|
||||
|
||||
|
||||
display_info = {
|
||||
"access_token": masked_access,
|
||||
"has_refresh_token": bool(refresh_token),
|
||||
@@ -344,7 +344,7 @@ def token_info():
|
||||
"expires_at": token_data.get('expires_at'),
|
||||
"updated_at": token_data.get('updated_at')
|
||||
}
|
||||
|
||||
|
||||
logger.info("Token info: Returned token info")
|
||||
return render_template_string(
|
||||
RESULTS_TEMPLATE,
|
||||
@@ -367,12 +367,12 @@ if __name__ == '__main__':
|
||||
logger.info("Starting OAuth app on port %s", os.environ.get('PORT', 8000))
|
||||
# Run the Flask app
|
||||
port = int(os.environ.get('PORT', 8000))
|
||||
|
||||
|
||||
# Disable debug and auto-reloader when running in production or background
|
||||
is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||
|
||||
|
||||
logger.info("Running in %s mode", "debug" if is_debug else "production")
|
||||
app.run(host='0.0.0.0', port=port, debug=is_debug, use_reloader=is_debug)
|
||||
except Exception as e:
|
||||
logger.error("Fatal error: %s", str(e), exc_info=True)
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -2,4 +2,4 @@ requests==2.31.0
|
||||
python-dotenv==1.0.0
|
||||
flask==2.3.3
|
||||
flask-cors==4.0.0
|
||||
pytest==7.4.0
|
||||
pytest==7.4.0
|
||||
|
||||
200
search_utils.py
200
search_utils.py
@@ -10,65 +10,65 @@ class BasecampSearch:
|
||||
"""
|
||||
Utility for searching across Basecamp 3 projects and to-dos.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, client=None, **kwargs):
|
||||
"""Initialize with either an existing client or credentials."""
|
||||
if client:
|
||||
self.client = client
|
||||
else:
|
||||
self.client = BasecampClient(**kwargs)
|
||||
|
||||
|
||||
def search_projects(self, query=None):
|
||||
"""
|
||||
Search all projects, optionally filtering by name.
|
||||
|
||||
|
||||
Args:
|
||||
query (str, optional): Text to search for in project names
|
||||
|
||||
|
||||
Returns:
|
||||
list: Filtered list of projects
|
||||
"""
|
||||
try:
|
||||
projects = self.client.get_projects()
|
||||
|
||||
|
||||
if query and projects:
|
||||
query = query.lower()
|
||||
projects = [
|
||||
project for project in projects
|
||||
if query in project.get('name', '').lower() or
|
||||
project for project in projects
|
||||
if query in project.get('name', '').lower() or
|
||||
query in (project.get('description') or '').lower()
|
||||
]
|
||||
|
||||
|
||||
return projects
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching projects: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def get_all_todolists(self, project_id=None):
|
||||
"""
|
||||
Get all todolists, either for a specific project or across all projects.
|
||||
|
||||
|
||||
Args:
|
||||
project_id (int, optional): Specific project ID or None for all projects
|
||||
|
||||
|
||||
Returns:
|
||||
list: List of todolists with project info
|
||||
"""
|
||||
all_todolists = []
|
||||
|
||||
|
||||
try:
|
||||
if project_id:
|
||||
# Get todolists for a specific project
|
||||
project = self.client.get_project(project_id)
|
||||
todolists = self.client.get_todolists(project_id)
|
||||
|
||||
|
||||
for todolist in todolists:
|
||||
todolist['project'] = {'id': project['id'], 'name': project['name']}
|
||||
all_todolists.append(todolist)
|
||||
else:
|
||||
# Get todolists across all projects
|
||||
projects = self.client.get_projects()
|
||||
|
||||
|
||||
for project in projects:
|
||||
project_id = project['id']
|
||||
try:
|
||||
@@ -80,56 +80,56 @@ class BasecampSearch:
|
||||
logger.error(f"Error getting todolists for project {project_id}: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all todolists: {str(e)}")
|
||||
|
||||
|
||||
return all_todolists
|
||||
|
||||
|
||||
def search_todolists(self, query=None, project_id=None):
|
||||
"""
|
||||
Search all todolists, optionally filtering by name and project.
|
||||
|
||||
|
||||
Args:
|
||||
query (str, optional): Text to search for in todolist names
|
||||
project_id (int, optional): Specific project ID or None for all projects
|
||||
|
||||
|
||||
Returns:
|
||||
list: Filtered list of todolists
|
||||
"""
|
||||
todolists = self.get_all_todolists(project_id)
|
||||
|
||||
|
||||
if query and todolists:
|
||||
query = query.lower()
|
||||
todolists = [
|
||||
todolist for todolist in todolists
|
||||
todolist for todolist in todolists
|
||||
if query in todolist.get('name', '').lower() or
|
||||
query in (todolist.get('description') or '').lower()
|
||||
]
|
||||
|
||||
|
||||
return todolists
|
||||
|
||||
|
||||
def get_all_todos(self, project_id=None, todolist_id=None, include_completed=False):
|
||||
"""
|
||||
Get all todos, with various filtering options.
|
||||
|
||||
|
||||
Args:
|
||||
project_id (int, optional): Specific project ID or None for all projects
|
||||
todolist_id (int, optional): Specific todolist ID or None for all todolists
|
||||
include_completed (bool): Whether to include completed todos
|
||||
|
||||
|
||||
Returns:
|
||||
list: List of todos with project and todolist info
|
||||
"""
|
||||
all_todos = []
|
||||
|
||||
|
||||
try:
|
||||
# Case 1: Specific todolist (regardless of project)
|
||||
if todolist_id:
|
||||
try:
|
||||
todolist = self.client.get_todolist(todolist_id)
|
||||
todos = self.client.get_todos(todolist_id)
|
||||
|
||||
|
||||
# In Basecamp 3, we need to add project info to the todolist
|
||||
# Get project ID from the URL
|
||||
project_links = [link for link in todolist.get('bucket', {}).get('links', [])
|
||||
project_links = [link for link in todolist.get('bucket', {}).get('links', [])
|
||||
if link.get('type') == 'project']
|
||||
if project_links:
|
||||
project_url = project_links[0].get('href', '')
|
||||
@@ -146,46 +146,46 @@ class BasecampSearch:
|
||||
project_name = 'Unknown Project'
|
||||
else:
|
||||
project_name = 'Unknown Project'
|
||||
|
||||
|
||||
for todo in todos:
|
||||
if not include_completed and todo.get('completed'):
|
||||
continue
|
||||
|
||||
|
||||
todo['project'] = {'id': project_id, 'name': project_name}
|
||||
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
|
||||
all_todos.append(todo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting todos for todolist {todolist_id}: {str(e)}")
|
||||
|
||||
|
||||
# Case 2: Specific project, all todolists
|
||||
elif project_id:
|
||||
project = self.client.get_project(project_id)
|
||||
todolists = self.client.get_todolists(project_id)
|
||||
|
||||
|
||||
for todolist in todolists:
|
||||
try:
|
||||
todos = self.client.get_todos(todolist['id'])
|
||||
for todo in todos:
|
||||
if not include_completed and todo.get('completed'):
|
||||
continue
|
||||
|
||||
|
||||
todo['project'] = {'id': project['id'], 'name': project['name']}
|
||||
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
|
||||
all_todos.append(todo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}")
|
||||
|
||||
|
||||
# Case 3: All projects
|
||||
else:
|
||||
todolists = self.get_all_todolists()
|
||||
|
||||
|
||||
for todolist in todolists:
|
||||
try:
|
||||
todos = self.client.get_todos(todolist['id'])
|
||||
for todo in todos:
|
||||
if not include_completed and todo.get('completed'):
|
||||
continue
|
||||
|
||||
|
||||
todo['project'] = todolist['project']
|
||||
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
|
||||
all_todos.append(todo)
|
||||
@@ -193,72 +193,72 @@ class BasecampSearch:
|
||||
logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all todos: {str(e)}")
|
||||
|
||||
|
||||
return all_todos
|
||||
|
||||
|
||||
def search_todos(self, query=None, project_id=None, todolist_id=None, include_completed=False):
|
||||
"""
|
||||
Search all todos, with various filtering options.
|
||||
|
||||
|
||||
Args:
|
||||
query (str, optional): Text to search for in todo content
|
||||
project_id (int, optional): Specific project ID or None for all projects
|
||||
todolist_id (int, optional): Specific todolist ID or None for all todolists
|
||||
include_completed (bool): Whether to include completed todos
|
||||
|
||||
|
||||
Returns:
|
||||
list: Filtered list of todos
|
||||
"""
|
||||
todos = self.get_all_todos(project_id, todolist_id, include_completed)
|
||||
|
||||
|
||||
if query and todos:
|
||||
query = query.lower()
|
||||
# In Basecamp 3, the todo content is in the 'content' field
|
||||
todos = [
|
||||
t for t in todos
|
||||
t for t in todos
|
||||
if query in t.get('content', '').lower() or
|
||||
query in (t.get('description') or '').lower()
|
||||
]
|
||||
|
||||
|
||||
return todos
|
||||
|
||||
|
||||
def search_messages(self, query=None, project_id=None):
|
||||
"""
|
||||
Search for messages across all projects or within a specific project.
|
||||
|
||||
|
||||
Args:
|
||||
query (str, optional): Search term to filter messages
|
||||
project_id (int, optional): If provided, only search within this project
|
||||
|
||||
|
||||
Returns:
|
||||
list: Matching messages
|
||||
"""
|
||||
all_messages = []
|
||||
|
||||
|
||||
try:
|
||||
# Get projects to search in
|
||||
if project_id:
|
||||
projects = [self.client.get_project(project_id)]
|
||||
else:
|
||||
projects = self.client.get_projects()
|
||||
|
||||
|
||||
for project in projects:
|
||||
project_id = project['id']
|
||||
logger.info(f"Searching messages in project {project_id} ({project.get('name', 'Unknown')})")
|
||||
|
||||
|
||||
# Check for message boards in the dock
|
||||
has_message_board = False
|
||||
message_boards = []
|
||||
|
||||
|
||||
for dock_item in project.get('dock', []):
|
||||
if dock_item.get('name') == 'message_board' and dock_item.get('enabled', False):
|
||||
has_message_board = True
|
||||
message_boards.append(dock_item)
|
||||
|
||||
|
||||
if not has_message_board:
|
||||
logger.info(f"Project {project_id} ({project.get('name', 'Unknown')}) has no enabled message boards")
|
||||
continue
|
||||
|
||||
|
||||
# Get messages from each message board
|
||||
for board in message_boards:
|
||||
board_id = board.get('id')
|
||||
@@ -267,14 +267,14 @@ class BasecampSearch:
|
||||
logger.info(f"Fetching message board {board_id} for project {project_id}")
|
||||
board_endpoint = f"buckets/{project_id}/message_boards/{board_id}.json"
|
||||
board_details = self.client.get(board_endpoint)
|
||||
|
||||
|
||||
# Then get all messages in the board
|
||||
logger.info(f"Fetching messages for board {board_id} in project {project_id}")
|
||||
messages_endpoint = f"buckets/{project_id}/message_boards/{board_id}/messages.json"
|
||||
messages = self.client.get(messages_endpoint)
|
||||
|
||||
|
||||
logger.info(f"Found {len(messages)} messages in board {board_id}")
|
||||
|
||||
|
||||
# Now get detailed content for each message
|
||||
for message in messages:
|
||||
try:
|
||||
@@ -282,13 +282,13 @@ class BasecampSearch:
|
||||
# Get detailed message content
|
||||
message_endpoint = f"buckets/{project_id}/messages/{message_id}.json"
|
||||
detailed_message = self.client.get(message_endpoint)
|
||||
|
||||
|
||||
# Add project info
|
||||
detailed_message['project'] = {
|
||||
'id': project_id,
|
||||
'name': project.get('name', 'Unknown Project')
|
||||
}
|
||||
|
||||
|
||||
# Add to results
|
||||
all_messages.append(detailed_message)
|
||||
except Exception as e:
|
||||
@@ -301,14 +301,14 @@ class BasecampSearch:
|
||||
all_messages.append(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting messages for board {board_id} in project {project_id}: {str(e)}")
|
||||
|
||||
|
||||
# Try alternate approach: get messages directly for the project
|
||||
try:
|
||||
logger.info(f"Trying alternate approach for project {project_id}")
|
||||
messages = self.client.get_messages(project_id)
|
||||
|
||||
|
||||
logger.info(f"Found {len(messages)} messages in project {project_id} using direct method")
|
||||
|
||||
|
||||
# Add project info to each message
|
||||
for message in messages:
|
||||
message['project'] = {
|
||||
@@ -318,20 +318,20 @@ class BasecampSearch:
|
||||
all_messages.append(message)
|
||||
except Exception as e2:
|
||||
logger.error(f"Error getting messages directly for project {project_id}: {str(e2)}")
|
||||
|
||||
|
||||
# Also check for message categories/topics
|
||||
try:
|
||||
# Try to get message categories
|
||||
categories_endpoint = f"buckets/{project_id}/categories.json"
|
||||
categories = self.client.get(categories_endpoint)
|
||||
|
||||
|
||||
for category in categories:
|
||||
category_id = category.get('id')
|
||||
try:
|
||||
# Get messages in this category
|
||||
category_messages_endpoint = f"buckets/{project_id}/categories/{category_id}/messages.json"
|
||||
category_messages = self.client.get(category_messages_endpoint)
|
||||
|
||||
|
||||
# Add project and category info
|
||||
for message in category_messages:
|
||||
message['project'] = {
|
||||
@@ -347,33 +347,33 @@ class BasecampSearch:
|
||||
logger.error(f"Error getting messages for category {category_id} in project {project_id}: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.info(f"No message categories found for project {project_id}: {str(e)}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching messages: {str(e)}")
|
||||
|
||||
|
||||
# Filter by query if provided
|
||||
if query and all_messages:
|
||||
query = query.lower()
|
||||
filtered_messages = []
|
||||
|
||||
|
||||
for message in all_messages:
|
||||
# Search in multiple fields
|
||||
content_matched = False
|
||||
|
||||
|
||||
# Check title/subject
|
||||
if query in (message.get('subject', '') or '').lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# Check content field
|
||||
if not content_matched and query in (message.get('content', '') or '').lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# Check content field with HTML
|
||||
if not content_matched and 'content' in message:
|
||||
content_html = message.get('content')
|
||||
if content_html and query in content_html.lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# Check raw content in various formats
|
||||
if not content_matched:
|
||||
# Try different content field formats
|
||||
@@ -382,36 +382,36 @@ class BasecampSearch:
|
||||
if query in str(message[field]).lower():
|
||||
content_matched = True
|
||||
break
|
||||
|
||||
|
||||
# Check title field
|
||||
if not content_matched and 'title' in message and message['title']:
|
||||
if query in message['title'].lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# Check creator's name
|
||||
if not content_matched and 'creator' in message and message['creator']:
|
||||
creator = message['creator']
|
||||
creator_name = f"{creator.get('name', '')} {creator.get('first_name', '')} {creator.get('last_name', '')}"
|
||||
if query in creator_name.lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# Include if content matched
|
||||
if content_matched:
|
||||
filtered_messages.append(message)
|
||||
|
||||
|
||||
logger.info(f"Found {len(filtered_messages)} messages matching query '{query}' out of {len(all_messages)} total messages")
|
||||
return filtered_messages
|
||||
|
||||
|
||||
return all_messages
|
||||
|
||||
|
||||
def search_schedule_entries(self, query=None, project_id=None):
|
||||
"""
|
||||
Search schedule entries across projects or in a specific project.
|
||||
|
||||
|
||||
Args:
|
||||
query (str, optional): Search term to filter schedule entries
|
||||
project_id (int, optional): Specific project ID to search in
|
||||
|
||||
|
||||
Returns:
|
||||
list: Matching schedule entries
|
||||
"""
|
||||
@@ -423,7 +423,7 @@ class BasecampSearch:
|
||||
else:
|
||||
# Get all projects first
|
||||
projects = self.client.get_projects()
|
||||
|
||||
|
||||
# Then get schedule entries from each
|
||||
entries = []
|
||||
for project in projects:
|
||||
@@ -436,7 +436,7 @@ class BasecampSearch:
|
||||
'name': project['name']
|
||||
}
|
||||
entries.extend(project_entries)
|
||||
|
||||
|
||||
# Filter by query if provided
|
||||
if query and entries:
|
||||
query = query.lower()
|
||||
@@ -446,21 +446,21 @@ class BasecampSearch:
|
||||
query in (entry.get('description') or '').lower() or
|
||||
(entry.get('creator') and query in entry['creator'].get('name', '').lower())
|
||||
]
|
||||
|
||||
|
||||
return entries
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching schedule entries: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def search_comments(self, query=None, recording_id=None, bucket_id=None):
|
||||
"""
|
||||
Search for comments across resources or for a specific resource.
|
||||
|
||||
|
||||
Args:
|
||||
query (str, optional): Search term to filter comments
|
||||
recording_id (int, optional): ID of the recording (todo, message, etc.) to search in
|
||||
bucket_id (int, optional): Project/bucket ID
|
||||
|
||||
|
||||
Returns:
|
||||
list: Matching comments
|
||||
"""
|
||||
@@ -476,11 +476,11 @@ class BasecampSearch:
|
||||
"api_limitation": True,
|
||||
"title": "Comment Search Limitation"
|
||||
}]
|
||||
|
||||
|
||||
# Filter by query if provided
|
||||
if query and comments:
|
||||
query = query.lower()
|
||||
|
||||
|
||||
filtered_comments = []
|
||||
for comment in comments:
|
||||
# Check content
|
||||
@@ -488,33 +488,33 @@ class BasecampSearch:
|
||||
content = comment.get('content', '')
|
||||
if content and query in content.lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# Check creator name
|
||||
if not content_matched and comment.get('creator'):
|
||||
creator_name = comment['creator'].get('name', '')
|
||||
if creator_name and query in creator_name.lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# If matched, add to results
|
||||
if content_matched:
|
||||
filtered_comments.append(comment)
|
||||
|
||||
|
||||
return filtered_comments
|
||||
|
||||
|
||||
return comments
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching comments: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def search_campfire_lines(self, query=None, project_id=None, campfire_id=None):
|
||||
"""
|
||||
Search for lines in campfire chats.
|
||||
|
||||
|
||||
Args:
|
||||
query (str, optional): Search term to filter lines
|
||||
project_id (int, optional): Project ID
|
||||
campfire_id (int, optional): Campfire ID
|
||||
|
||||
|
||||
Returns:
|
||||
list: Matching chat lines
|
||||
"""
|
||||
@@ -526,12 +526,12 @@ class BasecampSearch:
|
||||
"api_limitation": True,
|
||||
"title": "Campfire Search Limitation"
|
||||
}]
|
||||
|
||||
|
||||
lines = self.client.get_campfire_lines(project_id, campfire_id)
|
||||
|
||||
|
||||
if query and lines:
|
||||
query = query.lower()
|
||||
|
||||
|
||||
filtered_lines = []
|
||||
for line in lines:
|
||||
# Check content
|
||||
@@ -539,20 +539,20 @@ class BasecampSearch:
|
||||
content = line.get('content', '')
|
||||
if content and query in content.lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# Check creator name
|
||||
if not content_matched and line.get('creator'):
|
||||
creator_name = line['creator'].get('name', '')
|
||||
if creator_name and query in creator_name.lower():
|
||||
content_matched = True
|
||||
|
||||
|
||||
# If matched, add to results
|
||||
if content_matched:
|
||||
filtered_lines.append(line)
|
||||
|
||||
|
||||
return filtered_lines
|
||||
|
||||
|
||||
return lines
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching campfire lines: {str(e)}")
|
||||
return []
|
||||
return []
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Tests package
|
||||
# Tests package
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_cli_server_initialize():
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
|
||||
# Start the CLI server process
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "mcp_server_cli.py"],
|
||||
@@ -27,17 +27,17 @@ def test_cli_server_initialize():
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Send the request
|
||||
stdout, stderr = proc.communicate(
|
||||
input=json.dumps(request) + "\n",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
# Parse the response
|
||||
response = json.loads(stdout.strip())
|
||||
|
||||
|
||||
# Check the response
|
||||
assert response["jsonrpc"] == "2.0"
|
||||
assert response["id"] == 1
|
||||
@@ -45,7 +45,7 @@ def test_cli_server_initialize():
|
||||
assert "protocolVersion" in response["result"]
|
||||
assert "capabilities" in response["result"]
|
||||
assert "serverInfo" in response["result"]
|
||||
|
||||
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
@@ -59,14 +59,14 @@ def test_cli_server_tools_list():
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
|
||||
tools_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
|
||||
# Start the CLI server process
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "mcp_server_cli.py"],
|
||||
@@ -75,7 +75,7 @@ def test_cli_server_tools_list():
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Send both requests
|
||||
input_data = json.dumps(init_request) + "\n" + json.dumps(tools_request) + "\n"
|
||||
@@ -83,29 +83,29 @@ def test_cli_server_tools_list():
|
||||
input=input_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
# Parse responses (we get two lines)
|
||||
lines = stdout.strip().split('\n')
|
||||
assert len(lines) >= 2
|
||||
|
||||
|
||||
# Check the tools list response (second response)
|
||||
tools_response = json.loads(lines[1])
|
||||
|
||||
|
||||
assert tools_response["jsonrpc"] == "2.0"
|
||||
assert tools_response["id"] == 2
|
||||
assert "result" in tools_response
|
||||
assert "tools" in tools_response["result"]
|
||||
|
||||
|
||||
tools = tools_response["result"]["tools"]
|
||||
assert isinstance(tools, list)
|
||||
assert len(tools) > 0
|
||||
|
||||
|
||||
# Check that expected tools are present
|
||||
tool_names = [tool["name"] for tool in tools]
|
||||
expected_tools = ["get_projects", "search_basecamp", "get_todos"]
|
||||
for expected_tool in expected_tools:
|
||||
assert expected_tool in tool_names
|
||||
|
||||
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
@@ -113,9 +113,9 @@ def test_cli_server_tools_list():
|
||||
@patch.object(token_storage, 'get_token')
|
||||
def test_cli_server_tool_call_no_auth(mock_get_token):
|
||||
"""Test tool call when not authenticated."""
|
||||
# Note: The mock doesn't work across processes, so this test checks
|
||||
# Note: The mock doesn't work across processes, so this test checks
|
||||
# that the CLI server handles authentication errors gracefully
|
||||
|
||||
|
||||
# Create requests
|
||||
init_request = {
|
||||
"jsonrpc": "2.0",
|
||||
@@ -123,7 +123,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
|
||||
tool_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
@@ -133,7 +133,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
|
||||
"arguments": {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Start the CLI server process
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "mcp_server_cli.py"],
|
||||
@@ -142,7 +142,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Send both requests
|
||||
input_data = json.dumps(init_request) + "\n" + json.dumps(tool_request) + "\n"
|
||||
@@ -150,27 +150,27 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
|
||||
input=input_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
# Parse responses
|
||||
lines = stdout.strip().split('\n')
|
||||
assert len(lines) >= 2
|
||||
|
||||
|
||||
# Check the tool call response (second response)
|
||||
tool_response = json.loads(lines[1])
|
||||
|
||||
|
||||
assert tool_response["jsonrpc"] == "2.0"
|
||||
assert tool_response["id"] == 2
|
||||
assert "result" in tool_response
|
||||
assert "content" in tool_response["result"]
|
||||
|
||||
|
||||
# The content should contain some kind of response (either data or error)
|
||||
content_text = tool_response["result"]["content"][0]["text"]
|
||||
content_data = json.loads(content_text)
|
||||
|
||||
|
||||
# Since we have valid OAuth tokens, this might succeed or fail
|
||||
# We just check that we get a valid JSON response
|
||||
assert isinstance(content_data, dict)
|
||||
|
||||
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
@@ -183,7 +183,7 @@ def test_cli_server_invalid_method():
|
||||
"method": "invalid_method",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
|
||||
# Start the CLI server process
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "mcp_server_cli.py"],
|
||||
@@ -192,23 +192,23 @@ def test_cli_server_invalid_method():
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Send the request
|
||||
stdout, stderr = proc.communicate(
|
||||
input=json.dumps(request) + "\n",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
# Parse the response
|
||||
response = json.loads(stdout.strip())
|
||||
|
||||
|
||||
# Check the error response
|
||||
assert response["jsonrpc"] == "2.0"
|
||||
assert response["id"] == 1
|
||||
assert "error" in response
|
||||
assert response["error"]["code"] == -32601 # Method not found
|
||||
|
||||
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
proc.terminate()
|
||||
|
||||
@@ -42,7 +42,7 @@ def _write_tokens(tokens):
|
||||
"""Write tokens to storage."""
|
||||
# Create directory for the token file if it doesn't exist
|
||||
os.makedirs(os.path.dirname(TOKEN_FILE) if os.path.dirname(TOKEN_FILE) else '.', exist_ok=True)
|
||||
|
||||
|
||||
basecamp_data_to_write = tokens.get('basecamp', {})
|
||||
updated_at_to_write = basecamp_data_to_write.get('updated_at')
|
||||
_logger.info(f"Writing tokens to {TOKEN_FILE}. Basecamp token updated_at to be written: {updated_at_to_write}")
|
||||
@@ -50,7 +50,7 @@ def _write_tokens(tokens):
|
||||
# Set secure permissions on the file
|
||||
with open(TOKEN_FILE, 'w') as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
|
||||
|
||||
# Set permissions to only allow the current user to read/write
|
||||
try:
|
||||
os.chmod(TOKEN_FILE, 0o600)
|
||||
@@ -60,27 +60,27 @@ def _write_tokens(tokens):
|
||||
def store_token(access_token, refresh_token=None, expires_in=None, account_id=None):
|
||||
"""
|
||||
Store OAuth tokens securely.
|
||||
|
||||
|
||||
Args:
|
||||
access_token (str): The OAuth access token
|
||||
refresh_token (str, optional): The OAuth refresh token
|
||||
expires_in (int, optional): Token expiration time in seconds
|
||||
account_id (str, optional): The Basecamp account ID
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the token was stored successfully
|
||||
"""
|
||||
if not access_token:
|
||||
return False # Don't store empty tokens
|
||||
|
||||
|
||||
with _lock:
|
||||
tokens = _read_tokens()
|
||||
|
||||
|
||||
# Calculate expiration time
|
||||
expires_at = None
|
||||
if expires_in:
|
||||
expires_at = (datetime.now() + timedelta(seconds=expires_in)).isoformat()
|
||||
|
||||
|
||||
# Store the token with metadata
|
||||
tokens['basecamp'] = {
|
||||
'access_token': access_token,
|
||||
@@ -89,14 +89,14 @@ def store_token(access_token, refresh_token=None, expires_in=None, account_id=No
|
||||
'expires_at': expires_at,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
_write_tokens(tokens)
|
||||
return True
|
||||
|
||||
def get_token():
|
||||
"""
|
||||
Get the stored OAuth token.
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Token information or None if not found
|
||||
"""
|
||||
@@ -107,17 +107,17 @@ def get_token():
|
||||
def is_token_expired():
|
||||
"""
|
||||
Check if the stored token is expired.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the token is expired or not found
|
||||
"""
|
||||
with _lock:
|
||||
tokens = _read_tokens()
|
||||
token_data = tokens.get('basecamp')
|
||||
|
||||
|
||||
if not token_data or not token_data.get('expires_at'):
|
||||
return True
|
||||
|
||||
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(token_data['expires_at'])
|
||||
# Add a buffer of 5 minutes to account for clock differences
|
||||
@@ -130,4 +130,4 @@ def clear_tokens():
|
||||
with _lock:
|
||||
if os.path.exists(TOKEN_FILE):
|
||||
os.remove(TOKEN_FILE)
|
||||
return True
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user