Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503e97e520 |
41
README.md
41
README.md
@@ -2,9 +2,48 @@
|
|||||||
|
|
||||||
This project provides a **FastMCP-powered** integration for Basecamp 3, allowing Cursor to interact with Basecamp directly through the MCP protocol.
|
This project provides a **FastMCP-powered** integration for Basecamp 3, allowing Cursor to interact with Basecamp directly through the MCP protocol.
|
||||||
|
|
||||||
✅ **Migration Complete:** Successfully migrated to official Anthropic FastMCP framework with **100% feature parity** (all 46 tools)
|
✅ **Migration Complete:** Successfully migrated to official Anthropic FastMCP framework with **100% feature parity** (all 46 tools)
|
||||||
🚀 **Ready for Production:** Full protocol compliance with MCP 2025-06-18
|
🚀 **Ready for Production:** Full protocol compliance with MCP 2025-06-18
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The Basecamp MCP server is configured entirely via environment variables. No `.env` file is required.
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
All required configuration is passed via environment variables:
|
||||||
|
|
||||||
|
- `BASECAMP_CLIENT_ID` - OAuth client ID from 37signals
|
||||||
|
- `BASECAMP_CLIENT_SECRET` - OAuth client secret from 37signals
|
||||||
|
- `BASECAMP_ACCOUNT_ID` - Your Basecamp account ID
|
||||||
|
- `BASECAMP_REDIRECT_URI` - OAuth callback URL (usually `http://localhost:8000/auth/callback`)
|
||||||
|
- `USER_AGENT` - User agent string for API requests
|
||||||
|
|
||||||
|
### Data Locations
|
||||||
|
|
||||||
|
The server stores data in XDG-compliant locations:
|
||||||
|
|
||||||
|
| File Type | Default Location | Environment Variable Override |
|
||||||
|
|-----------|------------------|-------------------------------|
|
||||||
|
| OAuth Tokens | `~/.local/share/basecamp-mcp/oauth_tokens.json` | `BASECAMP_TOKEN_FILE` |
|
||||||
|
| Server Logs | `~/.local/state/basecamp-mcp/` | `BASECAMP_LOG_DIR` |
|
||||||
|
|
||||||
|
### Optional .env File (Development Only)
|
||||||
|
|
||||||
|
For local development, you can optionally create a `.env` file to avoid setting environment variables each time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default location: ~/.config/basecamp-mcp/.env
|
||||||
|
# Or override with: BASECAMP_ENV_FILE=/custom/path/.env
|
||||||
|
BASECAMP_CLIENT_ID=your_client_id_here
|
||||||
|
BASECAMP_CLIENT_SECRET=your_client_secret_here
|
||||||
|
BASECAMP_ACCOUNT_ID=your_account_id_here
|
||||||
|
BASECAMP_REDIRECT_URI=http://localhost:8000/auth/callback
|
||||||
|
USER_AGENT="Your App Name (your@email.com)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The server works perfectly without a `.env` file - it's only a convenience for development.
|
||||||
|
|
||||||
## Quick Setup
|
## Quick Setup
|
||||||
|
|
||||||
This server works with both **Cursor** and **Claude Desktop**. Choose your preferred client:
|
This server works with both **Cursor** and **Claude Desktop**. Choose your preferred client:
|
||||||
|
|||||||
1161
basecamp_fastmcp.py
1161
basecamp_fastmcp.py
File diff suppressed because it is too large
Load Diff
92
config_paths.py
Normal file
92
config_paths.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Centralized path resolution for Basecamp MCP Server.
|
||||||
|
|
||||||
|
All paths can be overridden via environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_directory() -> Path:
|
||||||
|
"""
|
||||||
|
Get the directory for log files.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. BASECAMP_LOG_DIR env variable
|
||||||
|
2. XDG_STATE_HOME (typically ~/.local/state)
|
||||||
|
3. ~/.local/state/basecamp-mcp (fallback)
|
||||||
|
"""
|
||||||
|
if log_dir := os.getenv("BASECAMP_LOG_DIR"):
|
||||||
|
return Path(log_dir)
|
||||||
|
|
||||||
|
if xdg_state := os.getenv("XDG_STATE_HOME"):
|
||||||
|
return Path(xdg_state) / "basecamp-mcp"
|
||||||
|
|
||||||
|
return Path.home() / ".local/state/basecamp-mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_directory() -> Path:
|
||||||
|
"""
|
||||||
|
Get the directory for configuration files (.env).
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. BASECAMP_CONFIG_DIR env variable
|
||||||
|
2. XDG_CONFIG_HOME (typically ~/.config)
|
||||||
|
3. ~/.config/basecamp-mcp (fallback)
|
||||||
|
|
||||||
|
NOTE: This is only used if BASECAMP_ENV_FILE is not set and
|
||||||
|
a .env file exists in the config directory. The server works
|
||||||
|
entirely with environment variables and doesn't require .env files.
|
||||||
|
"""
|
||||||
|
if config_dir := os.getenv("BASECAMP_CONFIG_DIR"):
|
||||||
|
return Path(config_dir)
|
||||||
|
|
||||||
|
if xdg_config := os.getenv("XDG_CONFIG_HOME"):
|
||||||
|
return Path(xdg_config) / "basecamp-mcp"
|
||||||
|
|
||||||
|
return Path.home() / ".config/basecamp-mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_file_path() -> Path:
|
||||||
|
"""
|
||||||
|
Get the path for .env file (optional).
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. BASECAMP_ENV_FILE env variable (explicitly specified)
|
||||||
|
2. Config directory + / .env
|
||||||
|
|
||||||
|
The server does NOT require this file - it's optional.
|
||||||
|
If not found, environment will be used as-is.
|
||||||
|
"""
|
||||||
|
if env_file := os.getenv("BASECAMP_ENV_FILE"):
|
||||||
|
return Path(env_file)
|
||||||
|
|
||||||
|
return get_config_directory() / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_file_path() -> Path:
|
||||||
|
"""
|
||||||
|
Get the path for OAuth token storage.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. BASECAMP_TOKEN_FILE env variable
|
||||||
|
2. XDG_DATA_HOME (typically ~/.local/share)
|
||||||
|
3. ~/.local/share/basecamp-mcp/oauth_tokens.json (fallback)
|
||||||
|
"""
|
||||||
|
if token_file := os.getenv("BASECAMP_TOKEN_FILE"):
|
||||||
|
return Path(token_file)
|
||||||
|
|
||||||
|
if xdg_data := os.getenv("XDG_DATA_HOME"):
|
||||||
|
return Path(xdg_data) / "basecamp-mcp" / "oauth_tokens.json"
|
||||||
|
|
||||||
|
return Path.home() / ".local/share/basecamp-mcp" / "oauth_tokens.json"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_directories_exist() -> None:
|
||||||
|
"""
|
||||||
|
Create all necessary directories if they don't exist.
|
||||||
|
"""
|
||||||
|
get_log_directory().mkdir(parents=True, exist_ok=True)
|
||||||
|
get_config_directory().mkdir(parents=True, exist_ok=True)
|
||||||
|
get_token_file_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
1073
mcp_server_cli.py
1073
mcp_server_cli.py
File diff suppressed because it is too large
Load Diff
260
oauth_app.py
260
oauth_app.py
@@ -13,29 +13,47 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import logging
|
import logging
|
||||||
from flask import Flask, request, redirect, url_for, session, render_template_string, jsonify
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
request,
|
||||||
|
redirect,
|
||||||
|
url_for,
|
||||||
|
session,
|
||||||
|
render_template_string,
|
||||||
|
jsonify,
|
||||||
|
)
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from basecamp_oauth import BasecampOAuth
|
from basecamp_oauth import BasecampOAuth
|
||||||
from basecamp_client import BasecampClient
|
from basecamp_client import BasecampClient
|
||||||
from search_utils import BasecampSearch
|
from search_utils import BasecampSearch
|
||||||
import token_storage
|
import token_storage
|
||||||
|
|
||||||
# Configure logging
|
from config_paths import get_log_directory, get_env_file_path, ensure_directories_exist
|
||||||
|
|
||||||
|
ensure_directories_exist()
|
||||||
|
|
||||||
|
DOTENV_PATH = get_env_file_path()
|
||||||
|
if DOTENV_PATH.exists():
|
||||||
|
load_dotenv(DOTENV_PATH)
|
||||||
|
|
||||||
|
LOG_FILE_PATH = get_log_directory() / "oauth_app.log"
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.FileHandler("oauth_app.log"),
|
logging.FileHandler(LOG_FILE_PATH),
|
||||||
logging.StreamHandler()
|
logging.StreamHandler(),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Check for required environment variables
|
# Check for required environment variables
|
||||||
required_vars = ['BASECAMP_CLIENT_ID', 'BASECAMP_CLIENT_SECRET', 'BASECAMP_REDIRECT_URI', 'USER_AGENT']
|
required_vars = [
|
||||||
|
"BASECAMP_CLIENT_ID",
|
||||||
|
"BASECAMP_CLIENT_SECRET",
|
||||||
|
"BASECAMP_REDIRECT_URI",
|
||||||
|
"USER_AGENT",
|
||||||
|
]
|
||||||
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
||||||
if missing_vars:
|
if missing_vars:
|
||||||
logger.error(f"Missing required environment variables: {', '.join(missing_vars)}")
|
logger.error(f"Missing required environment variables: {', '.join(missing_vars)}")
|
||||||
@@ -44,7 +62,7 @@ if missing_vars:
|
|||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.getenv('FLASK_SECRET_KEY', secrets.token_hex(16))
|
app.secret_key = os.getenv("FLASK_SECRET_KEY", secrets.token_hex(16))
|
||||||
|
|
||||||
# HTML template for displaying results
|
# HTML template for displaying results
|
||||||
RESULTS_TEMPLATE = """
|
RESULTS_TEMPLATE = """
|
||||||
@@ -109,101 +127,116 @@ RESULTS_TEMPLATE = """
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@app.template_filter('tojson')
|
|
||||||
|
@app.template_filter("tojson")
|
||||||
def to_json(value, indent=None):
|
def to_json(value, indent=None):
|
||||||
return json.dumps(value, indent=indent)
|
return json.dumps(value, indent=indent)
|
||||||
|
|
||||||
|
|
||||||
def get_oauth_client():
|
def get_oauth_client():
|
||||||
"""Get a configured OAuth client."""
|
"""Get a configured OAuth client."""
|
||||||
try:
|
try:
|
||||||
client_id = os.getenv('BASECAMP_CLIENT_ID')
|
client_id = os.getenv("BASECAMP_CLIENT_ID")
|
||||||
client_secret = os.getenv('BASECAMP_CLIENT_SECRET')
|
client_secret = os.getenv("BASECAMP_CLIENT_SECRET")
|
||||||
redirect_uri = os.getenv('BASECAMP_REDIRECT_URI')
|
redirect_uri = os.getenv("BASECAMP_REDIRECT_URI")
|
||||||
user_agent = os.getenv('USER_AGENT')
|
user_agent = os.getenv("USER_AGENT")
|
||||||
|
|
||||||
logger.info("Creating OAuth client with config: %s, %s, %s", client_id, redirect_uri, user_agent)
|
logger.info(
|
||||||
|
"Creating OAuth client with config: %s, %s, %s",
|
||||||
|
client_id,
|
||||||
|
redirect_uri,
|
||||||
|
user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
return BasecampOAuth(
|
return BasecampOAuth(
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
client_secret=client_secret,
|
client_secret=client_secret,
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
user_agent=user_agent
|
user_agent=user_agent,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error creating OAuth client: %s", str(e))
|
logger.error("Error creating OAuth client: %s", str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def ensure_valid_token():
|
def ensure_valid_token():
|
||||||
"""
|
"""
|
||||||
Ensure we have a valid, non-expired token.
|
Ensure we have a valid, non-expired token.
|
||||||
Attempts to refresh if expired.
|
Attempts to refresh if expired.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Valid token data or None if authentication is needed
|
dict: Valid token data or None if authentication is needed
|
||||||
"""
|
"""
|
||||||
token_data = token_storage.get_token()
|
token_data = token_storage.get_token()
|
||||||
|
|
||||||
if not token_data or not token_data.get('access_token'):
|
if not token_data or not token_data.get("access_token"):
|
||||||
logger.info("No token found")
|
logger.info("No token found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if token is expired
|
# Check if token is expired
|
||||||
if token_storage.is_token_expired():
|
if token_storage.is_token_expired():
|
||||||
logger.info("Token is expired, attempting to refresh")
|
logger.info("Token is expired, attempting to refresh")
|
||||||
|
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get("refresh_token")
|
||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
logger.warning("No refresh token available, user needs to re-authenticate")
|
logger.warning("No refresh token available, user needs to re-authenticate")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
oauth_client = get_oauth_client()
|
oauth_client = get_oauth_client()
|
||||||
new_token_data = oauth_client.refresh_token(refresh_token)
|
new_token_data = oauth_client.refresh_token(refresh_token)
|
||||||
|
|
||||||
# Store the new token
|
# Store the new token
|
||||||
access_token = new_token_data.get('access_token')
|
access_token = new_token_data.get("access_token")
|
||||||
new_refresh_token = new_token_data.get('refresh_token', refresh_token) # Use old refresh token if new one not provided
|
new_refresh_token = new_token_data.get(
|
||||||
expires_in = new_token_data.get('expires_in')
|
"refresh_token", refresh_token
|
||||||
account_id = token_data.get('account_id') # Keep the existing account_id
|
) # Use old refresh token if new one not provided
|
||||||
|
expires_in = new_token_data.get("expires_in")
|
||||||
|
account_id = token_data.get("account_id") # Keep the existing account_id
|
||||||
|
|
||||||
if access_token:
|
if access_token:
|
||||||
token_storage.store_token(
|
token_storage.store_token(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=new_refresh_token,
|
refresh_token=new_refresh_token,
|
||||||
expires_in=expires_in,
|
expires_in=expires_in,
|
||||||
account_id=account_id
|
account_id=account_id,
|
||||||
)
|
)
|
||||||
logger.info("Token refreshed successfully")
|
logger.info("Token refreshed successfully")
|
||||||
return token_storage.get_token()
|
return token_storage.get_token()
|
||||||
else:
|
else:
|
||||||
logger.error("No access token in refresh response")
|
logger.error("No access token in refresh response")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to refresh token: %s", str(e))
|
logger.error("Failed to refresh token: %s", str(e))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info("Token is valid")
|
logger.info("Token is valid")
|
||||||
return token_data
|
return token_data
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
|
@app.route("/")
|
||||||
def home():
|
def home():
|
||||||
"""Home page."""
|
"""Home page."""
|
||||||
# Ensure we have a valid token
|
# Ensure we have a valid token
|
||||||
token_data = ensure_valid_token()
|
token_data = ensure_valid_token()
|
||||||
|
|
||||||
if token_data and token_data.get('access_token'):
|
if token_data and token_data.get("access_token"):
|
||||||
# We have a valid token, show token information
|
# We have a valid token, show token information
|
||||||
access_token = token_data['access_token']
|
access_token = token_data["access_token"]
|
||||||
# Mask the token for security
|
# Mask the token for security
|
||||||
masked_token = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
|
masked_token = (
|
||||||
|
f"{access_token[:10]}...{access_token[-10:]}"
|
||||||
|
if len(access_token) > 20
|
||||||
|
else "***"
|
||||||
|
)
|
||||||
|
|
||||||
token_info = {
|
token_info = {
|
||||||
"access_token": masked_token,
|
"access_token": masked_token,
|
||||||
"account_id": token_data.get('account_id'),
|
"account_id": token_data.get("account_id"),
|
||||||
"has_refresh_token": bool(token_data.get('refresh_token')),
|
"has_refresh_token": bool(token_data.get("refresh_token")),
|
||||||
"expires_at": token_data.get('expires_at'),
|
"expires_at": token_data.get("expires_at"),
|
||||||
"updated_at": token_data.get('updated_at')
|
"updated_at": token_data.get("updated_at"),
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Home page: User is authenticated")
|
logger.info("Home page: User is authenticated")
|
||||||
@@ -213,7 +246,7 @@ def home():
|
|||||||
title="Basecamp OAuth Status",
|
title="Basecamp OAuth Status",
|
||||||
message="You are authenticated with Basecamp!",
|
message="You are authenticated with Basecamp!",
|
||||||
token_info=token_info,
|
token_info=token_info,
|
||||||
show_logout=True
|
show_logout=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# No valid token, show login button
|
# No valid token, show login button
|
||||||
@@ -227,7 +260,7 @@ def home():
|
|||||||
RESULTS_TEMPLATE,
|
RESULTS_TEMPLATE,
|
||||||
title="Basecamp OAuth Demo",
|
title="Basecamp OAuth Demo",
|
||||||
message="Welcome! Please log in with your Basecamp account to continue.",
|
message="Welcome! Please log in with your Basecamp account to continue.",
|
||||||
auth_url=auth_url
|
auth_url=auth_url,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error getting authorization URL: %s", str(e))
|
logger.error("Error getting authorization URL: %s", str(e))
|
||||||
@@ -237,13 +270,14 @@ def home():
|
|||||||
message=f"Error setting up OAuth: {str(e)}",
|
message=f"Error setting up OAuth: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/auth/callback')
|
|
||||||
|
@app.route("/auth/callback")
|
||||||
def auth_callback():
|
def auth_callback():
|
||||||
"""Handle the OAuth callback from Basecamp."""
|
"""Handle the OAuth callback from Basecamp."""
|
||||||
logger.info("OAuth callback called with args: %s", request.args)
|
logger.info("OAuth callback called with args: %s", request.args)
|
||||||
|
|
||||||
code = request.args.get('code')
|
code = request.args.get("code")
|
||||||
error = request.args.get('error')
|
error = request.args.get("error")
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
logger.error("OAuth callback error: %s", error)
|
logger.error("OAuth callback error: %s", error)
|
||||||
@@ -251,7 +285,7 @@ def auth_callback():
|
|||||||
RESULTS_TEMPLATE,
|
RESULTS_TEMPLATE,
|
||||||
title="Authentication Error",
|
title="Authentication Error",
|
||||||
message=f"Basecamp returned an error: {error}",
|
message=f"Basecamp returned an error: {error}",
|
||||||
show_home=True
|
show_home=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
@@ -260,7 +294,7 @@ def auth_callback():
|
|||||||
RESULTS_TEMPLATE,
|
RESULTS_TEMPLATE,
|
||||||
title="Error",
|
title="Error",
|
||||||
message="No authorization code received.",
|
message="No authorization code received.",
|
||||||
show_home=True
|
show_home=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -271,10 +305,10 @@ def auth_callback():
|
|||||||
logger.info(f"Raw token data from Basecamp exchange: {token_data}")
|
logger.info(f"Raw token data from Basecamp exchange: {token_data}")
|
||||||
|
|
||||||
# Store the token in our secure storage
|
# Store the token in our secure storage
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get("access_token")
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get("refresh_token")
|
||||||
expires_in = token_data.get('expires_in')
|
expires_in = token_data.get("expires_in")
|
||||||
account_id = os.getenv('BASECAMP_ACCOUNT_ID')
|
account_id = os.getenv("BASECAMP_ACCOUNT_ID")
|
||||||
|
|
||||||
if not access_token:
|
if not access_token:
|
||||||
logger.error("OAuth exchange: No access token received")
|
logger.error("OAuth exchange: No access token received")
|
||||||
@@ -282,7 +316,7 @@ def auth_callback():
|
|||||||
RESULTS_TEMPLATE,
|
RESULTS_TEMPLATE,
|
||||||
title="Authentication Error",
|
title="Authentication Error",
|
||||||
message="No access token received from Basecamp.",
|
message="No access token received from Basecamp.",
|
||||||
show_home=True
|
show_home=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to get identity if account_id is not set
|
# Try to get identity if account_id is not set
|
||||||
@@ -293,10 +327,10 @@ def auth_callback():
|
|||||||
logger.info("Identity response: %s", identity)
|
logger.info("Identity response: %s", identity)
|
||||||
|
|
||||||
# Find Basecamp 3 account
|
# Find Basecamp 3 account
|
||||||
if identity.get('accounts'):
|
if identity.get("accounts"):
|
||||||
for account in identity['accounts']:
|
for account in identity["accounts"]:
|
||||||
if account.get('product') == 'bc3': # Basecamp 3
|
if account.get("product") == "bc3": # Basecamp 3
|
||||||
account_id = account['id']
|
account_id = account["id"]
|
||||||
logger.info("Found account_id: %s", account_id)
|
logger.info("Found account_id: %s", account_id)
|
||||||
break
|
break
|
||||||
except Exception as identity_error:
|
except Exception as identity_error:
|
||||||
@@ -308,7 +342,7 @@ def auth_callback():
|
|||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
expires_in=expires_in,
|
expires_in=expires_in,
|
||||||
account_id=account_id
|
account_id=account_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not stored:
|
if not stored:
|
||||||
@@ -317,29 +351,30 @@ def auth_callback():
|
|||||||
RESULTS_TEMPLATE,
|
RESULTS_TEMPLATE,
|
||||||
title="Error",
|
title="Error",
|
||||||
message="Failed to store token. Please try again.",
|
message="Failed to store token. Please try again.",
|
||||||
show_home=True
|
show_home=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Also keep the access token in session for convenience
|
# Also keep the access token in session for convenience
|
||||||
session['access_token'] = access_token
|
session["access_token"] = access_token
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
session['refresh_token'] = refresh_token
|
session["refresh_token"] = refresh_token
|
||||||
if account_id:
|
if account_id:
|
||||||
session['account_id'] = account_id
|
session["account_id"] = account_id
|
||||||
|
|
||||||
logger.info("OAuth flow completed successfully")
|
logger.info("OAuth flow completed successfully")
|
||||||
|
|
||||||
return redirect(url_for('home'))
|
return redirect(url_for("home"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error in OAuth callback: %s", str(e), exc_info=True)
|
logger.error("Error in OAuth callback: %s", str(e), exc_info=True)
|
||||||
return render_template_string(
|
return render_template_string(
|
||||||
RESULTS_TEMPLATE,
|
RESULTS_TEMPLATE,
|
||||||
title="Error",
|
title="Error",
|
||||||
message=f"Failed to exchange code for token: {str(e)}",
|
message=f"Failed to exchange code for token: {str(e)}",
|
||||||
show_home=True
|
show_home=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/api/token', methods=['GET'])
|
|
||||||
|
@app.route("/api/token", methods=["GET"])
|
||||||
def get_token_api():
|
def get_token_api():
|
||||||
"""
|
"""
|
||||||
Secure API endpoint for the MCP server to get the token.
|
Secure API endpoint for the MCP server to get the token.
|
||||||
@@ -349,38 +384,40 @@ def get_token_api():
|
|||||||
|
|
||||||
# In production, implement proper authentication for this endpoint
|
# In production, implement proper authentication for this endpoint
|
||||||
# For now, we'll use a simple API key check
|
# For now, we'll use a simple API key check
|
||||||
api_key = request.headers.get('X-API-Key')
|
api_key = request.headers.get("X-API-Key")
|
||||||
if not api_key or api_key != os.getenv('MCP_API_KEY', 'mcp_secret_key'):
|
if not api_key or api_key != os.getenv("MCP_API_KEY", "mcp_secret_key"):
|
||||||
logger.error("Token API: Invalid API key")
|
logger.error("Token API: Invalid API key")
|
||||||
return jsonify({
|
return jsonify(
|
||||||
"error": "Unauthorized",
|
{"error": "Unauthorized", "message": "Invalid or missing API key"}
|
||||||
"message": "Invalid or missing API key"
|
), 401
|
||||||
}), 401
|
|
||||||
|
|
||||||
# Use the ensure_valid_token function to get a fresh token
|
# Use the ensure_valid_token function to get a fresh token
|
||||||
token_data = ensure_valid_token()
|
token_data = ensure_valid_token()
|
||||||
if not token_data or not token_data.get('access_token'):
|
if not token_data or not token_data.get("access_token"):
|
||||||
logger.error("Token API: No valid token available")
|
logger.error("Token API: No valid token available")
|
||||||
return jsonify({
|
return jsonify(
|
||||||
"error": "Not authenticated",
|
{"error": "Not authenticated", "message": "No valid token available"}
|
||||||
"message": "No valid token available"
|
), 404
|
||||||
}), 404
|
|
||||||
|
|
||||||
logger.info("Token API: Successfully returned token")
|
logger.info("Token API: Successfully returned token")
|
||||||
return jsonify({
|
return jsonify(
|
||||||
"access_token": token_data['access_token'],
|
{
|
||||||
"account_id": token_data.get('account_id')
|
"access_token": token_data["access_token"],
|
||||||
})
|
"account_id": token_data.get("account_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/logout')
|
|
||||||
|
@app.route("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
"""Clear the session and token storage."""
|
"""Clear the session and token storage."""
|
||||||
logger.info("Logout called")
|
logger.info("Logout called")
|
||||||
session.clear()
|
session.clear()
|
||||||
token_storage.clear_tokens()
|
token_storage.clear_tokens()
|
||||||
return redirect(url_for('home'))
|
return redirect(url_for("home"))
|
||||||
|
|
||||||
@app.route('/token/info')
|
|
||||||
|
@app.route("/token/info")
|
||||||
def token_info():
|
def token_info():
|
||||||
"""Display information about the stored token."""
|
"""Display information about the stored token."""
|
||||||
logger.info("Token info called")
|
logger.info("Token info called")
|
||||||
@@ -392,26 +429,36 @@ def token_info():
|
|||||||
RESULTS_TEMPLATE,
|
RESULTS_TEMPLATE,
|
||||||
title="Token Information",
|
title="Token Information",
|
||||||
message="No token stored.",
|
message="No token stored.",
|
||||||
show_home=True
|
show_home=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if token is expired
|
# Check if token is expired
|
||||||
is_expired = token_storage.is_token_expired()
|
is_expired = token_storage.is_token_expired()
|
||||||
|
|
||||||
# Mask the tokens for security
|
|
||||||
access_token = token_data.get('access_token', '')
|
|
||||||
refresh_token = token_data.get('refresh_token', '')
|
|
||||||
|
|
||||||
masked_access = f"{access_token[:10]}...{access_token[-10:]}" if len(access_token) > 20 else "***"
|
# Mask the tokens for security
|
||||||
masked_refresh = f"{refresh_token[:10]}...{refresh_token[-10:]}" if refresh_token and len(refresh_token) > 20 else "***" if refresh_token else None
|
access_token = token_data.get("access_token", "")
|
||||||
|
refresh_token = token_data.get("refresh_token", "")
|
||||||
|
|
||||||
|
masked_access = (
|
||||||
|
f"{access_token[:10]}...{access_token[-10:]}"
|
||||||
|
if len(access_token) > 20
|
||||||
|
else "***"
|
||||||
|
)
|
||||||
|
masked_refresh = (
|
||||||
|
f"{refresh_token[:10]}...{refresh_token[-10:]}"
|
||||||
|
if refresh_token and len(refresh_token) > 20
|
||||||
|
else "***"
|
||||||
|
if refresh_token
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
display_info = {
|
display_info = {
|
||||||
"access_token": masked_access,
|
"access_token": masked_access,
|
||||||
"has_refresh_token": bool(refresh_token),
|
"has_refresh_token": bool(refresh_token),
|
||||||
"account_id": token_data.get('account_id'),
|
"account_id": token_data.get("account_id"),
|
||||||
"expires_at": token_data.get('expires_at'),
|
"expires_at": token_data.get("expires_at"),
|
||||||
"updated_at": token_data.get('updated_at'),
|
"updated_at": token_data.get("updated_at"),
|
||||||
"is_expired": is_expired
|
"is_expired": is_expired,
|
||||||
}
|
}
|
||||||
|
|
||||||
warning_message = None
|
warning_message = None
|
||||||
@@ -424,29 +471,28 @@ def token_info():
|
|||||||
title="Token Information",
|
title="Token Information",
|
||||||
content=json.dumps(display_info, indent=2),
|
content=json.dumps(display_info, indent=2),
|
||||||
warning=warning_message,
|
warning=warning_message,
|
||||||
show_home=True
|
show_home=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/health')
|
|
||||||
|
@app.route("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
logger.info("Health check called")
|
logger.info("Health check called")
|
||||||
return jsonify({
|
return jsonify({"status": "ok", "service": "basecamp-oauth-app"})
|
||||||
"status": "ok",
|
|
||||||
"service": "basecamp-oauth-app"
|
|
||||||
})
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
logger.info("Starting OAuth app on port %s", os.environ.get('PORT', 8000))
|
logger.info("Starting OAuth app on port %s", os.environ.get("PORT", 8000))
|
||||||
# Run the Flask app
|
# Run the Flask app
|
||||||
port = int(os.environ.get('PORT', 8000))
|
port = int(os.environ.get("PORT", 8000))
|
||||||
|
|
||||||
# Disable debug and auto-reloader when running in production or background
|
# Disable debug and auto-reloader when running in production or background
|
||||||
is_debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
is_debug = os.environ.get("FLASK_DEBUG", "False").lower() == "true"
|
||||||
|
|
||||||
logger.info("Running in %s mode", "debug" if is_debug else "production")
|
logger.info("Running in %s mode", "debug" if is_debug else "production")
|
||||||
app.run(host='0.0.0.0', port=port, debug=is_debug, use_reloader=is_debug)
|
app.run(host="0.0.0.0", port=port, debug=is_debug, use_reloader=is_debug)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Fatal error: %s", str(e), exc_info=True)
|
logger.error("Fatal error: %s", str(e), exc_info=True)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import threading
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Determine the directory where this script (token_storage.py) is located
|
from config_paths import get_token_file_path
|
||||||
|
|
||||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
# Allow token file location to be configured via environment variable
|
|
||||||
# Falls back to oauth_tokens.json in the script directory if not set
|
|
||||||
TOKEN_FILE = os.environ.get(
|
TOKEN_FILE = os.environ.get(
|
||||||
"BASECAMP_TOKEN_FILE", os.path.join(SCRIPT_DIR, "oauth_tokens.json")
|
"BASECAMP_TOKEN_FILE",
|
||||||
|
str(get_token_file_path()),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Lock for thread-safe operations
|
# Lock for thread-safe operations
|
||||||
|
|||||||
Reference in New Issue
Block a user