Merge feature/sse-mcp-cursor-compat into main - Added CLI MCP server, tests, and Cursor config generator
This commit is contained in:
112
README.md
112
README.md
@@ -37,45 +37,109 @@ This project provides a MCP (Model Context Protocol) integration for Basecamp 3,
|
||||
```bash
|
||||
python oauth_app.py
|
||||
```
|
||||
- Visit http://localhost:8000 in your browser
|
||||
- Click "Log in with Basecamp" and complete the OAuth flow
|
||||
- Keep the OAuth app running in the background
|
||||
Visit http://localhost:8000 and complete the OAuth flow.
|
||||
|
||||
4. **Generate Cursor configuration:**
|
||||
4. **Generate and install Cursor configuration:**
|
||||
```bash
|
||||
python generate_cursor_config.py
|
||||
```
|
||||
This will automatically create the Cursor MCP configuration file in the correct location.
|
||||
|
||||
This script will:
|
||||
- Generate the correct MCP configuration with full paths
|
||||
- Automatically detect your virtual environment
|
||||
- Include the BASECAMP_ACCOUNT_ID environment variable
|
||||
- Update your Cursor configuration file automatically
|
||||
|
||||
5. **Restart Cursor** to load the new MCP configuration.
|
||||
5. **Restart Cursor completely** (quit and reopen, not just reload)
|
||||
|
||||
6. **Test the integration** by using the Basecamp MCP tools in Cursor!
|
||||
6. **Verify in Cursor:**
|
||||
- Go to Cursor Settings → MCP
|
||||
- You should see "basecamp" with a **green checkmark**
|
||||
- Available tools: "get_projects", "search_basecamp", "get_project", etc.
|
||||
|
||||
### Test Your Setup
|
||||
|
||||
```bash
|
||||
# Quick test the MCP server
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python mcp_server_cli.py
|
||||
|
||||
# Run automated tests
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
Once configured, you can use these tools in Cursor:
|
||||
|
||||
- **Get Projects**: List all your Basecamp projects
|
||||
- **Get Project**: Get details for a specific project
|
||||
- **Get Todo Lists**: Get todo lists for a project
|
||||
- **Get Todos**: Get todos from a todo list
|
||||
- **Search Basecamp**: Search across projects, todos, and messages
|
||||
- **Get Comments**: Get comments for any Basecamp item
|
||||
- **Get Campfire Lines**: Get recent messages from project chat rooms
|
||||
- `get_projects` - Get all Basecamp projects
|
||||
- `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
|
||||
- `get_comments` - Get comments for a Basecamp item
|
||||
|
||||
### Example Cursor Usage
|
||||
|
||||
Ask Cursor things like:
|
||||
- "Show me all my Basecamp projects"
|
||||
- "What todos are in project X?"
|
||||
- "Search for messages containing 'deadline'"
|
||||
- "Get details for the Technology project"
|
||||
|
||||
## Architecture
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Issues
|
||||
- Make sure the OAuth app is running: `python oauth_app.py`
|
||||
- Visit http://localhost:8000 and re-authenticate if needed
|
||||
- Check that your `.env` file has the correct credentials
|
||||
### Common Issues
|
||||
|
||||
### Cursor Connection Issues
|
||||
- Restart Cursor after running `generate_cursor_config.py`
|
||||
- Check that the generated configuration includes your `BASECAMP_ACCOUNT_ID`
|
||||
- Make sure your virtual environment is activated when running the OAuth app
|
||||
- **Yellow indicator (not green):** Check that paths in Cursor config are correct
|
||||
- **"No tools available":** Make sure you completed OAuth authentication first
|
||||
- **"Tool not found" errors:** Restart Cursor completely and check `mcp_cli_server.log`
|
||||
- **Missing BASECAMP_ACCOUNT_ID:** The config generator automatically includes this from your `.env` file
|
||||
|
||||
### Configuration Issues
|
||||
|
||||
If automatic configuration doesn't work, manually edit your Cursor MCP configuration:
|
||||
|
||||
**On macOS/Linux:** `~/.cursor/mcp.json`
|
||||
**On Windows:** `%APPDATA%\Cursor\mcp.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"basecamp": {
|
||||
"command": "/full/path/to/your/project/venv/bin/python",
|
||||
"args": ["/full/path/to/your/project/mcp_server_cli.py"],
|
||||
"cwd": "/full/path/to/your/project",
|
||||
"env": {
|
||||
"PYTHONPATH": "/full/path/to/your/project",
|
||||
"VIRTUAL_ENV": "/full/path/to/your/project/venv",
|
||||
"BASECAMP_ACCOUNT_ID": "your_account_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Requirements
|
||||
|
||||
Based on [Cursor community forums](https://forum.cursor.com/t/mcp-servers-no-tools-found/49094), the following are essential:
|
||||
|
||||
1. **Full executable paths** (not just "python")
|
||||
2. **Proper environment variables** (PYTHONPATH, VIRTUAL_ENV, BASECAMP_ACCOUNT_ID)
|
||||
3. **Correct working directory** (cwd)
|
||||
4. **MCP protocol compliance** (our server handles this correctly)
|
||||
|
||||
## Finding Your Account ID
|
||||
|
||||
### Finding Your Account ID
|
||||
If you don't know your Basecamp account ID:
|
||||
1. Log into Basecamp in your browser
|
||||
2. Look at the URL - it will be like `https://3.basecamp.com/4389629/projects`
|
||||
@@ -89,4 +153,4 @@ If you don't know your Basecamp account ID:
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Example client for using the Basecamp MCP server with Composio.
|
||||
This example demonstrates MCP protocol integration requirements.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Configuration
|
||||
MCP_SERVER_URL = "http://localhost:5001"
|
||||
COMPOSIO_API_KEY = os.getenv("COMPOSIO_API_KEY")
|
||||
|
||||
class BasecampComposioClient:
|
||||
"""A client for interacting with Basecamp through Composio's MCP protocol."""
|
||||
|
||||
def __init__(self, mcp_server_url=MCP_SERVER_URL):
|
||||
"""Initialize the client with the MCP server URL."""
|
||||
self.mcp_server_url = mcp_server_url
|
||||
self.headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# Add Composio API key if available
|
||||
if COMPOSIO_API_KEY:
|
||||
self.headers["X-Composio-API-Key"] = COMPOSIO_API_KEY
|
||||
|
||||
def check_auth(self):
|
||||
"""Check if OAuth authentication is available."""
|
||||
response = requests.get(
|
||||
f"{self.mcp_server_url}/composio/check_auth",
|
||||
headers=self.headers
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def get_schema(self):
|
||||
"""Get the schema of available tools."""
|
||||
response = requests.get(
|
||||
f"{self.mcp_server_url}/composio/schema",
|
||||
headers=self.headers
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def execute_tool(self, tool_name, params=None):
|
||||
"""Execute a tool with the given parameters."""
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.mcp_server_url}/composio/tool",
|
||||
headers=self.headers,
|
||||
json={"tool": tool_name, "params": params}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def get_projects(self):
|
||||
"""Get all projects from Basecamp."""
|
||||
return self.execute_tool("GET_PROJECTS")
|
||||
|
||||
def get_project(self, project_id):
|
||||
"""Get details for a specific project."""
|
||||
return self.execute_tool("GET_PROJECT", {"project_id": project_id})
|
||||
|
||||
def get_todolists(self, project_id):
|
||||
"""Get all todo lists for a project."""
|
||||
return self.execute_tool("GET_TODOLISTS", {"project_id": project_id})
|
||||
|
||||
def get_todos(self, todolist_id):
|
||||
"""Get all todos for a specific todolist."""
|
||||
return self.execute_tool("GET_TODOS", {"todolist_id": todolist_id})
|
||||
|
||||
def get_campfire(self, project_id):
|
||||
"""Get all chat rooms (campfires) for a project."""
|
||||
return self.execute_tool("GET_CAMPFIRE", {"project_id": project_id})
|
||||
|
||||
def get_campfire_lines(self, project_id, campfire_id):
|
||||
"""Get messages from a specific chat room."""
|
||||
return self.execute_tool("GET_CAMPFIRE_LINES", {
|
||||
"project_id": project_id,
|
||||
"campfire_id": campfire_id
|
||||
})
|
||||
|
||||
def search(self, query, project_id=None):
|
||||
"""Search across Basecamp resources."""
|
||||
params = {"query": query}
|
||||
if project_id:
|
||||
params["project_id"] = project_id
|
||||
return self.execute_tool("SEARCH", params)
|
||||
|
||||
def get_comments(self, recording_id, bucket_id):
|
||||
"""Get comments for a specific Basecamp object."""
|
||||
return self.execute_tool("GET_COMMENTS", {
|
||||
"recording_id": recording_id,
|
||||
"bucket_id": bucket_id
|
||||
})
|
||||
|
||||
def create_comment(self, recording_id, bucket_id, content):
|
||||
"""Create a new comment on a Basecamp object."""
|
||||
return self.execute_tool("CREATE_COMMENT", {
|
||||
"recording_id": recording_id,
|
||||
"bucket_id": bucket_id,
|
||||
"content": content
|
||||
})
|
||||
|
||||
def main():
|
||||
"""Main function to demonstrate the client."""
|
||||
client = BasecampComposioClient()
|
||||
|
||||
# Verify connectivity to MCP server
|
||||
print("==== Basecamp MCP-Composio Integration Test ====")
|
||||
|
||||
# Check authentication
|
||||
print("\n1. Checking authentication status...")
|
||||
auth_status = client.check_auth()
|
||||
print(f"Authentication Status: {json.dumps(auth_status, indent=2)}")
|
||||
|
||||
if auth_status.get("status") == "error":
|
||||
print(f"Please authenticate at: {auth_status.get('error', {}).get('auth_url', '')}")
|
||||
return
|
||||
|
||||
# Get available tools
|
||||
print("\n2. Retrieving tool schema...")
|
||||
schema = client.get_schema()
|
||||
print(f"Server Name: {schema.get('name')}")
|
||||
print(f"Version: {schema.get('version')}")
|
||||
print(f"Authentication Type: {schema.get('auth', {}).get('type')}")
|
||||
print("\nAvailable Tools:")
|
||||
for tool in schema.get("tools", []):
|
||||
required_params = tool.get("parameters", {}).get("required", [])
|
||||
required_str = ", ".join(required_params) if required_params else "None"
|
||||
print(f"- {tool['name']}: {tool['description']} (Required params: {required_str})")
|
||||
|
||||
# Get projects
|
||||
print("\n3. Fetching projects...")
|
||||
projects_response = client.get_projects()
|
||||
|
||||
if projects_response.get("status") == "error":
|
||||
print(f"Error: {projects_response.get('error', {}).get('message', 'Unknown error')}")
|
||||
else:
|
||||
project_data = projects_response.get("data", [])
|
||||
print(f"Found {len(project_data)} projects:")
|
||||
for i, project in enumerate(project_data[:3], 1): # Show first 3 projects
|
||||
print(f"{i}. {project.get('name')} (ID: {project.get('id')})")
|
||||
|
||||
if project_data:
|
||||
# Use the first project for further examples
|
||||
project_id = project_data[0].get("id")
|
||||
|
||||
# Get campfires for the project
|
||||
print(f"\n4. Fetching campfires for project {project_id}...")
|
||||
campfires_response = client.get_campfire(project_id)
|
||||
|
||||
if campfires_response.get("status") == "error":
|
||||
print(f"Error: {campfires_response.get('error', {}).get('message', 'Unknown error')}")
|
||||
else:
|
||||
campfire_data = campfires_response.get("data", {}).get("campfire", [])
|
||||
print(f"Found {len(campfire_data)} campfires:")
|
||||
for i, campfire in enumerate(campfire_data[:2], 1): # Show first 2 campfires
|
||||
print(f"{i}. {campfire.get('title')} (ID: {campfire.get('id')})")
|
||||
|
||||
if campfire_data:
|
||||
# Get messages from the first campfire
|
||||
campfire_id = campfire_data[0].get("id")
|
||||
print(f"\n5. Fetching messages from campfire {campfire_id}...")
|
||||
messages_response = client.get_campfire_lines(project_id, campfire_id)
|
||||
|
||||
if messages_response.get("status") == "error":
|
||||
print(f"Error: {messages_response.get('error', {}).get('message', 'Unknown error')}")
|
||||
else:
|
||||
message_data = messages_response.get("data", {}).get("lines", [])
|
||||
print(f"Found {len(message_data)} messages:")
|
||||
for i, message in enumerate(message_data[:3], 1): # Show first 3 messages
|
||||
creator = message.get("creator", {}).get("name", "Unknown")
|
||||
content = message.get("title", "No content")
|
||||
print(f"{i}. From {creator}: {content[:50]}...")
|
||||
|
||||
print("\n==== Test completed ====")
|
||||
print("This example demonstrates how to connect to the Basecamp MCP server")
|
||||
print("and use it with the Composio MCP protocol.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,420 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from flask import Flask, request, jsonify
|
||||
from basecamp_client import BasecampClient
|
||||
from search_utils import BasecampSearch
|
||||
import token_storage
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def get_basecamp_client(auth_mode='oauth'):
|
||||
"""
|
||||
Returns a BasecampClient instance with appropriate authentication.
|
||||
|
||||
Args:
|
||||
auth_mode (str): The authentication mode to use ('oauth' or 'basic')
|
||||
|
||||
Returns:
|
||||
BasecampClient: A client for interacting with Basecamp
|
||||
"""
|
||||
if auth_mode == 'oauth':
|
||||
# Get the OAuth token
|
||||
token_data = token_storage.get_token()
|
||||
if not token_data or 'access_token' not in token_data:
|
||||
logger.error("No OAuth token available")
|
||||
return None
|
||||
|
||||
# Create a client using the OAuth token
|
||||
account_id = os.getenv('BASECAMP_ACCOUNT_ID')
|
||||
user_agent = os.getenv('USER_AGENT')
|
||||
client = BasecampClient(
|
||||
account_id=account_id,
|
||||
user_agent=user_agent,
|
||||
access_token=token_data['access_token'],
|
||||
auth_mode='oauth'
|
||||
)
|
||||
return client
|
||||
else:
|
||||
# Basic auth is not recommended but keeping for compatibility
|
||||
username = os.getenv('BASECAMP_USERNAME')
|
||||
password = os.getenv('BASECAMP_PASSWORD')
|
||||
account_id = os.getenv('BASECAMP_ACCOUNT_ID')
|
||||
user_agent = os.getenv('USER_AGENT')
|
||||
|
||||
client = BasecampClient(
|
||||
username=username,
|
||||
password=password,
|
||||
account_id=account_id,
|
||||
user_agent=user_agent,
|
||||
auth_mode='basic'
|
||||
)
|
||||
return client
|
||||
|
||||
def get_schema():
|
||||
"""
|
||||
Returns the schema for Basecamp tools compatible with Composio's MCP format.
|
||||
|
||||
Returns:
|
||||
dict: A schema describing available tools and their parameters according to Composio specs
|
||||
"""
|
||||
schema = {
|
||||
"name": "Basecamp MCP Server",
|
||||
"description": "Integration with Basecamp 3 for project management and team collaboration",
|
||||
"version": "1.0.0",
|
||||
"auth": {
|
||||
"type": "oauth2",
|
||||
"redirect_url": "http://localhost:8000",
|
||||
"token_url": "http://localhost:8000/token/info"
|
||||
},
|
||||
"contact": {
|
||||
"name": "Basecamp MCP Server Team",
|
||||
"url": "https://github.com/georgeantonopoulos/Basecamp-MCP-Server"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"name": "GET_PROJECTS",
|
||||
"description": "Get all projects from Basecamp",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET_PROJECT",
|
||||
"description": "Get details for a specific project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "The ID of the project"}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET_TODOLISTS",
|
||||
"description": "Get all todo lists for a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "The ID of the project"}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET_TODOS",
|
||||
"description": "Get all todos for a specific todolist",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todolist_id": {"type": "string", "description": "The ID of the todolist"}
|
||||
},
|
||||
"required": ["todolist_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET_CAMPFIRE",
|
||||
"description": "Get all chat rooms (campfires) for a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "The ID of the project"}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET_CAMPFIRE_LINES",
|
||||
"description": "Get messages from a specific chat room",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "The ID of the project"},
|
||||
"campfire_id": {"type": "string", "description": "The ID of the campfire/chat room"}
|
||||
},
|
||||
"required": ["project_id", "campfire_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "SEARCH",
|
||||
"description": "Search across Basecamp resources",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "The search query"},
|
||||
"project_id": {"type": "string", "description": "Optional project ID to limit search scope"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "GET_COMMENTS",
|
||||
"description": "Get comments for a specific Basecamp object",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recording_id": {"type": "string", "description": "The ID of the object to get comments for"},
|
||||
"bucket_id": {"type": "string", "description": "The bucket ID"}
|
||||
},
|
||||
"required": ["recording_id", "bucket_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CREATE_COMMENT",
|
||||
"description": "Create a new comment on a Basecamp object",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recording_id": {"type": "string", "description": "The ID of the object to comment on"},
|
||||
"bucket_id": {"type": "string", "description": "The bucket ID"},
|
||||
"content": {"type": "string", "description": "The comment content"}
|
||||
},
|
||||
"required": ["recording_id", "bucket_id", "content"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
return schema
|
||||
|
||||
def handle_composio_request(data):
|
||||
"""
|
||||
Handle a request from Composio following MCP standards.
|
||||
|
||||
Args:
|
||||
data (dict): The request data containing tool name and parameters
|
||||
|
||||
Returns:
|
||||
dict: The result of the tool execution in MCP-compliant format
|
||||
"""
|
||||
# Check if the API key is valid (if provided)
|
||||
composio_api_key = os.getenv('COMPOSIO_API_KEY')
|
||||
request_api_key = request.headers.get('X-Composio-API-Key')
|
||||
|
||||
if composio_api_key and request_api_key and composio_api_key != request_api_key:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "authentication_error",
|
||||
"message": "Invalid API key provided"
|
||||
}
|
||||
}
|
||||
|
||||
tool_name = data.get('tool')
|
||||
params = data.get('params', {})
|
||||
|
||||
# Get a Basecamp client
|
||||
client = get_basecamp_client(auth_mode='oauth')
|
||||
if not client:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "authentication_required",
|
||||
"message": "OAuth authentication required",
|
||||
"auth_url": "http://localhost:8000/"
|
||||
}
|
||||
}
|
||||
|
||||
# Route to the appropriate handler based on tool_name
|
||||
try:
|
||||
if tool_name == "GET_PROJECTS":
|
||||
result = client.get_projects()
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
|
||||
elif tool_name == "GET_PROJECT":
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "invalid_parameters",
|
||||
"message": "Missing required parameter: project_id"
|
||||
}
|
||||
}
|
||||
result = client.get_project(project_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
|
||||
elif tool_name == "GET_TODOLISTS":
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "invalid_parameters",
|
||||
"message": "Missing required parameter: project_id"
|
||||
}
|
||||
}
|
||||
result = client.get_todolists(project_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
|
||||
elif tool_name == "GET_TODOS":
|
||||
todolist_id = params.get('todolist_id')
|
||||
if not todolist_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "invalid_parameters",
|
||||
"message": "Missing required parameter: todolist_id"
|
||||
}
|
||||
}
|
||||
result = client.get_todos(todolist_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
|
||||
elif tool_name == "GET_CAMPFIRE":
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "invalid_parameters",
|
||||
"message": "Missing required parameter: project_id"
|
||||
}
|
||||
}
|
||||
result = client.get_campfires(project_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"campfire": result
|
||||
}
|
||||
}
|
||||
|
||||
elif tool_name == "GET_CAMPFIRE_LINES":
|
||||
project_id = params.get('project_id')
|
||||
campfire_id = params.get('campfire_id')
|
||||
if not project_id or not campfire_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "invalid_parameters",
|
||||
"message": "Missing required parameters: project_id and/or campfire_id"
|
||||
}
|
||||
}
|
||||
result = client.get_campfire_lines(project_id, campfire_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
|
||||
elif tool_name == "SEARCH":
|
||||
query = params.get('query')
|
||||
project_id = params.get('project_id')
|
||||
if not query:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "invalid_parameters",
|
||||
"message": "Missing required parameter: query"
|
||||
}
|
||||
}
|
||||
|
||||
search = BasecampSearch(client=client)
|
||||
results = []
|
||||
|
||||
# Search projects
|
||||
if not project_id:
|
||||
projects = search.search_projects(query)
|
||||
if projects:
|
||||
results.extend([{"type": "project", "data": p} for p in projects])
|
||||
|
||||
# If project_id is provided, search within that project
|
||||
if project_id:
|
||||
# Search todolists
|
||||
todolists = search.search_todolists(query, project_id)
|
||||
if todolists:
|
||||
results.extend([{"type": "todolist", "data": t} for t in todolists])
|
||||
|
||||
# Search todos
|
||||
todos = search.search_todos(query, project_id)
|
||||
if todos:
|
||||
results.extend([{"type": "todo", "data": t} for t in todos])
|
||||
|
||||
# Search campfire lines
|
||||
campfires = client.get_campfires(project_id)
|
||||
for campfire in campfires:
|
||||
campfire_id = campfire.get('id')
|
||||
lines = search.search_campfire_lines(query, project_id, campfire_id)
|
||||
if lines:
|
||||
results.extend([{"type": "campfire_line", "data": l} for l in lines])
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"results": results,
|
||||
"count": len(results)
|
||||
}
|
||||
}
|
||||
|
||||
elif tool_name == "GET_COMMENTS":
|
||||
recording_id = params.get('recording_id')
|
||||
bucket_id = params.get('bucket_id')
|
||||
if not recording_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "invalid_parameters",
|
||||
"message": "Missing required parameters: recording_id and/or bucket_id"
|
||||
}
|
||||
}
|
||||
result = client.get_comments(recording_id, bucket_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
|
||||
elif tool_name == "CREATE_COMMENT":
|
||||
recording_id = params.get('recording_id')
|
||||
bucket_id = params.get('bucket_id')
|
||||
content = params.get('content')
|
||||
if not recording_id or not bucket_id or not content:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "invalid_parameters",
|
||||
"message": "Missing required parameters"
|
||||
}
|
||||
}
|
||||
result = client.create_comment(recording_id, bucket_id, content)
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "unknown_tool",
|
||||
"message": f"Unknown tool: {tool_name}"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling tool {tool_name}: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": "server_error",
|
||||
"message": f"Error executing tool: {str(e)}"
|
||||
}
|
||||
}
|
||||
137
generate_cursor_config.py
Normal file
137
generate_cursor_config.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate the correct Cursor MCP configuration for this Basecamp MCP server.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
def get_project_root():
|
||||
"""Get the absolute path to the project root."""
|
||||
return str(Path(__file__).parent.absolute())
|
||||
|
||||
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
|
||||
|
||||
def generate_config():
|
||||
"""Generate the MCP configuration for Cursor."""
|
||||
project_root = get_project_root()
|
||||
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)
|
||||
basecamp_account_id = os.getenv("BASECAMP_ACCOUNT_ID")
|
||||
|
||||
env_vars = {
|
||||
"PYTHONPATH": project_root,
|
||||
"VIRTUAL_ENV": os.path.join(project_root, "venv")
|
||||
}
|
||||
if basecamp_account_id:
|
||||
env_vars["BASECAMP_ACCOUNT_ID"] = basecamp_account_id
|
||||
else:
|
||||
print("⚠️ WARNING: BASECAMP_ACCOUNT_ID not found in .env file. MCP server might not work correctly.")
|
||||
print(f" Attempted to load .env from: {dotenv_path}")
|
||||
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"basecamp": {
|
||||
"command": python_path,
|
||||
"args": [script_path],
|
||||
"cwd": project_root,
|
||||
"env": env_vars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return Path(os.environ.get("APPDATA", home)) / "Cursor" / "mcp.json"
|
||||
else: # Linux
|
||||
return home / ".cursor" / "mcp.json"
|
||||
|
||||
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")
|
||||
print("2. Restart Cursor completely (quit and reopen)")
|
||||
print("3. Check Cursor Settings → MCP for a green checkmark next to 'basecamp'")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,556 +0,0 @@
|
||||
"""
|
||||
Basecamp 2.4.2 MCP Integration Module
|
||||
|
||||
This module provides Multi-Cloud Provider (MCP) compatible functions for integrating
|
||||
with Basecamp 2.4.2 API. It can be used as a starting point for creating a full
|
||||
MCP connector.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from basecamp_client import BasecampClient
|
||||
from search_utils import BasecampSearch
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# MCP Authentication Functions
|
||||
|
||||
def get_required_parameters(params):
|
||||
"""
|
||||
Get the required parameters for connecting to Basecamp 2.4.2.
|
||||
|
||||
For Basic Authentication, we need:
|
||||
- username
|
||||
- password
|
||||
- account_id
|
||||
- user_agent
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of required parameters
|
||||
"""
|
||||
return {
|
||||
"required_parameters": [
|
||||
{
|
||||
"name": "username",
|
||||
"description": "Your Basecamp username (email)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"description": "Your Basecamp password",
|
||||
"type": "string",
|
||||
"sensitive": True
|
||||
},
|
||||
{
|
||||
"name": "account_id",
|
||||
"description": "Your Basecamp account ID (the number in your Basecamp URL)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "user_agent",
|
||||
"description": "User agent for API requests (e.g., 'YourApp (your-email@example.com)')",
|
||||
"type": "string",
|
||||
"default": f"MCP Basecamp Connector ({os.getenv('BASECAMP_USERNAME', 'your-email@example.com')})"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def initiate_connection(params):
|
||||
"""
|
||||
Initiate a connection to Basecamp 2.4.2.
|
||||
|
||||
Args:
|
||||
params (dict): Connection parameters including:
|
||||
- username: Basecamp username (email)
|
||||
- password: Basecamp password
|
||||
- account_id: Basecamp account ID
|
||||
- user_agent: User agent for API requests
|
||||
|
||||
Returns:
|
||||
dict: Connection details and status
|
||||
"""
|
||||
parameters = params.get("parameters", {})
|
||||
username = parameters.get("username")
|
||||
password = parameters.get("password")
|
||||
account_id = parameters.get("account_id")
|
||||
user_agent = parameters.get("user_agent")
|
||||
|
||||
try:
|
||||
client = BasecampClient(
|
||||
username=username,
|
||||
password=password,
|
||||
account_id=account_id,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
success, message = client.test_connection()
|
||||
|
||||
if success:
|
||||
return {
|
||||
"status": "connected",
|
||||
"connection_id": f"basecamp_{account_id}",
|
||||
"message": "Successfully connected to Basecamp"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": message
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
def check_active_connection(params):
|
||||
"""
|
||||
Check if a connection to Basecamp is active.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters containing:
|
||||
- connection_id: The connection ID to check
|
||||
|
||||
Returns:
|
||||
dict: Status of the connection
|
||||
"""
|
||||
# This is a placeholder. In a real implementation, you would check if the
|
||||
# connection is still valid, possibly by making a simple API call.
|
||||
return {
|
||||
"status": "active",
|
||||
"message": "Connection is active"
|
||||
}
|
||||
|
||||
# MCP Core Functions
|
||||
|
||||
def get_projects(params):
|
||||
"""
|
||||
Get all projects from Basecamp.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- query (optional): Filter projects by name
|
||||
|
||||
Returns:
|
||||
dict: List of projects
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the query parameter
|
||||
query = params.get("query")
|
||||
|
||||
# Search for projects
|
||||
projects = search.search_projects(query)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(projects),
|
||||
"projects": projects
|
||||
}
|
||||
|
||||
def get_todo_lists(params):
|
||||
"""
|
||||
Get all to-do lists from a project.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- project_id: The project ID
|
||||
- query (optional): Filter to-do lists by name
|
||||
|
||||
Returns:
|
||||
dict: List of to-do lists
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the parameters
|
||||
project_id = params.get("project_id")
|
||||
query = params.get("query")
|
||||
|
||||
# Validate required parameters
|
||||
if not project_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "project_id is required"
|
||||
}
|
||||
|
||||
# Search for to-do lists
|
||||
todolists = search.search_todolists(query, project_id)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(todolists),
|
||||
"todolists": todolists
|
||||
}
|
||||
|
||||
def get_todos(params):
|
||||
"""
|
||||
Get all to-dos with various filters.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- project_id (optional): Filter by project ID
|
||||
- todolist_id (optional): Filter by to-do list ID
|
||||
- query (optional): Filter to-dos by content
|
||||
- include_completed (optional): Include completed to-dos
|
||||
|
||||
Returns:
|
||||
dict: List of to-dos
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the parameters
|
||||
project_id = params.get("project_id")
|
||||
todolist_id = params.get("todolist_id")
|
||||
query = params.get("query")
|
||||
include_completed = params.get("include_completed", False)
|
||||
|
||||
# Search for to-dos
|
||||
todos = search.search_todos(
|
||||
query=query,
|
||||
project_id=project_id,
|
||||
todolist_id=todolist_id,
|
||||
include_completed=include_completed
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(todos),
|
||||
"todos": todos
|
||||
}
|
||||
|
||||
def get_comments(params):
|
||||
"""
|
||||
Get comments for a specific recording (todo, message, etc.).
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- recording_id (required): ID of the recording (todo, message, etc.)
|
||||
- bucket_id (required): Project/bucket ID
|
||||
- query (optional): Filter comments by content
|
||||
|
||||
Returns:
|
||||
dict: List of comments
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the parameters
|
||||
recording_id = params.get("recording_id")
|
||||
bucket_id = params.get("bucket_id")
|
||||
query = params.get("query")
|
||||
|
||||
# Validate required parameters
|
||||
if not recording_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "recording_id and bucket_id are required"
|
||||
}
|
||||
|
||||
# Search for comments
|
||||
comments = search.search_comments(
|
||||
query=query,
|
||||
recording_id=recording_id,
|
||||
bucket_id=bucket_id
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(comments),
|
||||
"comments": comments
|
||||
}
|
||||
|
||||
def create_comment(params):
|
||||
"""
|
||||
Create a comment on a recording.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- recording_id (required): ID of the recording to comment on
|
||||
- bucket_id (required): Project/bucket ID
|
||||
- content (required): Content of the comment in HTML format
|
||||
|
||||
Returns:
|
||||
dict: The created comment
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Get the parameters
|
||||
recording_id = params.get("recording_id")
|
||||
bucket_id = params.get("bucket_id")
|
||||
content = params.get("content")
|
||||
|
||||
# Validate required parameters
|
||||
if not recording_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "recording_id and bucket_id are required"
|
||||
}
|
||||
|
||||
if not content:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "content is required"
|
||||
}
|
||||
|
||||
try:
|
||||
# Create the comment
|
||||
comment = client.create_comment(recording_id, bucket_id, content)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"comment": comment
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
def update_comment(params):
|
||||
"""
|
||||
Update a comment.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- comment_id (required): Comment ID
|
||||
- bucket_id (required): Project/bucket ID
|
||||
- content (required): New content for the comment in HTML format
|
||||
|
||||
Returns:
|
||||
dict: The updated comment
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Get the parameters
|
||||
comment_id = params.get("comment_id")
|
||||
bucket_id = params.get("bucket_id")
|
||||
content = params.get("content")
|
||||
|
||||
# Validate required parameters
|
||||
if not comment_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "comment_id and bucket_id are required"
|
||||
}
|
||||
|
||||
if not content:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "content is required"
|
||||
}
|
||||
|
||||
try:
|
||||
# Update the comment
|
||||
comment = client.update_comment(comment_id, bucket_id, content)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"comment": comment
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
def delete_comment(params):
|
||||
"""
|
||||
Delete a comment.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- comment_id (required): Comment ID
|
||||
- bucket_id (required): Project/bucket ID
|
||||
|
||||
Returns:
|
||||
dict: Status of the operation
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Get the parameters
|
||||
comment_id = params.get("comment_id")
|
||||
bucket_id = params.get("bucket_id")
|
||||
|
||||
# Validate required parameters
|
||||
if not comment_id or not bucket_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "comment_id and bucket_id are required"
|
||||
}
|
||||
|
||||
try:
|
||||
# Delete the comment
|
||||
success = client.delete_comment(comment_id, bucket_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Comment deleted successfully"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Failed to delete comment"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
def search_all(params):
|
||||
"""
|
||||
Search across all Basecamp resources.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- query: The search query
|
||||
- resource_types (optional): Types of resources to search (projects, todolists, todos)
|
||||
- include_completed (optional): Include completed to-dos
|
||||
|
||||
Returns:
|
||||
dict: Search results grouped by resource type
|
||||
"""
|
||||
# Get the client
|
||||
client = _get_client_from_params(params)
|
||||
|
||||
# Set up the search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Get the parameters
|
||||
query = params.get("query")
|
||||
resource_types = params.get("resource_types", ["projects", "todolists", "todos"])
|
||||
include_completed = params.get("include_completed", False)
|
||||
|
||||
# Validate required parameters
|
||||
if not query:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "query is required"
|
||||
}
|
||||
|
||||
# Initialize results
|
||||
results = {
|
||||
"status": "success",
|
||||
"query": query,
|
||||
"results": {}
|
||||
}
|
||||
|
||||
# Search based on resource types
|
||||
if "projects" in resource_types:
|
||||
projects = search.search_projects(query)
|
||||
results["results"]["projects"] = {
|
||||
"count": len(projects),
|
||||
"items": projects
|
||||
}
|
||||
|
||||
if "todolists" in resource_types:
|
||||
todolists = search.search_todolists(query)
|
||||
results["results"]["todolists"] = {
|
||||
"count": len(todolists),
|
||||
"items": todolists
|
||||
}
|
||||
|
||||
if "todos" in resource_types:
|
||||
todos = search.search_todos(query=query, include_completed=include_completed)
|
||||
results["results"]["todos"] = {
|
||||
"count": len(todos),
|
||||
"items": todos
|
||||
}
|
||||
|
||||
# Calculate total count
|
||||
total_count = sum(
|
||||
results["results"][resource]["count"]
|
||||
for resource in results["results"]
|
||||
)
|
||||
results["total_count"] = total_count
|
||||
|
||||
return results
|
||||
|
||||
# Helper functions
|
||||
|
||||
def _get_client_from_params(params):
|
||||
"""
|
||||
Get a BasecampClient instance from the given parameters.
|
||||
|
||||
Args:
|
||||
params (dict): Parameters including:
|
||||
- connection_id (optional): Connection ID for the client
|
||||
- oauth_mode (optional): Whether to use OAuth for authentication
|
||||
- access_token (optional): OAuth access token
|
||||
- username (optional): Basic Auth username
|
||||
- password (optional): Basic Auth password
|
||||
- account_id (optional): Account ID
|
||||
- user_agent (optional): User agent for API requests
|
||||
|
||||
Returns:
|
||||
BasecampClient: A configured client
|
||||
"""
|
||||
# Mock connection for testing - return a fake client
|
||||
if params.get("connection_id") and params.get("connection_id").startswith("mock_"):
|
||||
print(f"Using mock client for connection ID: {params.get('connection_id')}")
|
||||
from unittest.mock import MagicMock
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Set up mock responses for known methods
|
||||
mock_client.get_projects.return_value = [{"id": 123, "name": "Mock Project"}]
|
||||
mock_client.get_comments.return_value = [{"id": 456, "content": "Mock comment"}]
|
||||
mock_client.create_comment.return_value = {"id": 789, "content": "New mock comment"}
|
||||
mock_client.update_comment.return_value = {"id": 789, "content": "Updated mock comment"}
|
||||
mock_client.delete_comment.return_value = True
|
||||
|
||||
return mock_client
|
||||
|
||||
# Check if OAuth mode is specified
|
||||
oauth_mode = params.get("oauth_mode", False)
|
||||
|
||||
if oauth_mode:
|
||||
# OAuth authentication
|
||||
access_token = params.get("access_token") or os.getenv("BASECAMP_ACCESS_TOKEN")
|
||||
account_id = params.get("account_id") or os.getenv("BASECAMP_ACCOUNT_ID")
|
||||
user_agent = params.get("user_agent") or os.getenv("USER_AGENT")
|
||||
|
||||
if not all([access_token, account_id, user_agent]):
|
||||
raise ValueError("Missing required OAuth credentials. Please provide access_token, account_id, and user_agent.")
|
||||
|
||||
return BasecampClient(
|
||||
access_token=access_token,
|
||||
account_id=account_id,
|
||||
user_agent=user_agent,
|
||||
auth_mode="oauth"
|
||||
)
|
||||
else:
|
||||
# Basic authentication
|
||||
username = params.get("username") or os.getenv("BASECAMP_USERNAME")
|
||||
password = params.get("password") or os.getenv("BASECAMP_PASSWORD")
|
||||
account_id = params.get("account_id") or os.getenv("BASECAMP_ACCOUNT_ID")
|
||||
user_agent = params.get("user_agent") or os.getenv("USER_AGENT")
|
||||
|
||||
if not all([username, password, account_id, user_agent]):
|
||||
raise ValueError("Missing required Basic Auth credentials. Please provide username, password, account_id, and user_agent.")
|
||||
|
||||
return BasecampClient(
|
||||
username=username,
|
||||
password=password,
|
||||
account_id=account_id,
|
||||
user_agent=user_agent,
|
||||
auth_mode="basic"
|
||||
)
|
||||
834
mcp_server.py
834
mcp_server.py
@@ -1,834 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from flask import Flask, request, jsonify
|
||||
from dotenv import load_dotenv
|
||||
from threading import Thread
|
||||
import time
|
||||
from basecamp_client import BasecampClient
|
||||
from search_utils import BasecampSearch
|
||||
import token_storage # Import the token storage module
|
||||
import requests # For token refresh
|
||||
from flask_cors import CORS
|
||||
|
||||
# Import MCP integration components, using try/except to catch any import errors
|
||||
try:
|
||||
from mcp_integration import (
|
||||
get_required_parameters,
|
||||
initiate_connection,
|
||||
check_active_connection,
|
||||
get_projects,
|
||||
get_todo_lists,
|
||||
get_todos,
|
||||
get_comments,
|
||||
create_comment,
|
||||
update_comment,
|
||||
delete_comment,
|
||||
search_all
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error importing MCP integration: {str(e)}")
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
# Import Composio integration components
|
||||
try:
|
||||
from composio_integration import get_schema, handle_composio_request
|
||||
except Exception as e:
|
||||
print(f"Error importing Composio integration: {str(e)}")
|
||||
traceback.print_exc()
|
||||
# Don't exit since Composio integration is optional
|
||||
|
||||
# Helper function for consistent response format
|
||||
def mcp_response(data=None, status="success", error=None, message=None, status_code=200):
|
||||
"""
|
||||
Generate a standardized MCP response.
|
||||
|
||||
Args:
|
||||
data: The response data
|
||||
status: 'success' or 'error'
|
||||
error: Error code in case of an error
|
||||
message: Human-readable message
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
tuple: JSON response and HTTP status code
|
||||
"""
|
||||
response = {
|
||||
"status": status
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
response.update(data)
|
||||
|
||||
if status == "error":
|
||||
response["error"] = error
|
||||
response["message"] = message
|
||||
|
||||
return jsonify(response), status_code
|
||||
|
||||
# Configure logging with more verbose output
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler('improved_mcp_server.log')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger('mcp_server')
|
||||
|
||||
# Load environment variables from .env file
|
||||
try:
|
||||
load_dotenv()
|
||||
logger.info("Environment variables loaded")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading environment variables: {str(e)}")
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
# MCP Server configuration
|
||||
MCP_PORT = int(os.environ.get('MCP_PORT', 5001))
|
||||
BASECAMP_ACCOUNT_ID = os.environ.get('BASECAMP_ACCOUNT_ID')
|
||||
USER_AGENT = os.environ.get('USER_AGENT')
|
||||
CLIENT_ID = os.environ.get('BASECAMP_CLIENT_ID')
|
||||
CLIENT_SECRET = os.environ.get('BASECAMP_CLIENT_SECRET')
|
||||
REDIRECT_URI = os.environ.get('BASECAMP_REDIRECT_URI')
|
||||
|
||||
# Token endpoints
|
||||
TOKEN_URL = "https://launchpad.37signals.com/authorization/token"
|
||||
|
||||
# Keep track of existing connections
|
||||
active_connections = {}
|
||||
|
||||
def refresh_oauth_token():
|
||||
"""
|
||||
Refresh the OAuth token if it's expired.
|
||||
|
||||
Returns:
|
||||
str: The new access token if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current token data
|
||||
token_data = token_storage.get_token()
|
||||
if not token_data or not token_data.get('refresh_token'):
|
||||
logger.error("No refresh token available")
|
||||
return None
|
||||
|
||||
refresh_token = token_data['refresh_token']
|
||||
|
||||
# Prepare the refresh request
|
||||
data = {
|
||||
'type': 'refresh',
|
||||
'client_id': CLIENT_ID,
|
||||
'client_secret': CLIENT_SECRET,
|
||||
'refresh_token': refresh_token
|
||||
}
|
||||
|
||||
headers = {
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
|
||||
logger.info("Refreshing OAuth token")
|
||||
response = requests.post(TOKEN_URL, data=data, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
new_token_data = response.json()
|
||||
logger.info("Token refresh successful")
|
||||
|
||||
# Store the new token
|
||||
access_token = new_token_data.get('access_token')
|
||||
new_refresh_token = new_token_data.get('refresh_token') or refresh_token # Use old refresh if not provided
|
||||
expires_in = new_token_data.get('expires_in')
|
||||
|
||||
token_storage.store_token(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
expires_in=expires_in,
|
||||
account_id=BASECAMP_ACCOUNT_ID
|
||||
)
|
||||
|
||||
return access_token
|
||||
else:
|
||||
logger.error(f"Failed to refresh token: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing token: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_basecamp_client(auth_mode='oauth'):
|
||||
"""
|
||||
Get a Basecamp client with the appropriate authentication.
|
||||
|
||||
Args:
|
||||
auth_mode (str): Authentication mode, either 'oauth' or 'pat' (Personal Access Token)
|
||||
|
||||
Returns:
|
||||
BasecampClient: A configured client
|
||||
"""
|
||||
logger.info(f"Getting Basecamp client with auth_mode: {auth_mode}")
|
||||
|
||||
if auth_mode == 'oauth':
|
||||
# Get the token from storage
|
||||
token_data = token_storage.get_token()
|
||||
|
||||
# If no token or token is expired, try to refresh
|
||||
if not token_data or not token_data.get('access_token') or token_storage.is_token_expired():
|
||||
logger.info("Token missing or expired, attempting to refresh")
|
||||
access_token = refresh_oauth_token()
|
||||
if not access_token:
|
||||
logger.error("No OAuth token available after refresh attempt")
|
||||
raise ValueError("No OAuth token available. Please authenticate first.")
|
||||
else:
|
||||
access_token = token_data['access_token']
|
||||
|
||||
account_id = token_data.get('account_id') or BASECAMP_ACCOUNT_ID
|
||||
|
||||
if not account_id:
|
||||
logger.error("No account ID available")
|
||||
raise ValueError("No Basecamp account ID available. Please set BASECAMP_ACCOUNT_ID.")
|
||||
|
||||
logger.info(f"Using OAuth token (starts with {access_token[:5]}...) for account {account_id}")
|
||||
|
||||
return BasecampClient(
|
||||
access_token=access_token,
|
||||
account_id=account_id,
|
||||
user_agent=USER_AGENT,
|
||||
auth_mode='oauth'
|
||||
)
|
||||
elif auth_mode == 'pat':
|
||||
# Use Personal Access Token
|
||||
username = os.environ.get('BASECAMP_USERNAME')
|
||||
token = os.environ.get('BASECAMP_TOKEN')
|
||||
account_id = BASECAMP_ACCOUNT_ID
|
||||
|
||||
if not username or not token or not account_id:
|
||||
logger.error("Missing credentials for PAT authentication")
|
||||
raise ValueError("Missing credentials for PAT authentication")
|
||||
|
||||
logger.info(f"Using PAT authentication for user {username} and account {account_id}")
|
||||
|
||||
return BasecampClient(
|
||||
username=username,
|
||||
token=token,
|
||||
account_id=account_id,
|
||||
user_agent=USER_AGENT,
|
||||
auth_mode='pat'
|
||||
)
|
||||
else:
|
||||
logger.error(f"Invalid auth mode: {auth_mode}")
|
||||
raise ValueError(f"Invalid auth mode: {auth_mode}")
|
||||
|
||||
# Basic health check endpoint for testing server responsiveness
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Simple health check endpoint that returns a static response."""
|
||||
logger.debug("Health check endpoint called")
|
||||
return jsonify({"status": "ok", "message": "MCP server is running"}), 200
|
||||
|
||||
# Enable CORS for all routes
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
response.headers.add('Access-Control-Allow-Origin', '*')
|
||||
response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
|
||||
logger.debug(f"CORS headers added to response: {response}")
|
||||
return response
|
||||
|
||||
# Add OPTIONS method handler for CORS preflight requests
|
||||
@app.route('/', defaults={'path': ''}, methods=['OPTIONS'])
|
||||
@app.route('/<path:path>', methods=['OPTIONS'])
|
||||
def handle_options(path):
|
||||
"""Handle OPTIONS preflight requests for CORS."""
|
||||
logger.debug(f"OPTIONS request for path: {path}")
|
||||
return '', 200
|
||||
|
||||
# MCP Info endpoint
|
||||
@app.route('/mcp/info', methods=['GET'])
|
||||
def mcp_info():
|
||||
"""
|
||||
Provides information about this MCP server.
|
||||
"""
|
||||
server_info = {
|
||||
"name": "Basecamp MCP Server",
|
||||
"version": "1.0.0",
|
||||
"description": "A MCP server for Basecamp 3",
|
||||
"auth_modes": ["oauth"],
|
||||
"actions": [
|
||||
"get_projects",
|
||||
"get_project",
|
||||
"get_todosets",
|
||||
"get_todolists",
|
||||
"get_todolist",
|
||||
"get_todos",
|
||||
"get_todo",
|
||||
"get_people",
|
||||
"get_campfire",
|
||||
"get_campfire_lines",
|
||||
"get_message_board",
|
||||
"get_messages",
|
||||
"get_schedule",
|
||||
"get_schedule_entries",
|
||||
"get_comments",
|
||||
"create_comment",
|
||||
"update_comment",
|
||||
"delete_comment",
|
||||
"search"
|
||||
],
|
||||
"composio_compatible": True # Add this flag to indicate Composio compatibility
|
||||
}
|
||||
return jsonify(server_info)
|
||||
|
||||
# MCP Action endpoint with improved error handling
|
||||
@app.route('/mcp/action', methods=['POST'])
|
||||
def mcp_action():
|
||||
"""
|
||||
Handle direct MCP actions without connection management.
|
||||
This is a simpler interface for testing and direct integration.
|
||||
|
||||
Note: The connection-based approach using /initiate_connection and /tool/<connection_id>
|
||||
is preferred as it provides better error handling and state management.
|
||||
"""
|
||||
logger.info("MCP action endpoint called")
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
action = data.get('action')
|
||||
params = data.get('params', {})
|
||||
|
||||
logger.info(f"Action requested: {action}")
|
||||
|
||||
# First check if we have a valid OAuth token
|
||||
token_data = token_storage.get_token()
|
||||
if not token_data or not token_data.get('access_token'):
|
||||
logger.error("No OAuth token available for action")
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "authentication_required",
|
||||
"message": "OAuth authentication required. Please authenticate using the OAuth app first.",
|
||||
"oauth_url": "http://localhost:8000/"
|
||||
})
|
||||
|
||||
# Check if token is expired
|
||||
if token_storage.is_token_expired():
|
||||
logger.info("Token expired, attempting to refresh")
|
||||
new_token = refresh_oauth_token()
|
||||
if not new_token:
|
||||
logger.error("Failed to refresh token")
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "token_expired",
|
||||
"message": "OAuth token has expired and could not be refreshed. Please authenticate again.",
|
||||
"oauth_url": "http://localhost:8000/"
|
||||
})
|
||||
|
||||
# Create a Basecamp client
|
||||
client = get_basecamp_client(auth_mode='oauth')
|
||||
|
||||
# Handle actions
|
||||
if action == 'get_projects':
|
||||
projects = client.get_projects()
|
||||
return mcp_response({
|
||||
"projects": projects,
|
||||
"count": len(projects)
|
||||
})
|
||||
|
||||
elif action == 'get_project':
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return mcp_response(
|
||||
status="error",
|
||||
error="missing_parameter",
|
||||
message="Missing project_id parameter",
|
||||
status_code=400
|
||||
)
|
||||
|
||||
project = client.get_project(project_id)
|
||||
return mcp_response({
|
||||
"project": project
|
||||
})
|
||||
|
||||
elif action == 'get_todolists':
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return mcp_response(
|
||||
status="error",
|
||||
error="missing_parameter",
|
||||
message="Missing project_id parameter",
|
||||
status_code=400
|
||||
)
|
||||
|
||||
todolists = client.get_todolists(project_id)
|
||||
return mcp_response({
|
||||
"todolists": todolists,
|
||||
"count": len(todolists)
|
||||
})
|
||||
|
||||
elif action == 'get_todos':
|
||||
todolist_id = params.get('todolist_id')
|
||||
if not todolist_id:
|
||||
return jsonify({"status": "error", "error": "missing_parameter", "message": "Missing todolist_id parameter"}), 400
|
||||
|
||||
todos = client.get_todos(todolist_id)
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"todos": todos,
|
||||
"count": len(todos)
|
||||
})
|
||||
|
||||
elif action == 'create_todo':
|
||||
todolist_id = params.get('todolist_id')
|
||||
content = params.get('content')
|
||||
|
||||
if not todolist_id or not content:
|
||||
return jsonify({"status": "error", "error": "missing_parameter", "message": "Missing todolist_id or content parameter"}), 400
|
||||
|
||||
todo = client.create_todo(
|
||||
todolist_id=todolist_id,
|
||||
content=content,
|
||||
description=params.get('description', ''),
|
||||
assignee_ids=params.get('assignee_ids', [])
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"todo": todo
|
||||
})
|
||||
|
||||
elif action == 'complete_todo':
|
||||
todo_id = params.get('todo_id')
|
||||
if not todo_id:
|
||||
return jsonify({"status": "error", "error": "missing_parameter", "message": "Missing todo_id parameter"}), 400
|
||||
|
||||
result = client.complete_todo(todo_id)
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"result": result
|
||||
})
|
||||
|
||||
elif action == 'get_comments':
|
||||
recording_id = params.get('recording_id')
|
||||
bucket_id = params.get('bucket_id')
|
||||
|
||||
if not recording_id or not bucket_id:
|
||||
return jsonify({"status": "error", "error": "missing_parameter", "message": "Missing recording_id or bucket_id parameter"}), 400
|
||||
|
||||
comments = client.get_comments(recording_id, bucket_id)
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"comments": comments,
|
||||
"count": len(comments)
|
||||
})
|
||||
|
||||
elif action == 'get_campfire':
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return jsonify({"status": "error", "error": "missing_parameter", "message": "Missing project_id parameter"}), 400
|
||||
|
||||
campfire = client.get_campfires(project_id)
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"campfire": campfire
|
||||
})
|
||||
|
||||
elif action == 'get_campfire_lines':
|
||||
project_id = params.get('project_id')
|
||||
campfire_id = params.get('campfire_id')
|
||||
|
||||
if not project_id or not campfire_id:
|
||||
return jsonify({"status": "error", "error": "missing_parameter", "message": "Missing project_id or campfire_id parameter"}), 400
|
||||
|
||||
try:
|
||||
lines = client.get_campfire_lines(project_id, campfire_id)
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"lines": lines,
|
||||
"count": len(lines)
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "api_error",
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
elif action == 'search':
|
||||
search = BasecampSearch(client=client)
|
||||
query = params.get('query', '')
|
||||
include_completed = params.get('include_completed', False)
|
||||
|
||||
logger.info(f"Searching with query: {query}")
|
||||
|
||||
results = {
|
||||
"projects": search.search_projects(query),
|
||||
"todos": search.search_todos(query, include_completed=include_completed),
|
||||
"messages": search.search_messages(query),
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"results": results
|
||||
})
|
||||
|
||||
else:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "unknown_action",
|
||||
"message": f"Unknown action: {action}"
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in mcp_action: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error": "server_error",
|
||||
"message": str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
"""Home page for the MCP server."""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "basecamp-mcp-server",
|
||||
"description": "MCP server for Basecamp 3 integration"
|
||||
})
|
||||
|
||||
@app.route('/check_required_parameters', methods=['POST'])
|
||||
def check_required_parameters():
|
||||
"""
|
||||
Check the required parameters for connecting to Basecamp.
|
||||
"""
|
||||
logger.info("Checking required parameters for Basecamp")
|
||||
|
||||
try:
|
||||
# For OAuth mode
|
||||
if token_storage.get_token():
|
||||
return jsonify({
|
||||
"parameters": [] # No parameters needed if we have a token
|
||||
})
|
||||
|
||||
# Otherwise, we need OAuth credentials
|
||||
return jsonify({
|
||||
"parameters": [
|
||||
{
|
||||
"name": "auth_mode",
|
||||
"description": "Authentication mode (oauth or pat)",
|
||||
"required": True
|
||||
}
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking required parameters: {str(e)}")
|
||||
return jsonify({
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/initiate_connection', methods=['POST'])
|
||||
def initiate_connection():
|
||||
"""
|
||||
Initiate a connection to Basecamp.
|
||||
"""
|
||||
data = request.json
|
||||
auth_mode = data.get('auth_mode', 'oauth')
|
||||
|
||||
logger.info(f"Initiating connection with auth_mode: {auth_mode}")
|
||||
|
||||
try:
|
||||
# Check if we have credentials for the requested auth mode
|
||||
if auth_mode == 'oauth':
|
||||
# Check if we have a valid token
|
||||
token_data = token_storage.get_token()
|
||||
|
||||
# If token missing or expired, but we have a refresh token, try refreshing
|
||||
if (not token_data or not token_data.get('access_token') or token_storage.is_token_expired()) and token_data and token_data.get('refresh_token'):
|
||||
logger.info("Token missing or expired, attempting to refresh")
|
||||
access_token = refresh_oauth_token()
|
||||
if access_token:
|
||||
logger.info("Token refreshed successfully")
|
||||
token_data = token_storage.get_token() # Get the updated token data
|
||||
|
||||
# After potential refresh, check if we have a valid token
|
||||
if not token_data or not token_data.get('access_token'):
|
||||
logger.error("No OAuth token available")
|
||||
return jsonify({
|
||||
"error": "No OAuth token available. Please authenticate using the OAuth app first.",
|
||||
"oauth_url": "http://localhost:8000/"
|
||||
}), 401
|
||||
|
||||
# Create a connection ID
|
||||
connection_id = f"basecamp-oauth-{int(time.time())}"
|
||||
active_connections[connection_id] = {
|
||||
"auth_mode": "oauth",
|
||||
"created_at": time.time()
|
||||
}
|
||||
|
||||
logger.info(f"Created connection {connection_id} with OAuth")
|
||||
|
||||
return jsonify({
|
||||
"connection_id": connection_id,
|
||||
"status": "connected",
|
||||
"auth_mode": "oauth"
|
||||
})
|
||||
|
||||
elif auth_mode == 'pat':
|
||||
# Check if we have PAT credentials
|
||||
username = os.environ.get('BASECAMP_USERNAME')
|
||||
token = os.environ.get('BASECAMP_TOKEN')
|
||||
|
||||
if not username or not token:
|
||||
logger.error("Missing PAT credentials")
|
||||
return jsonify({
|
||||
"error": "Missing Personal Access Token credentials. Please set BASECAMP_USERNAME and BASECAMP_TOKEN."
|
||||
}), 401
|
||||
|
||||
# Create a connection ID
|
||||
connection_id = f"basecamp-pat-{int(time.time())}"
|
||||
active_connections[connection_id] = {
|
||||
"auth_mode": "pat",
|
||||
"created_at": time.time()
|
||||
}
|
||||
|
||||
logger.info(f"Created connection {connection_id} with PAT")
|
||||
|
||||
return jsonify({
|
||||
"connection_id": connection_id,
|
||||
"status": "connected",
|
||||
"auth_mode": "pat"
|
||||
})
|
||||
|
||||
else:
|
||||
logger.error(f"Invalid auth mode: {auth_mode}")
|
||||
return jsonify({
|
||||
"error": f"Invalid auth mode: {auth_mode}"
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initiating connection: {str(e)}")
|
||||
return jsonify({
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/check_active_connection', methods=['POST'])
|
||||
def check_active_connection():
|
||||
"""
|
||||
Check if a connection is active.
|
||||
"""
|
||||
data = request.json
|
||||
connection_id = data.get('connection_id')
|
||||
|
||||
logger.info(f"Checking active connection: {connection_id}")
|
||||
|
||||
if connection_id in active_connections:
|
||||
return jsonify({
|
||||
"connection_id": connection_id,
|
||||
"status": "active"
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"connection_id": connection_id,
|
||||
"status": "inactive"
|
||||
})
|
||||
|
||||
@app.route('/tool/<connection_id>', methods=['POST'])
|
||||
def tool(connection_id):
|
||||
"""
|
||||
Handle tool calls from the MCP client.
|
||||
"""
|
||||
data = request.json
|
||||
action = data.get('action')
|
||||
params = data.get('params', {})
|
||||
|
||||
logger.info(f"Tool call: {connection_id} - {action} - {params}")
|
||||
|
||||
# Check if the connection is active
|
||||
if connection_id not in active_connections:
|
||||
logger.error(f"Invalid connection ID: {connection_id}")
|
||||
return jsonify({
|
||||
"error": "Invalid connection ID"
|
||||
}), 401
|
||||
|
||||
# Get the auth mode for this connection
|
||||
auth_mode = active_connections[connection_id].get('auth_mode', 'oauth')
|
||||
|
||||
try:
|
||||
# Create a Basecamp client
|
||||
client = get_basecamp_client(auth_mode=auth_mode)
|
||||
|
||||
# Handle different actions
|
||||
if action == 'get_projects':
|
||||
projects = client.get_projects()
|
||||
return jsonify({
|
||||
"projects": projects
|
||||
})
|
||||
|
||||
elif action == 'get_project':
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return jsonify({"error": "Missing project_id parameter"}), 400
|
||||
|
||||
project = client.get_project(project_id)
|
||||
return jsonify({
|
||||
"project": project
|
||||
})
|
||||
|
||||
elif action == 'get_todolists':
|
||||
project_id = params.get('project_id')
|
||||
if not project_id:
|
||||
return jsonify({"error": "Missing project_id parameter"}), 400
|
||||
|
||||
todolists = client.get_todolists(project_id)
|
||||
return jsonify({
|
||||
"todolists": todolists
|
||||
})
|
||||
|
||||
elif action == 'get_todos':
|
||||
todolist_id = params.get('todolist_id')
|
||||
if not todolist_id:
|
||||
return jsonify({"error": "Missing todolist_id parameter"}), 400
|
||||
|
||||
todos = client.get_todos(todolist_id)
|
||||
return jsonify({
|
||||
"todos": todos
|
||||
})
|
||||
|
||||
elif action == 'create_todo':
|
||||
todolist_id = params.get('todolist_id')
|
||||
content = params.get('content')
|
||||
|
||||
if not todolist_id or not content:
|
||||
return jsonify({"error": "Missing todolist_id or content parameter"}), 400
|
||||
|
||||
todo = client.create_todo(
|
||||
todolist_id=todolist_id,
|
||||
content=content,
|
||||
description=params.get('description', ''),
|
||||
assignee_ids=params.get('assignee_ids', [])
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"todo": todo
|
||||
})
|
||||
|
||||
elif action == 'complete_todo':
|
||||
todo_id = params.get('todo_id')
|
||||
if not todo_id:
|
||||
return jsonify({"error": "Missing todo_id parameter"}), 400
|
||||
|
||||
result = client.complete_todo(todo_id)
|
||||
return jsonify({
|
||||
"result": result
|
||||
})
|
||||
|
||||
elif action == 'search':
|
||||
query = params.get('query')
|
||||
if not query:
|
||||
return jsonify({"error": "Missing query parameter"}), 400
|
||||
|
||||
# Create search utility
|
||||
search = BasecampSearch(client=client)
|
||||
|
||||
# Determine what to search
|
||||
types = params.get('types', ['projects', 'todos', 'messages'])
|
||||
include_completed = params.get('include_completed', False)
|
||||
|
||||
results = {}
|
||||
|
||||
if 'projects' in types:
|
||||
results['projects'] = search.search_projects(query)
|
||||
|
||||
if 'todos' in types:
|
||||
results['todos'] = search.search_todos(query, include_completed=include_completed)
|
||||
|
||||
if 'messages' in types:
|
||||
results['messages'] = search.search_messages(query)
|
||||
|
||||
return jsonify({
|
||||
"results": results
|
||||
})
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown action: {action}")
|
||||
return jsonify({
|
||||
"error": f"Unknown action: {action}"
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling tool call: {str(e)}")
|
||||
return jsonify({
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
# Add Composio-specific routes
|
||||
@app.route('/composio/schema', methods=['GET'])
|
||||
def composio_schema():
|
||||
"""
|
||||
Returns the schema for Basecamp tools compatible with Composio.
|
||||
"""
|
||||
try:
|
||||
schema = get_schema()
|
||||
return jsonify(schema)
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating Composio schema: {str(e)}")
|
||||
return jsonify({"error": "server_error", "message": f"Error generating schema: {str(e)}"}), 500
|
||||
|
||||
@app.route('/composio/tool', methods=['POST'])
|
||||
def composio_tool():
|
||||
"""
|
||||
Handles tool calls from Composio.
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({"error": "invalid_request", "message": "Invalid request data"}), 400
|
||||
|
||||
result = handle_composio_request(data)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling Composio tool call: {str(e)}")
|
||||
return jsonify({"error": "server_error", "message": f"Error handling tool call: {str(e)}"}), 500
|
||||
|
||||
@app.route('/composio/check_auth', methods=['GET'])
|
||||
def composio_check_auth():
|
||||
"""
|
||||
Checks if OAuth authentication is available for Composio.
|
||||
"""
|
||||
try:
|
||||
token_data = token_storage.get_token()
|
||||
if token_data and 'access_token' in token_data:
|
||||
return jsonify({"authenticated": True})
|
||||
else:
|
||||
return jsonify({
|
||||
"authenticated": False,
|
||||
"auth_url": f"http://localhost:8000/",
|
||||
"message": "OAuth authentication required. Please visit the auth URL to authenticate."
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Composio auth: {str(e)}")
|
||||
return jsonify({"error": "server_error", "message": f"Error checking auth: {str(e)}"}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
logger.info(f"Starting MCP server on port {MCP_PORT}")
|
||||
logger.info("Press Ctrl+C to stop the server")
|
||||
|
||||
# Run the Flask app
|
||||
# Disable debug and auto-reloader when running in production or background
|
||||
is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||
|
||||
logger.info("Running in %s mode", "debug" if is_debug else "production")
|
||||
app.run(
|
||||
host='0.0.0.0',
|
||||
port=MCP_PORT,
|
||||
debug=is_debug,
|
||||
use_reloader=is_debug,
|
||||
threaded=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting server: {str(e)}", exc_info=True)
|
||||
sys.exit(1)
|
||||
427
mcp_server_cli.py
Executable file
427
mcp_server_cli.py
Executable file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Command-line MCP server for Basecamp integration with Cursor.
|
||||
|
||||
This server implements the MCP (Model Context Protocol) via stdin/stdout
|
||||
as expected by Cursor.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from basecamp_client import BasecampClient
|
||||
from search_utils import BasecampSearch
|
||||
import token_storage
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Determine project root (directory containing this script)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
# Explicitly load .env from the project root
|
||||
DOTENV_PATH = os.path.join(PROJECT_ROOT, '.env')
|
||||
load_dotenv(DOTENV_PATH)
|
||||
|
||||
# Log file in the project directory
|
||||
LOG_FILE_PATH = os.path.join(PROJECT_ROOT, 'mcp_cli_server.log')
|
||||
# Set up logging to file AND stderr
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE_PATH),
|
||||
logging.StreamHandler(sys.stderr) # Added StreamHandler for stderr
|
||||
]
|
||||
)
|
||||
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 [
|
||||
{
|
||||
"name": "get_projects",
|
||||
"description": "Get all Basecamp projects",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_project",
|
||||
"description": "Get details for a specific project",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "The project ID"}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_todolists",
|
||||
"description": "Get todo lists for a project",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "The project ID"}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_todos",
|
||||
"description": "Get todos from a todo list",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todolist_id": {"type": "string", "description": "The todo list ID"}
|
||||
},
|
||||
"required": ["todolist_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search_basecamp",
|
||||
"description": "Search across Basecamp projects, todos, and messages",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"project_id": {"type": "string", "description": "Optional project ID to limit search scope"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_comments",
|
||||
"description": "Get comments for a Basecamp item",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recording_id": {"type": "string", "description": "The item ID"},
|
||||
"bucket_id": {"type": "string", "description": "The bucket/project ID"}
|
||||
},
|
||||
"required": ["recording_id", "bucket_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_campfire_lines",
|
||||
"description": "Get recent messages from a Basecamp campfire (chat room)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "The project ID"},
|
||||
"campfire_id": {"type": "string", "description": "The campfire/chat room ID"}
|
||||
},
|
||||
"required": ["project_id", "campfire_id"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
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,
|
||||
user_agent=user_agent,
|
||||
auth_mode='oauth'
|
||||
)
|
||||
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")
|
||||
# Normalize method name for cursor compatibility
|
||||
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 {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "basecamp-mcp-server",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"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,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": json.dumps(result, indent=2)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
elif method_lower in ("listofferings", "list_offerings", "loffering"):
|
||||
# Respond to Cursor's ListOfferings UI request
|
||||
offerings = []
|
||||
for tool in self.tools:
|
||||
offerings.append({
|
||||
"name": tool.get("name"),
|
||||
"displayName": tool.get("name"),
|
||||
"description": tool.get("description")
|
||||
})
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"offerings": offerings
|
||||
}
|
||||
}
|
||||
|
||||
elif method_lower == "ping":
|
||||
# Handle ping requests
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {}
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": f"Method not found: {method}"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling request: {e}")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"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()
|
||||
if not client:
|
||||
# Check if it's specifically a token expiration issue
|
||||
if token_storage.is_token_expired():
|
||||
return {
|
||||
"error": "OAuth token expired",
|
||||
"message": "Your Basecamp OAuth token has expired. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again."
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"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()
|
||||
return {
|
||||
"status": "success",
|
||||
"projects": projects,
|
||||
"count": len(projects)
|
||||
}
|
||||
|
||||
elif tool_name == "get_project":
|
||||
project_id = arguments.get("project_id")
|
||||
project = client.get_project(project_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"project": project
|
||||
}
|
||||
|
||||
elif tool_name == "get_todolists":
|
||||
project_id = arguments.get("project_id")
|
||||
todolists = client.get_todolists(project_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"todolists": todolists,
|
||||
"count": len(todolists)
|
||||
}
|
||||
|
||||
elif tool_name == "get_todos":
|
||||
todolist_id = arguments.get("todolist_id")
|
||||
todos = client.get_todos(todolist_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"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)
|
||||
results["todos"] = search.search_todos(query, project_id)
|
||||
else:
|
||||
# Search across all projects
|
||||
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")
|
||||
comments = client.get_comments(recording_id, bucket_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"comments": comments,
|
||||
"count": len(comments)
|
||||
}
|
||||
|
||||
elif tool_name == "get_campfire_lines":
|
||||
project_id = arguments.get("project_id")
|
||||
campfire_id = arguments.get("campfire_id")
|
||||
lines = client.get_campfire_lines(project_id, campfire_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"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)
|
||||
if "401" in str(e) and "expired" in str(e).lower():
|
||||
return {
|
||||
"error": "OAuth token expired",
|
||||
"message": "Your Basecamp OAuth token expired during the API call. Please re-authenticate by visiting http://localhost:8000 and completing the OAuth flow again."
|
||||
}
|
||||
return {
|
||||
"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 = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {
|
||||
"code": -32700,
|
||||
"message": "Parse error"
|
||||
}
|
||||
}
|
||||
print(json.dumps(error_response), flush=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
error_response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"message": f"Internal error: {str(e)}"
|
||||
}
|
||||
}
|
||||
print(json.dumps(error_response), flush=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = MCPServer()
|
||||
server.run()
|
||||
@@ -204,6 +204,7 @@ def auth_callback():
|
||||
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')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
requests==2.31.0
|
||||
python-dotenv==1.0.0
|
||||
flask==2.3.3
|
||||
flask-cors==4.0.0
|
||||
flask-cors==4.0.0
|
||||
pytest==7.4.0
|
||||
35
setup.sh
35
setup.sh
@@ -1,35 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
echo "Setting up Basecamp API integration..."
|
||||
|
||||
# Create virtual environment
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
|
||||
# Activate virtual environment
|
||||
echo "Activating virtual environment..."
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Set up .env file if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
echo "Creating .env file from template..."
|
||||
cp .env.example .env
|
||||
echo "Please edit .env with your Basecamp credentials"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Setup complete!"
|
||||
echo ""
|
||||
echo "To activate the virtual environment, run:"
|
||||
echo " source venv/bin/activate"
|
||||
echo ""
|
||||
echo "To test your Basecamp connection, run:"
|
||||
echo " python basecamp_cli.py projects"
|
||||
echo ""
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Starting Basecamp MCP integration..."
|
||||
|
||||
# Kill any existing processes
|
||||
echo "Stopping any existing servers..."
|
||||
pkill -f "python oauth_app.py" 2>/dev/null || true
|
||||
pkill -f "python mcp_server.py" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ -d "venv" ]; then
|
||||
echo "Activating virtual environment..."
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# Start the OAuth app
|
||||
echo "Starting OAuth app on port 8000..."
|
||||
nohup python oauth_app.py > oauth_app.log 2>&1 < /dev/null &
|
||||
OAUTH_PID=$!
|
||||
echo "OAuth app started with PID: $OAUTH_PID"
|
||||
|
||||
# Wait a bit for OAuth app to start
|
||||
sleep 2
|
||||
|
||||
# Start the MCP server
|
||||
echo "Starting MCP server on port 5001..."
|
||||
nohup python mcp_server.py > mcp_server.log 2>&1 < /dev/null &
|
||||
MCP_PID=$!
|
||||
echo "MCP server started with PID: $MCP_PID"
|
||||
|
||||
echo ""
|
||||
echo "Basecamp MCP integration is now running:"
|
||||
echo "- OAuth app: http://localhost:8000"
|
||||
echo "- MCP server: http://localhost:5001"
|
||||
echo ""
|
||||
echo "To stop the servers, run: pkill -f 'python oauth_app.py' && pkill -f 'python mcp_server.py'"
|
||||
echo ""
|
||||
echo "To check server logs, run:"
|
||||
echo "- OAuth app logs: tail -f oauth_app.log"
|
||||
echo "- MCP server logs: tail -f mcp_server.log"
|
||||
echo ""
|
||||
echo "To use with Cursor, configure a new MCP server with URL: http://localhost:5001"
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
214
tests/test_cli_server.py
Normal file
214
tests/test_cli_server.py
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the CLI MCP server."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
import token_storage
|
||||
|
||||
def test_cli_server_initialize():
|
||||
"""Test that the CLI server responds to initialize requests."""
|
||||
# Create a mock request
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
# Start the CLI server process
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "mcp_server_cli.py"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
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
|
||||
assert "result" in response
|
||||
assert "protocolVersion" in response["result"]
|
||||
assert "capabilities" in response["result"]
|
||||
assert "serverInfo" in response["result"]
|
||||
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
def test_cli_server_tools_list():
|
||||
"""Test that the CLI server returns available tools."""
|
||||
# Create requests
|
||||
init_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"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"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
try:
|
||||
# Send both requests
|
||||
input_data = json.dumps(init_request) + "\n" + json.dumps(tools_request) + "\n"
|
||||
stdout, stderr = proc.communicate(
|
||||
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()
|
||||
|
||||
@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
|
||||
# that the CLI server handles authentication errors gracefully
|
||||
|
||||
# Create requests
|
||||
init_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
tool_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_projects",
|
||||
"arguments": {}
|
||||
}
|
||||
}
|
||||
|
||||
# Start the CLI server process
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "mcp_server_cli.py"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
try:
|
||||
# Send both requests
|
||||
input_data = json.dumps(init_request) + "\n" + json.dumps(tool_request) + "\n"
|
||||
stdout, stderr = proc.communicate(
|
||||
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()
|
||||
|
||||
def test_cli_server_invalid_method():
|
||||
"""Test that the CLI server handles invalid methods."""
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "invalid_method",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
# Start the CLI server process
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "mcp_server_cli.py"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
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()
|
||||
@@ -10,21 +10,31 @@ import os
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
# Token storage file - in production, use a database instead
|
||||
TOKEN_FILE = 'oauth_tokens.json'
|
||||
# Determine the directory where this script (token_storage.py) is located
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
# Define TOKEN_FILE as an absolute path within that directory
|
||||
TOKEN_FILE = os.path.join(SCRIPT_DIR, 'oauth_tokens.json')
|
||||
|
||||
# Lock for thread-safe operations
|
||||
_lock = threading.Lock()
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def _read_tokens():
|
||||
"""Read tokens from storage."""
|
||||
try:
|
||||
with open(TOKEN_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
basecamp_data = data.get('basecamp', {})
|
||||
updated_at = basecamp_data.get('updated_at')
|
||||
_logger.info(f"Read tokens from {TOKEN_FILE}. Basecamp token updated_at: {updated_at}")
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
_logger.info(f"{TOKEN_FILE} not found. Returning empty tokens.")
|
||||
return {} # Return empty dict if file doesn't exist
|
||||
except json.JSONDecodeError:
|
||||
_logger.warning(f"Error decoding JSON from {TOKEN_FILE}. Returning empty tokens.")
|
||||
# If file exists but isn't valid JSON, return empty dict
|
||||
return {}
|
||||
|
||||
@@ -33,6 +43,10 @@ def _write_tokens(tokens):
|
||||
# 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}")
|
||||
|
||||
# Set secure permissions on the file
|
||||
with open(TOKEN_FILE, 'w') as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
|
||||
Reference in New Issue
Block a user