chore: remove trailing spaces and ensure newline

This commit is contained in:
George Antonopoulos
2025-06-06 10:23:50 +01:00
parent 32a0de3bb0
commit 0884dcd105
15 changed files with 343 additions and 343 deletions

View File

@@ -12,4 +12,4 @@ FLASK_SECRET_KEY=your-flask-secret-key
# OAuth tokens (filled automatically by the app)
BASECAMP_ACCESS_TOKEN=
BASECAMP_REFRESH_TOKEN=
BASECAMP_REFRESH_TOKEN=

2
.gitignore vendored
View File

@@ -49,4 +49,4 @@ icons/
.idea/
.vscode/
*.swp
*.swo
*.swo

View File

@@ -138,4 +138,4 @@ Claude's servers need to be able to reach your MCP server over the internet.
5. **Firewall/Network:**
* If deploying to a server with a firewall, ensure the port your MCP server is running on (e.g., 5001) allows inbound HTTPS traffic.
By following these steps, you should be able to successfully integrate your Basecamp MCP server with Anthropic Claude, empowering Claude with your custom Basecamp functionalities.
By following these steps, you should be able to successfully integrate your Basecamp MCP server with Anthropic Claude, empowering Claude with your custom Basecamp functionalities.

View File

@@ -69,4 +69,4 @@ To further improve this implementation:
- Implement proper token encryption
- Add audit logging for security events
This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol.
This implementation provides a solid foundation for a production-ready Basecamp integration with Cursor through the MCP protocol.

View File

