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