@@ -7,7 +7,7 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3,
### Prerequisites
- Python 3.7+
- A Basecamp 3 account
- A Basecamp 3 account
- A Basecamp OAuth application (create one at https://launchpad.37signals.com/integrations)
### Step-by-Step Instructions
@@ -42,10 +42,10 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3,
```bash
python generate_cursor_config.py
```
This script will:
- Generate the correct MCP configuration with full paths
- Automatically detect your virtual environment
- Automatically detect your virtual environment
- Include the BASECAMP_ACCOUNT_ID environment variable
- Update your Cursor configuration file automatically
@@ -71,7 +71,7 @@ python -m pytest tests/ -v
Once configured, you can use these tools in Cursor:
- `get_projects` - Get all Basecamp projects
- `get_project` - Get details for a specific project
- `get_project` - Get details for a specific project
- `get_todolists` - Get todo lists for a project
- `get_todos` - Get todos from a todo list
- `search_basecamp` - Search across projects, todos, and messages
@@ -90,7 +90,7 @@ Ask Cursor things like:
The project consists of:
1. **OAuth App** (`oauth_app.py`) - Handles OAuth 2.0 flow with Basecamp
2. **MCP Server** (`mcp_server_cli.py`) - Implements MCP protocol for Cursor
2. **MCP Server** (`mcp_server_cli.py`) - Implements MCP protocol for Cursor
3. **Token Storage** (`token_storage.py`) - Securely stores OAuth tokens
4. **Basecamp Client** (`basecamp_client.py`) - Basecamp API client library
5. **Search Utilities** (`search_utils.py`) - Search across Basecamp resources
@@ -108,7 +108,7 @@ The project consists of:
If automatic configuration doesn't work, manually edit your Cursor MCP configuration:
**On macOS/Linux:** `~/.cursor/mcp.json`
**On macOS/Linux:** `~/.cursor/mcp.json`
**On Windows:** `%APPDATA%\Cursor\mcp.json`
```json
@@ -152,7 +152,7 @@ Based on [Cursor community forums](https://forum.cursor.com/t/mcp-servers-no-too
## License
MIT License - see LICENSE file for details.
MIT License - see LICENSE file for details.
## Recent Changes
@@ -166,7 +166,7 @@ MIT License - see LICENSE file for details.
- Improved error handling and response formatting across all action handlers
- Fixed CORS support by adding the Flask-CORS package
These changes ensure more reliable and consistent responses from the MCP server, particularly for Campfire chat functionality.
These changes ensure more reliable and consistent responses from the MCP server, particularly for Campfire chat functionality.
### March 9, 2024 - Added Local Testing
@@ -255,7 +255,7 @@ TODO: To test the integration without connecting to Composio:
-d '{"tool": "GET_PROJECTS", "params": {}}'
```
TODO: For more detailed documentation on Composio integration, refer to the [official Composio documentation](https://docs.composio.dev).
TODO: For more detailed documentation on Composio integration, refer to the [official Composio documentation](https://docs.composio.dev).
## Quick Setup for Cursor
@@ -291,7 +291,7 @@ TODO: For more detailed documentation on Composio integration, refer to the [off
```bash
python generate_cursor_config.py
```
This script will:
- Generate the correct MCP configuration with full paths
- Automatically detect your virtual environment
@@ -323,7 +323,7 @@ Based on the [Cursor community forum](https://forum.cursor.com/t/mcp-servers-no-
If the automatic configuration doesn't work, manually edit your Cursor MCP configuration:
**On macOS/Linux:** `~/.cursor/mcp.json`
**On macOS/Linux:** `~/.cursor/mcp.json`
**On Windows:** `%APPDATA%\Cursor\mcp.json`
```json
@@ -366,4 +366,4 @@ The key fixes that resolve the "yellow indicator" and "tool not found" issues me
1. **Full Python executable path** instead of just "python"
2. **Environment variables** for PYTHONPATH and VIRTUAL_ENV
3. **Proper MCP protocol handling** including the 'initialized' notification
4. **Absolute paths** for all configuration values
4. **Absolute paths** for all configuration values

View File

@@ -6,12 +6,12 @@ class BasecampClient:
"""
Client for interacting with Basecamp 3 API using Basic Authentication or OAuth 2.0.
"""
def __init__(self, username=None, password=None, account_id=None, user_agent=None,
def __init__(self, username=None, password=None, account_id=None, user_agent=None,
access_token=None, auth_mode="basic"):
"""
Initialize the Basecamp client with credentials.
Args:
username (str, optional): Basecamp username (email) for Basic Auth
password (str, optional): Basecamp password for Basic Auth
@@ -22,44 +22,44 @@ class BasecampClient:
"""
# Load environment variables if not provided directly
load_dotenv()
self.auth_mode = auth_mode.lower()
self.account_id = account_id or os.getenv('BASECAMP_ACCOUNT_ID')
self.user_agent = user_agent or os.getenv('USER_AGENT')
# Set up authentication based on mode
if self.auth_mode == 'basic':
self.username = username or os.getenv('BASECAMP_USERNAME')
self.password = password or os.getenv('BASECAMP_PASSWORD')
if not all([self.username, self.password, self.account_id, self.user_agent]):
raise ValueError("Missing required credentials for Basic Auth. Set them in .env file or pass them to the constructor.")
self.auth = (self.username, self.password)
self.headers = {
"User-Agent": self.user_agent,
"Content-Type": "application/json"
}
elif self.auth_mode == 'oauth':
self.access_token = access_token or os.getenv('BASECAMP_ACCESS_TOKEN')
if not all([self.access_token, self.account_id, self.user_agent]):
raise ValueError("Missing required credentials for OAuth. Set them in .env file or pass them to the constructor.")
self.auth = None # No basic auth needed for OAuth
self.headers = {
"User-Agent": self.user_agent,
"Content-Type": "application/json",
"Authorization": f"Bearer {self.access_token}"
}
else:
raise ValueError("Invalid auth_mode. Must be 'basic' or 'oauth'")
# Basecamp 3 uses a different URL structure
self.base_url = f"https://3.basecampapi.com/{self.account_id}"
def test_connection(self):
"""Test the connection to Basecamp API."""
response = self.get('projects.json')
@@ -67,27 +67,27 @@ class BasecampClient:
return True, "Connection successful"
else:
return False, f"Connection failed: {response.status_code} - {response.text}"
def get(self, endpoint, params=None):
"""Make a GET request to the Basecamp API."""
url = f"{self.base_url}/{endpoint}"
return requests.get(url, auth=self.auth, headers=self.headers, params=params)
def post(self, endpoint, data=None):
"""Make a POST request to the Basecamp API."""
url = f"{self.base_url}/{endpoint}"
return requests.post(url, auth=self.auth, headers=self.headers, json=data)
def put(self, endpoint, data=None):
"""Make a PUT request to the Basecamp API."""
url = f"{self.base_url}/{endpoint}"
return requests.put(url, auth=self.auth, headers=self.headers, json=data)
def delete(self, endpoint):
"""Make a DELETE request to the Basecamp API."""
url = f"{self.base_url}/{endpoint}"
return requests.delete(url, auth=self.auth, headers=self.headers)
# Project methods
def get_projects(self):
"""Get all projects."""
@@ -96,7 +96,7 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get projects: {response.status_code} - {response.text}")
def get_project(self, project_id):
"""Get a specific project by ID."""
response = self.get(f'projects/{project_id}.json')
@@ -104,7 +104,7 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get project: {response.status_code} - {response.text}")
# To-do list methods
def get_todosets(self, project_id):
"""Get the todoset for a project (Basecamp 3 has one todoset per project)."""
@@ -113,20 +113,20 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get todoset: {response.status_code} - {response.text}")
def get_todolists(self, project_id):
"""Get all todolists for a project."""
# First get the todoset ID for this project
todoset = self.get_todosets(project_id)
todoset_id = todoset['id']
# Then get all todolists in this todoset
response = self.get(f'todolists.json', {'todoset_id': todoset_id})
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get todolists: {response.status_code} - {response.text}")
def get_todolist(self, todolist_id):
"""Get a specific todolist."""
response = self.get(f'todolists/{todolist_id}.json')
@@ -134,7 +134,7 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get todolist: {response.status_code} - {response.text}")
# To-do methods
def get_todos(self, todolist_id):
"""Get all todos in a todolist."""
@@ -143,7 +143,7 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get todos: {response.status_code} - {response.text}")
def get_todo(self, todo_id):
"""Get a specific todo."""
response = self.get(f'todos/{todo_id}.json')
@@ -151,7 +151,7 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get todo: {response.status_code} - {response.text}")
# People methods
def get_people(self):
"""Get all people in the account."""
@@ -160,7 +160,7 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get people: {response.status_code} - {response.text}")
# Campfire (chat) methods
def get_campfires(self, project_id):
"""Get the campfire for a project."""
@@ -169,7 +169,7 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get campfire: {response.status_code} - {response.text}")
def get_campfire_lines(self, project_id, campfire_id):
"""Get chat lines from a campfire."""
response = self.get(f'buckets/{project_id}/chats/{campfire_id}/lines.json')
@@ -177,7 +177,7 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get campfire lines: {response.status_code} - {response.text}")
# Message board methods
def get_message_board(self, project_id):
"""Get the message board for a project."""
@@ -186,20 +186,20 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get message board: {response.status_code} - {response.text}")
def get_messages(self, project_id):
"""Get all messages for a project."""
# First get the message board ID
message_board = self.get_message_board(project_id)
message_board_id = message_board['id']
# Then get all messages
response = self.get('messages.json', {'message_board_id': message_board_id})
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get messages: {response.status_code} - {response.text}")
# Schedule methods
def get_schedule(self, project_id):
"""Get the schedule for a project."""
@@ -208,21 +208,21 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get schedule: {response.status_code} - {response.text}")
def get_schedule_entries(self, project_id):
"""
Get schedule entries for a project.
Args:
project_id (int): Project ID
Returns:
list: Schedule entries
"""
try:
endpoint = f"buckets/{project_id}/schedules.json"
schedule = self.get(endpoint)
if isinstance(schedule, list) and len(schedule) > 0:
schedule_id = schedule[0]['id']
entries_endpoint = f"buckets/{project_id}/schedules/{schedule_id}/entries.json"
@@ -231,39 +231,39 @@ class BasecampClient:
return []
except Exception as e:
raise Exception(f"Failed to get schedule: {str(e)}")
# Comments methods
def get_comments(self, recording_id, bucket_id=None):
"""
Get all comments for a recording (todo, message, etc.).
Args:
recording_id (int): ID of the recording (todo, message, etc.)
bucket_id (int, optional): Project/bucket ID. If not provided, it will be extracted from the recording ID.
Returns:
list: Comments for the recording
"""
if bucket_id is None:
# Try to get the recording first to extract the bucket_id
raise ValueError("bucket_id is required")
endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json"
response = self.get(endpoint)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get comments: {response.status_code} - {response.text}")
def create_comment(self, recording_id, bucket_id, content):
"""
Create a comment on a recording.
Args:
recording_id (int): ID of the recording to comment on
bucket_id (int): Project/bucket ID
content (str): Content of the comment in HTML format
Returns:
dict: The created comment
"""
@@ -274,15 +274,15 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to create comment: {response.status_code} - {response.text}")
def get_comment(self, comment_id, bucket_id):
"""
Get a specific comment.
Args:
comment_id (int): Comment ID
bucket_id (int): Project/bucket ID
Returns:
dict: Comment details
"""
@@ -292,16 +292,16 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to get comment: {response.status_code} - {response.text}")
def update_comment(self, comment_id, bucket_id, content):
"""
Update a comment.
Args:
comment_id (int): Comment ID
bucket_id (int): Project/bucket ID
content (str): New content for the comment in HTML format
Returns:
dict: Updated comment
"""
@@ -312,15 +312,15 @@ class BasecampClient:
return response.json()
else:
raise Exception(f"Failed to update comment: {response.status_code} - {response.text}")
def delete_comment(self, comment_id, bucket_id):
"""
Delete a comment.
Args:
comment_id (int): Comment ID
bucket_id (int): Project/bucket ID
Returns:
bool: True if successful
"""
@@ -329,4 +329,4 @@ class BasecampClient:
if response.status_code == 204:
return True
else:
raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}")
raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}")

View File

@@ -22,24 +22,24 @@ class BasecampOAuth:
"""
OAuth 2.0 client for Basecamp 3.
"""
def __init__(self, client_id=None, client_secret=None, redirect_uri=None, user_agent=None):
"""Initialize the OAuth client with credentials."""
self.client_id = client_id or os.getenv('BASECAMP_CLIENT_ID')
self.client_secret = client_secret or os.getenv('BASECAMP_CLIENT_SECRET')
self.redirect_uri = redirect_uri or os.getenv('BASECAMP_REDIRECT_URI')
self.user_agent = user_agent or os.getenv('USER_AGENT')
if not all([self.client_id, self.client_secret, self.redirect_uri, self.user_agent]):
raise ValueError("Missing required OAuth credentials. Set them in .env file or pass them to the constructor.")
def get_authorization_url(self, state=None):
"""
Get the URL to redirect the user to for authorization.
Args:
state (str, optional): A random string to maintain state between requests
Returns:
str: The authorization URL
"""
@@ -48,19 +48,19 @@ class BasecampOAuth:
'client_id': self.client_id,
'redirect_uri': self.redirect_uri
}
if state:
params['state'] = state
return f"{AUTH_URL}?{urlencode(params)}"
def exchange_code_for_token(self, code):
"""
Exchange the authorization code for an access token.
Args:
code (str): The authorization code received after user grants permission
Returns:
dict: The token response containing access_token and refresh_token
"""
@@ -71,25 +71,25 @@ class BasecampOAuth:
'client_secret': self.client_secret,
'code': code
}
headers = {
'User-Agent': self.user_agent
}
response = requests.post(TOKEN_URL, data=data, headers=headers)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to exchange code for token: {response.status_code} - {response.text}")
def refresh_token(self, refresh_token):
"""
Refresh an expired access token.
Args:
refresh_token (str): The refresh token from the original token response
Returns:
dict: The new token response containing a new access_token
"""
@@ -99,25 +99,25 @@ class BasecampOAuth:
'client_secret': self.client_secret,
'refresh_token': refresh_token
}
headers = {
'User-Agent': self.user_agent
}
response = requests.post(TOKEN_URL, data=data, headers=headers)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to refresh token: {response.status_code} - {response.text}")
def get_identity(self, access_token):
"""
Get the identity and account information for the authenticated user.
Args:
access_token (str): The OAuth access token
Returns:
dict: The identity and account information
"""
@@ -125,10 +125,10 @@ class BasecampOAuth:
'User-Agent': self.user_agent,
'Authorization': f"Bearer {access_token}"
}
response = requests.get(IDENTITY_URL, headers=headers)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Failed to get identity: {response.status_code} - {response.text}")
raise Exception(f"Failed to get identity: {response.status_code} - {response.text}")

View File

@@ -17,10 +17,10 @@ def get_python_path():
"""Get the path to the Python executable in the virtual environment."""
project_root = get_project_root()
venv_python = os.path.join(project_root, "venv", "bin", "python")
if os.path.exists(venv_python):
return venv_python
# Fallback to system Python
return sys.executable
@@ -30,7 +30,7 @@ def generate_config():
python_path = get_python_path()
# Use absolute path to the MCP CLI script to avoid double-slash issues
script_path = os.path.join(project_root, "mcp_server_cli.py")
# Load .env file from project root to get BASECAMP_ACCOUNT_ID
dotenv_path = os.path.join(project_root, ".env")
load_dotenv(dotenv_path)
@@ -56,13 +56,13 @@ def generate_config():
}
}
}
return config
def get_cursor_config_path():
"""Get the path to the Cursor MCP configuration file."""
home = Path.home()
if sys.platform == "darwin": # macOS
return home / ".cursor" / "mcp.json"
elif sys.platform == "win32": # Windows
@@ -74,59 +74,59 @@ def main():
"""Main function."""
config = generate_config()
config_path = get_cursor_config_path()
print("🔧 Generated Cursor MCP Configuration:")
print(json.dumps(config, indent=2))
print()
print(f"📁 Configuration should be saved to: {config_path}")
print()
# Check if the file exists and offer to update it
if config_path.exists():
print("⚠️ Configuration file already exists.")
response = input("Do you want to update it? (y/N): ").lower().strip()
if response in ['y', 'yes']:
# Read existing config
try:
with open(config_path, 'r') as f:
existing_config = json.load(f)
# Update the basecamp server configuration
if "mcpServers" not in existing_config:
existing_config["mcpServers"] = {}
existing_config["mcpServers"]["basecamp"] = config["mcpServers"]["basecamp"]
# Write back the updated config
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
json.dump(existing_config, f, indent=2)
print("✅ Configuration updated successfully!")
except Exception as e:
print(f"❌ Error updating configuration: {e}")
else:
print("Configuration not updated.")
else:
response = input("Do you want to create the configuration file? (y/N): ").lower().strip()
if response in ['y', 'yes']:
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
print("✅ Configuration file created successfully!")
except Exception as e:
print(f"❌ Error creating configuration file: {e}")
else:
print("Configuration file not created.")
print()
print("📋 Next steps:")
print("1. Make sure you've authenticated with Basecamp: python oauth_app.py")
@@ -134,4 +134,4 @@ def main():
print("3. Check Cursor Settings → MCP for a green checkmark next to 'basecamp'")
if __name__ == "__main__":
main()
main()

View File

@@ -37,11 +37,11 @@ logger = logging.getLogger('mcp_cli_server')
class MCPServer:
"""MCP server implementing the Model Context Protocol for Cursor."""
def __init__(self):
self.tools = self._get_available_tools()
logger.info("MCP CLI Server initialized")
def _get_available_tools(self) -> List[Dict[str, Any]]:
"""Get list of available tools for Basecamp."""
return [
@@ -58,7 +58,7 @@ class MCPServer:
"name": "get_project",
"description": "Get details for a specific project",
"inputSchema": {
"type": "object",
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "The project ID"}
},
@@ -124,34 +124,34 @@ class MCPServer:
}
}
]
def _get_basecamp_client(self) -> Optional[BasecampClient]:
"""Get authenticated Basecamp client."""
try:
token_data = token_storage.get_token()
logger.debug(f"Token data retrieved: {token_data}")
if not token_data or not token_data.get('access_token'):
logger.error("No OAuth token available")
return None
# Check if token is expired
if token_storage.is_token_expired():
logger.error("OAuth token has expired")
return None
# Get account_id from token data first, then fall back to env var
account_id = token_data.get('account_id') or os.getenv('BASECAMP_ACCOUNT_ID')
# Set a default user agent if none is provided
user_agent = os.getenv('USER_AGENT') or "Basecamp MCP Server (cursor@example.com)"
if not account_id:
logger.error(f"Missing account_id. Token data: {token_data}, Env BASECAMP_ACCOUNT_ID: {os.getenv('BASECAMP_ACCOUNT_ID')}")
return None
logger.debug(f"Creating Basecamp client with account_id: {account_id}, user_agent: {user_agent}")
return BasecampClient(
access_token=token_data['access_token'],
account_id=account_id,
@@ -161,7 +161,7 @@ class MCPServer:
except Exception as e:
logger.error(f"Error creating Basecamp client: {e}")
return None
def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
"""Handle an MCP request."""
method = request.get("method")
@@ -169,9 +169,9 @@ class MCPServer:
method_lower = method.lower() if isinstance(method, str) else ''
params = request.get("params", {})
request_id = request.get("id")
logger.info(f"Handling request: {method}")
try:
if method_lower == "initialize":
return {
@@ -188,12 +188,12 @@ class MCPServer:
}
}
}
elif method_lower == "initialized":
# This is a notification, no response needed
logger.info("Received initialized notification")
return None
elif method_lower in ("tools/list", "listtools"):
return {
"jsonrpc": "2.0",
@@ -202,13 +202,13 @@ class MCPServer:
"tools": self.tools
}
}
elif method_lower in ("tools/call", "toolscall"):
tool_name = params.get("name")
arguments = params.get("arguments", {})
result = self._execute_tool(tool_name, arguments)
return {
"jsonrpc": "2.0",
"id": request_id,
@@ -221,7 +221,7 @@ class MCPServer:
]
}
}
elif method_lower in ("listofferings", "list_offerings", "loffering"):
# Respond to Cursor's ListOfferings UI request
offerings = []
@@ -238,7 +238,7 @@ class MCPServer:
"offerings": offerings
}
}
elif method_lower == "ping":
# Handle ping requests
return {
@@ -246,7 +246,7 @@ class MCPServer:
"id": request_id,
"result": {}
}
else:
return {
"jsonrpc": "2.0",
@@ -256,7 +256,7 @@ class MCPServer:
"message": f"Method not found: {method}"
}
}
except Exception as e:
logger.error(f"Error handling request: {e}")
return {
@@ -267,7 +267,7 @@ class MCPServer:
"message": f"Internal error: {str(e)}"
}
}
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a tool and return the result."""
client = self._get_basecamp_client()
@@ -283,7 +283,7 @@ class MCPServer:
"error": "Authentication required",
"message": "Please authenticate with Basecamp first. Visit http://localhost:8000 to log in."
}
try:
if tool_name == "get_projects":
projects = client.get_projects()
@@ -292,7 +292,7 @@ class MCPServer:
"projects": projects,
"count": len(projects)
}
elif tool_name == "get_project":
project_id = arguments.get("project_id")
project = client.get_project(project_id)
@@ -300,7 +300,7 @@ class MCPServer:
"status": "success",
"project": project
}
elif tool_name == "get_todolists":
project_id = arguments.get("project_id")
todolists = client.get_todolists(project_id)
@@ -309,7 +309,7 @@ class MCPServer:
"todolists": todolists,
"count": len(todolists)
}
elif tool_name == "get_todos":
todolist_id = arguments.get("todolist_id")
todos = client.get_todos(todolist_id)
@@ -318,14 +318,14 @@ class MCPServer:
"todos": todos,
"count": len(todos)
}
elif tool_name == "search_basecamp":
query = arguments.get("query")
project_id = arguments.get("project_id")
search = BasecampSearch(client=client)
results = {}
if project_id:
# Search within specific project
results["todolists"] = search.search_todolists(query, project_id)
@@ -335,13 +335,13 @@ class MCPServer:
results["projects"] = search.search_projects(query)
results["todos"] = search.search_todos(query)
results["messages"] = search.search_messages(query)
return {
"status": "success",
"query": query,
"results": results
}
elif tool_name == "get_comments":
recording_id = arguments.get("recording_id")
bucket_id = arguments.get("bucket_id")
@@ -351,7 +351,7 @@ class MCPServer:
"comments": comments,
"count": len(comments)
}
elif tool_name == "get_campfire_lines":
project_id = arguments.get("project_id")
campfire_id = arguments.get("campfire_id")
@@ -361,13 +361,13 @@ class MCPServer:
"campfire_lines": lines,
"count": len(lines)
}
else:
return {
"error": "Unknown tool",
"message": f"Tool '{tool_name}' is not supported"
}
except Exception as e:
logger.error(f"Error executing tool {tool_name}: {e}")
# Check if it's a 401 error (token expired during API call)
@@ -380,24 +380,24 @@ class MCPServer:
"error": "Execution error",
"message": str(e)
}
def run(self):
"""Run the MCP server, reading from stdin and writing to stdout."""
logger.info("Starting MCP CLI server")
for line in sys.stdin:
try:
line = line.strip()
if not line:
continue
request = json.loads(line)
response = self.handle_request(request)
# Write response to stdout (only if there's a response)
if response is not None:
print(json.dumps(response), flush=True)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON received: {e}")
error_response = {
@@ -409,11 +409,11 @@ class MCPServer:
}
}
print(json.dumps(error_response), flush=True)
except Exception as e:
logger.error(f"Unexpected error: {e}")
error_response = {
"jsonrpc": "2.0",
"jsonrpc": "2.0",
"id": None,
"error": {
"code": -32603,
@@ -424,4 +424,4 @@ class MCPServer:
if __name__ == "__main__":
server = MCPServer()
server.run()
server.run()

View File

@@ -56,13 +56,13 @@ RESULTS_TEMPLATE = """
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; }
.button {
display: inline-block;
background-color: #4CAF50;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 5px;
.button {
display: inline-block;
background-color: #4CAF50;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 5px;
margin-top: 20px;
}
.container { max-width: 1000px; margin: 0 auto; }
@@ -109,9 +109,9 @@ def get_oauth_client():
client_secret = os.getenv('BASECAMP_CLIENT_SECRET')
redirect_uri = os.getenv('BASECAMP_REDIRECT_URI')
user_agent = os.getenv('USER_AGENT')
logger.info("Creating OAuth client with config: %s, %s, %s", client_id, redirect_uri, user_agent)
return BasecampOAuth(
client_id=client_id,
client_secret=client_secret,
@@ -127,13 +127,13 @@ def home():
"""Home page."""
# Check if we have a stored token
token_data = token_storage.get_token()
if token_data and token_data.get('access_token'):
# We have a token, show token information
access_token = token_data['access_token']
# Mask the token for security
masked_token = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
token_info = {
"access_token": masked_token,
"account_id": token_data.get('account_id'),
@@ -141,9 +141,9 @@ def home():
"expires_at": token_data.get('expires_at'),
"updated_at": token_data.get('updated_at')
}
logger.info("Home page: User is authenticated")
return render_template_string(
RESULTS_TEMPLATE,
title="Basecamp OAuth Status",
@@ -156,9 +156,9 @@ def home():
try:
oauth_client = get_oauth_client()
auth_url = oauth_client.get_authorization_url()
logger.info("Home page: User not authenticated, showing login button")
return render_template_string(
RESULTS_TEMPLATE,
title="Basecamp OAuth Demo",
@@ -177,10 +177,10 @@ def home():
def auth_callback():
"""Handle the OAuth callback from Basecamp."""
logger.info("OAuth callback called with args: %s", request.args)
code = request.args.get('code')
error = request.args.get('error')
if error:
logger.error("OAuth callback error: %s", error)
return render_template_string(
@@ -189,7 +189,7 @@ def auth_callback():
message=f"Basecamp returned an error: {error}",
show_home=True
)
if not code:
logger.error("OAuth callback: No code provided")
return render_template_string(
@@ -198,20 +198,20 @@ def auth_callback():
message="No authorization code received.",
show_home=True
)
try:
# Exchange the code for an access token
oauth_client = get_oauth_client()
logger.info("Exchanging code for token")
token_data = oauth_client.exchange_code_for_token(code)
logger.info(f"Raw token data from Basecamp exchange: {token_data}")
# Store the token in our secure storage
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in')
account_id = os.getenv('BASECAMP_ACCOUNT_ID')
if not access_token:
logger.error("OAuth exchange: No access token received")
return render_template_string(
@@ -220,14 +220,14 @@ def auth_callback():
message="No access token received from Basecamp.",
show_home=True
)
# Try to get identity if account_id is not set
if not account_id:
try:
logger.info("Getting user identity to find account_id")
identity = oauth_client.get_identity(access_token)
logger.info("Identity response: %s", identity)
# Find Basecamp 3 account
if identity.get('accounts'):
for account in identity['accounts']:
@@ -238,7 +238,7 @@ def auth_callback():
except Exception as identity_error:
logger.error("Error getting identity: %s", str(identity_error))
# Continue with the flow, but log the error
logger.info("Storing token with account_id: %s", account_id)
stored = token_storage.store_token(
access_token=access_token,
@@ -246,7 +246,7 @@ def auth_callback():
expires_in=expires_in,
account_id=account_id
)
if not stored:
logger.error("Failed to store token")
return render_template_string(
@@ -255,16 +255,16 @@ def auth_callback():
message="Failed to store token. Please try again.",
show_home=True
)
# Also keep the access token in session for convenience
session['access_token'] = access_token
if refresh_token:
session['refresh_token'] = refresh_token
if account_id:
session['account_id'] = account_id
logger.info("OAuth flow completed successfully")
return redirect(url_for('home'))
except Exception as e:
logger.error("Error in OAuth callback: %s", str(e), exc_info=True)
@@ -282,7 +282,7 @@ def get_token_api():
This should only be accessible by the MCP server.
"""
logger.info("Token API called with headers: %s", request.headers)
# In production, implement proper authentication for this endpoint
# For now, we'll use a simple API key check
api_key = request.headers.get('X-API-Key')
@@ -292,7 +292,7 @@ def get_token_api():
"error": "Unauthorized",
"message": "Invalid or missing API key"
}), 401
token_data = token_storage.get_token()
if not token_data or not token_data.get('access_token'):
logger.error("Token API: No valid token available")
@@ -300,7 +300,7 @@ def get_token_api():
"error": "Not authenticated",
"message": "No valid token available"
}), 404
logger.info("Token API: Successfully returned token")
return jsonify({
"access_token": token_data['access_token'],
@@ -320,7 +320,7 @@ def token_info():
"""Display information about the stored token."""
logger.info("Token info called")
token_data = token_storage.get_token()
if not token_data:
logger.info("Token info: No token stored")
return render_template_string(
@@ -329,14 +329,14 @@ def token_info():
message="No token stored.",
show_home=True
)
# Mask the tokens for security
access_token = token_data.get('access_token', '')
refresh_token = token_data.get('refresh_token', '')
masked_access = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
masked_refresh = f"{refresh_token[:10]}...{refresh_token[-10:]}" if refresh_token and len(refresh_token) > 20 else "***" if refresh_token else None
display_info = {
"access_token": masked_access,
"has_refresh_token": bool(refresh_token),
@@ -344,7 +344,7 @@ def token_info():
"expires_at": token_data.get('expires_at'),
"updated_at": token_data.get('updated_at')
}
logger.info("Token info: Returned token info")
return render_template_string(
RESULTS_TEMPLATE,
@@ -367,12 +367,12 @@ if __name__ == '__main__':
logger.info("Starting OAuth app on port %s", os.environ.get('PORT', 8000))
# Run the Flask app
port = int(os.environ.get('PORT', 8000))
# Disable debug and auto-reloader when running in production or background
is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
logger.info("Running in %s mode", "debug" if is_debug else "production")
app.run(host='0.0.0.0', port=port, debug=is_debug, use_reloader=is_debug)
except Exception as e:
logger.error("Fatal error: %s", str(e), exc_info=True)
sys.exit(1)
sys.exit(1)

View File

@@ -2,4 +2,4 @@ requests==2.31.0
python-dotenv==1.0.0
flask==2.3.3
flask-cors==4.0.0
pytest==7.4.0
pytest==7.4.0

View File

@@ -10,65 +10,65 @@ class BasecampSearch:
"""
Utility for searching across Basecamp 3 projects and to-dos.
"""
def __init__(self, client=None, **kwargs):
"""Initialize with either an existing client or credentials."""
if client:
self.client = client
else:
self.client = BasecampClient(**kwargs)
def search_projects(self, query=None):
"""
Search all projects, optionally filtering by name.
Args:
query (str, optional): Text to search for in project names
Returns:
list: Filtered list of projects
"""
try:
projects = self.client.get_projects()
if query and projects:
query = query.lower()
projects = [
project for project in projects
if query in project.get('name', '').lower() or
project for project in projects
if query in project.get('name', '').lower() or
query in (project.get('description') or '').lower()
]
return projects
except Exception as e:
logger.error(f"Error searching projects: {str(e)}")
return []
def get_all_todolists(self, project_id=None):
"""
Get all todolists, either for a specific project or across all projects.
Args:
project_id (int, optional): Specific project ID or None for all projects
Returns:
list: List of todolists with project info
"""
all_todolists = []
try:
if project_id:
# Get todolists for a specific project
project = self.client.get_project(project_id)
todolists = self.client.get_todolists(project_id)
for todolist in todolists:
todolist['project'] = {'id': project['id'], 'name': project['name']}
all_todolists.append(todolist)
else:
# Get todolists across all projects
projects = self.client.get_projects()
for project in projects:
project_id = project['id']
try:
@@ -80,56 +80,56 @@ class BasecampSearch:
logger.error(f"Error getting todolists for project {project_id}: {str(e)}")
except Exception as e:
logger.error(f"Error getting all todolists: {str(e)}")
return all_todolists
def search_todolists(self, query=None, project_id=None):
"""
Search all todolists, optionally filtering by name and project.
Args:
query (str, optional): Text to search for in todolist names
project_id (int, optional): Specific project ID or None for all projects
Returns:
list: Filtered list of todolists
"""
todolists = self.get_all_todolists(project_id)
if query and todolists:
query = query.lower()
todolists = [
todolist for todolist in todolists
todolist for todolist in todolists
if query in todolist.get('name', '').lower() or
query in (todolist.get('description') or '').lower()
]
return todolists
def get_all_todos(self, project_id=None, todolist_id=None, include_completed=False):
"""
Get all todos, with various filtering options.
Args:
project_id (int, optional): Specific project ID or None for all projects
todolist_id (int, optional): Specific todolist ID or None for all todolists
include_completed (bool): Whether to include completed todos
Returns:
list: List of todos with project and todolist info
"""
all_todos = []
try:
# Case 1: Specific todolist (regardless of project)
if todolist_id:
try:
todolist = self.client.get_todolist(todolist_id)
todos = self.client.get_todos(todolist_id)
# In Basecamp 3, we need to add project info to the todolist
# Get project ID from the URL
project_links = [link for link in todolist.get('bucket', {}).get('links', [])
project_links = [link for link in todolist.get('bucket', {}).get('links', [])
if link.get('type') == 'project']
if project_links:
project_url = project_links[0].get('href', '')
@@ -146,46 +146,46 @@ class BasecampSearch:
project_name = 'Unknown Project'
else:
project_name = 'Unknown Project'
for todo in todos:
if not include_completed and todo.get('completed'):
continue
todo['project'] = {'id': project_id, 'name': project_name}
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
all_todos.append(todo)
except Exception as e:
logger.error(f"Error getting todos for todolist {todolist_id}: {str(e)}")
# Case 2: Specific project, all todolists
elif project_id:
project = self.client.get_project(project_id)
todolists = self.client.get_todolists(project_id)
for todolist in todolists:
try:
todos = self.client.get_todos(todolist['id'])
for todo in todos:
if not include_completed and todo.get('completed'):
continue
todo['project'] = {'id': project['id'], 'name': project['name']}
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
all_todos.append(todo)
except Exception as e:
logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}")
# Case 3: All projects
else:
todolists = self.get_all_todolists()
for todolist in todolists:
try:
todos = self.client.get_todos(todolist['id'])
for todo in todos:
if not include_completed and todo.get('completed'):
continue
todo['project'] = todolist['project']
todo['todolist'] = {'id': todolist['id'], 'name': todolist['name']}
all_todos.append(todo)
@@ -193,72 +193,72 @@ class BasecampSearch:
logger.error(f"Error getting todos for todolist {todolist['id']}: {str(e)}")
except Exception as e:
logger.error(f"Error getting all todos: {str(e)}")
return all_todos
def search_todos(self, query=None, project_id=None, todolist_id=None, include_completed=False):
"""
Search all todos, with various filtering options.
Args:
query (str, optional): Text to search for in todo content
project_id (int, optional): Specific project ID or None for all projects
todolist_id (int, optional): Specific todolist ID or None for all todolists
include_completed (bool): Whether to include completed todos
Returns:
list: Filtered list of todos
"""
todos = self.get_all_todos(project_id, todolist_id, include_completed)
if query and todos:
query = query.lower()
# In Basecamp 3, the todo content is in the 'content' field
todos = [
t for t in todos
t for t in todos
if query in t.get('content', '').lower() or
query in (t.get('description') or '').lower()
]
return todos
def search_messages(self, query=None, project_id=None):
"""
Search for messages across all projects or within a specific project.
Args:
query (str, optional): Search term to filter messages
project_id (int, optional): If provided, only search within this project
Returns:
list: Matching messages
"""
all_messages = []
try:
# Get projects to search in
if project_id:
projects = [self.client.get_project(project_id)]
else:
projects = self.client.get_projects()
for project in projects:
project_id = project['id']
logger.info(f"Searching messages in project {project_id} ({project.get('name', 'Unknown')})")
# Check for message boards in the dock
has_message_board = False
message_boards = []
for dock_item in project.get('dock', []):
if dock_item.get('name') == 'message_board' and dock_item.get('enabled', False):
has_message_board = True
message_boards.append(dock_item)
if not has_message_board:
logger.info(f"Project {project_id} ({project.get('name', 'Unknown')}) has no enabled message boards")
continue
# Get messages from each message board
for board in message_boards:
board_id = board.get('id')
@@ -267,14 +267,14 @@ class BasecampSearch:
logger.info(f"Fetching message board {board_id} for project {project_id}")
board_endpoint = f"buckets/{project_id}/message_boards/{board_id}.json"
board_details = self.client.get(board_endpoint)
# Then get all messages in the board
logger.info(f"Fetching messages for board {board_id} in project {project_id}")
messages_endpoint = f"buckets/{project_id}/message_boards/{board_id}/messages.json"
messages = self.client.get(messages_endpoint)
logger.info(f"Found {len(messages)} messages in board {board_id}")
# Now get detailed content for each message
for message in messages:
try:
@@ -282,13 +282,13 @@ class BasecampSearch:
# Get detailed message content
message_endpoint = f"buckets/{project_id}/messages/{message_id}.json"
detailed_message = self.client.get(message_endpoint)
# Add project info
detailed_message['project'] = {
'id': project_id,
'name': project.get('name', 'Unknown Project')
}
# Add to results
all_messages.append(detailed_message)
except Exception as e:
@@ -301,14 +301,14 @@ class BasecampSearch:
all_messages.append(message)
except Exception as e:
logger.error(f"Error getting messages for board {board_id} in project {project_id}: {str(e)}")
# Try alternate approach: get messages directly for the project
try:
logger.info(f"Trying alternate approach for project {project_id}")
messages = self.client.get_messages(project_id)
logger.info(f"Found {len(messages)} messages in project {project_id} using direct method")
# Add project info to each message
for message in messages:
message['project'] = {
@@ -318,20 +318,20 @@ class BasecampSearch:
all_messages.append(message)
except Exception as e2:
logger.error(f"Error getting messages directly for project {project_id}: {str(e2)}")
# Also check for message categories/topics
try:
# Try to get message categories
categories_endpoint = f"buckets/{project_id}/categories.json"
categories = self.client.get(categories_endpoint)
for category in categories:
category_id = category.get('id')
try:
# Get messages in this category
category_messages_endpoint = f"buckets/{project_id}/categories/{category_id}/messages.json"
category_messages = self.client.get(category_messages_endpoint)
# Add project and category info
for message in category_messages:
message['project'] = {
@@ -347,33 +347,33 @@ class BasecampSearch:
logger.error(f"Error getting messages for category {category_id} in project {project_id}: {str(e)}")
except Exception as e:
logger.info(f"No message categories found for project {project_id}: {str(e)}")
except Exception as e:
logger.error(f"Error searching messages: {str(e)}")
# Filter by query if provided
if query and all_messages:
query = query.lower()
filtered_messages = []
for message in all_messages:
# Search in multiple fields
content_matched = False
# Check title/subject
if query in (message.get('subject', '') or '').lower():
content_matched = True
# Check content field
if not content_matched and query in (message.get('content', '') or '').lower():
content_matched = True
# Check content field with HTML
if not content_matched and 'content' in message:
content_html = message.get('content')
if content_html and query in content_html.lower():
content_matched = True
# Check raw content in various formats
if not content_matched:
# Try different content field formats
@@ -382,36 +382,36 @@ class BasecampSearch:
if query in str(message[field]).lower():
content_matched = True
break
# Check title field
if not content_matched and 'title' in message and message['title']:
if query in message['title'].lower():
content_matched = True
# Check creator's name
if not content_matched and 'creator' in message and message['creator']:
creator = message['creator']
creator_name = f"{creator.get('name', '')} {creator.get('first_name', '')} {creator.get('last_name', '')}"
if query in creator_name.lower():
content_matched = True
# Include if content matched
if content_matched:
filtered_messages.append(message)
logger.info(f"Found {len(filtered_messages)} messages matching query '{query}' out of {len(all_messages)} total messages")
return filtered_messages
return all_messages
def search_schedule_entries(self, query=None, project_id=None):
"""
Search schedule entries across projects or in a specific project.
Args:
query (str, optional): Search term to filter schedule entries
project_id (int, optional): Specific project ID to search in
Returns:
list: Matching schedule entries
"""
@@ -423,7 +423,7 @@ class BasecampSearch:
else:
# Get all projects first
projects = self.client.get_projects()
# Then get schedule entries from each
entries = []
for project in projects:
@@ -436,7 +436,7 @@ class BasecampSearch:
'name': project['name']
}
entries.extend(project_entries)
# Filter by query if provided
if query and entries:
query = query.lower()
@@ -446,21 +446,21 @@ class BasecampSearch:
query in (entry.get('description') or '').lower() or
(entry.get('creator') and query in entry['creator'].get('name', '').lower())
]
return entries
except Exception as e:
logger.error(f"Error searching schedule entries: {str(e)}")
return []
def search_comments(self, query=None, recording_id=None, bucket_id=None):
"""
Search for comments across resources or for a specific resource.
Args:
query (str, optional): Search term to filter comments
recording_id (int, optional): ID of the recording (todo, message, etc.) to search in
bucket_id (int, optional): Project/bucket ID
Returns:
list: Matching comments
"""
@@ -476,11 +476,11 @@ class BasecampSearch:
"api_limitation": True,
"title": "Comment Search Limitation"
}]
# Filter by query if provided
if query and comments:
query = query.lower()
filtered_comments = []
for comment in comments:
# Check content
@@ -488,33 +488,33 @@ class BasecampSearch:
content = comment.get('content', '')
if content and query in content.lower():
content_matched = True
# Check creator name
if not content_matched and comment.get('creator'):
creator_name = comment['creator'].get('name', '')
if creator_name and query in creator_name.lower():
content_matched = True
# If matched, add to results
if content_matched:
filtered_comments.append(comment)
return filtered_comments
return comments
except Exception as e:
logger.error(f"Error searching comments: {str(e)}")
return []
def search_campfire_lines(self, query=None, project_id=None, campfire_id=None):
"""
Search for lines in campfire chats.
Args:
query (str, optional): Search term to filter lines
project_id (int, optional): Project ID
campfire_id (int, optional): Campfire ID
Returns:
list: Matching chat lines
"""
@@ -526,12 +526,12 @@ class BasecampSearch:
"api_limitation": True,
"title": "Campfire Search Limitation"
}]
lines = self.client.get_campfire_lines(project_id, campfire_id)
if query and lines:
query = query.lower()
filtered_lines = []
for line in lines:
# Check content
@@ -539,20 +539,20 @@ class BasecampSearch:
content = line.get('content', '')
if content and query in content.lower():
content_matched = True
# Check creator name
if not content_matched and line.get('creator'):
creator_name = line['creator'].get('name', '')
if creator_name and query in creator_name.lower():
content_matched = True
# If matched, add to results
if content_matched:
filtered_lines.append(line)
return filtered_lines
return lines
except Exception as e:
logger.error(f"Error searching campfire lines: {str(e)}")
return []
return []

View File

@@ -1 +1 @@
# Tests package
# Tests package

View File

@@ -18,7 +18,7 @@ def test_cli_server_initialize():
"method": "initialize",
"params": {}
}
# Start the CLI server process
proc = subprocess.Popen(
[sys.executable, "mcp_server_cli.py"],
@@ -27,17 +27,17 @@ def test_cli_server_initialize():
stderr=subprocess.PIPE,
text=True
)
try:
# Send the request
stdout, stderr = proc.communicate(
input=json.dumps(request) + "\n",
timeout=10
)
# Parse the response
response = json.loads(stdout.strip())
# Check the response
assert response["jsonrpc"] == "2.0"
assert response["id"] == 1
@@ -45,7 +45,7 @@ def test_cli_server_initialize():
assert "protocolVersion" in response["result"]
assert "capabilities" in response["result"]
assert "serverInfo" in response["result"]
finally:
if proc.poll() is None:
proc.terminate()
@@ -59,14 +59,14 @@ def test_cli_server_tools_list():
"method": "initialize",
"params": {}
}
tools_request = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
# Start the CLI server process
proc = subprocess.Popen(
[sys.executable, "mcp_server_cli.py"],
@@ -75,7 +75,7 @@ def test_cli_server_tools_list():
stderr=subprocess.PIPE,
text=True
)
try:
# Send both requests
input_data = json.dumps(init_request) + "\n" + json.dumps(tools_request) + "\n"
@@ -83,29 +83,29 @@ def test_cli_server_tools_list():
input=input_data,
timeout=10
)
# Parse responses (we get two lines)
lines = stdout.strip().split('\n')
assert len(lines) >= 2
# Check the tools list response (second response)
tools_response = json.loads(lines[1])
assert tools_response["jsonrpc"] == "2.0"
assert tools_response["id"] == 2
assert "result" in tools_response
assert "tools" in tools_response["result"]
tools = tools_response["result"]["tools"]
assert isinstance(tools, list)
assert len(tools) > 0
# Check that expected tools are present
tool_names = [tool["name"] for tool in tools]
expected_tools = ["get_projects", "search_basecamp", "get_todos"]
for expected_tool in expected_tools:
assert expected_tool in tool_names
finally:
if proc.poll() is None:
proc.terminate()
@@ -113,9 +113,9 @@ def test_cli_server_tools_list():
@patch.object(token_storage, 'get_token')
def test_cli_server_tool_call_no_auth(mock_get_token):
"""Test tool call when not authenticated."""
# Note: The mock doesn't work across processes, so this test checks
# Note: The mock doesn't work across processes, so this test checks
# that the CLI server handles authentication errors gracefully
# Create requests
init_request = {
"jsonrpc": "2.0",
@@ -123,7 +123,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
"method": "initialize",
"params": {}
}
tool_request = {
"jsonrpc": "2.0",
"id": 2,
@@ -133,7 +133,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
"arguments": {}
}
}
# Start the CLI server process
proc = subprocess.Popen(
[sys.executable, "mcp_server_cli.py"],
@@ -142,7 +142,7 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
stderr=subprocess.PIPE,
text=True
)
try:
# Send both requests
input_data = json.dumps(init_request) + "\n" + json.dumps(tool_request) + "\n"
@@ -150,27 +150,27 @@ def test_cli_server_tool_call_no_auth(mock_get_token):
input=input_data,
timeout=10
)
# Parse responses
lines = stdout.strip().split('\n')
assert len(lines) >= 2
# Check the tool call response (second response)
tool_response = json.loads(lines[1])
assert tool_response["jsonrpc"] == "2.0"
assert tool_response["id"] == 2
assert "result" in tool_response
assert "content" in tool_response["result"]
# The content should contain some kind of response (either data or error)
content_text = tool_response["result"]["content"][0]["text"]
content_data = json.loads(content_text)
# Since we have valid OAuth tokens, this might succeed or fail
# We just check that we get a valid JSON response
assert isinstance(content_data, dict)
finally:
if proc.poll() is None:
proc.terminate()
@@ -183,7 +183,7 @@ def test_cli_server_invalid_method():
"method": "invalid_method",
"params": {}
}
# Start the CLI server process
proc = subprocess.Popen(
[sys.executable, "mcp_server_cli.py"],
@@ -192,23 +192,23 @@ def test_cli_server_invalid_method():
stderr=subprocess.PIPE,
text=True
)
try:
# Send the request
stdout, stderr = proc.communicate(
input=json.dumps(request) + "\n",
timeout=10
)
# Parse the response
response = json.loads(stdout.strip())
# Check the error response
assert response["jsonrpc"] == "2.0"
assert response["id"] == 1
assert "error" in response
assert response["error"]["code"] == -32601 # Method not found
finally:
if proc.poll() is None:
proc.terminate()
proc.terminate()

View File

@@ -42,7 +42,7 @@ def _write_tokens(tokens):
"""Write tokens to storage."""
# Create directory for the token file if it doesn't exist
os.makedirs(os.path.dirname(TOKEN_FILE) if os.path.dirname(TOKEN_FILE) else '.', exist_ok=True)
basecamp_data_to_write = tokens.get('basecamp', {})
updated_at_to_write = basecamp_data_to_write.get('updated_at')
_logger.info(f"Writing tokens to {TOKEN_FILE}. Basecamp token updated_at to be written: {updated_at_to_write}")
@@ -50,7 +50,7 @@ def _write_tokens(tokens):
# Set secure permissions on the file
with open(TOKEN_FILE, 'w') as f:
json.dump(tokens, f, indent=2)
# Set permissions to only allow the current user to read/write
try:
os.chmod(TOKEN_FILE, 0o600)
@@ -60,27 +60,27 @@ def _write_tokens(tokens):
def store_token(access_token, refresh_token=None, expires_in=None, account_id=None):
"""
Store OAuth tokens securely.
Args:
access_token (str): The OAuth access token
refresh_token (str, optional): The OAuth refresh token
expires_in (int, optional): Token expiration time in seconds
account_id (str, optional): The Basecamp account ID
Returns:
bool: True if the token was stored successfully
"""
if not access_token:
return False # Don't store empty tokens
with _lock:
tokens = _read_tokens()
# Calculate expiration time
expires_at = None
if expires_in:
expires_at = (datetime.now() + timedelta(seconds=expires_in)).isoformat()
# Store the token with metadata
tokens['basecamp'] = {
'access_token': access_token,
@@ -89,14 +89,14 @@ def store_token(access_token, refresh_token=None, expires_in=None, account_id=No
'expires_at': expires_at,
'updated_at': datetime.now().isoformat()
}
_write_tokens(tokens)
return True
def get_token():
"""
Get the stored OAuth token.
Returns:
dict: Token information or None if not found
"""
@@ -107,17 +107,17 @@ def get_token():
def is_token_expired():
"""
Check if the stored token is expired.
Returns:
bool: True if the token is expired or not found
"""
with _lock:
tokens = _read_tokens()
token_data = tokens.get('basecamp')
if not token_data or not token_data.get('expires_at'):
return True
try:
expires_at = datetime.fromisoformat(token_data['expires_at'])
# Add a buffer of 5 minutes to account for clock differences
@@ -130,4 +130,4 @@ def clear_tokens():
with _lock:
if os.path.exists(TOKEN_FILE):
os.remove(TOKEN_FILE)
return True
return True