pull in main

This commit is contained in:
Taylor Wilsdon
2025-08-13 09:48:51 -04:00
12 changed files with 148 additions and 106 deletions

22
.github/workflows/ruff.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Ruff Check
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync
- name: Run ruff check
run: uv run ruff check

View File

@@ -152,7 +152,6 @@ Claude Desktop stores these securely in the OS keychain; set them once in the ex
```bash
export GOOGLE_OAUTH_CLIENT_ID="your-client-id.apps.googleusercontent.com"
export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
export GOOGLE_OAUTH_REDIRECT_URI="http://localhost:8000/oauth2callback" # Optional - see Reverse Proxy Setup below
```
**Option B: File-based (Traditional)**
@@ -305,19 +304,8 @@ The server supports two transport modes:
#### Stdio Mode (Default - Recommended for Claude Desktop)
**Guided Setup (Recommended if not using DXT)**
```bash
python install_claude.py
```
This script automatically:
- Prompts you for your Google OAuth credentials (Client ID and Secret)
- Creates the Claude Desktop config file in the correct location
- Sets up all necessary environment variables
- No manual file editing required!
After running the script, just restart Claude Desktop and you're ready to go.
In general, you should use the one-click DXT installer package for Claude Desktop.
If you are unable to for some reason, you can configure it manually via `claude_desktop_config.json`
**Manual Claude Configuration (Alternative)**
1. Open Claude Desktop Settings → Developer → Edit Config

View File

@@ -327,7 +327,6 @@ async def handle_oauth_client_config(request: Request):
"client_uri": config.base_url,
"redirect_uris": [
f"{config.base_url}/oauth2callback",
"http://localhost:5173/auth/callback"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],

View File

@@ -203,6 +203,18 @@ class OAuthConfig:
if params.has_pkce:
return "oauth21"
# Additional detection: Check if we have an active OAuth 2.1 session
# This is important for tool calls where PKCE params aren't available
authenticated_user = request_params.get("authenticated_user")
if authenticated_user:
try:
from auth.oauth21_session_store import get_oauth21_session_store
store = get_oauth21_session_store()
if store.has_session(authenticated_user):
return "oauth21"
except Exception:
pass # Fall back to OAuth 2.0 if session check fails
# For public clients in OAuth 2.1 mode, we require PKCE
# But since they didn't send PKCE, fall back to OAuth 2.0
# This ensures backward compatibility

View File

@@ -336,33 +336,48 @@ def require_google_service(
from auth.oauth_config import is_oauth21_enabled, get_oauth_config
# Smart OAuth version detection and fallback
use_oauth21 = False
oauth_version = "oauth20" # Default
if is_oauth21_enabled():
# OAuth 2.1 is enabled globally, check client capabilities
# Try to detect from context if this is an OAuth 2.1 capable client
config = get_oauth_config()
# Build request params from context for version detection
request_params = {}
# When OAuth 2.1 is enabled globally, ALWAYS use OAuth 2.1 for authenticated users.
if authenticated_user:
request_params["authenticated_user"] = authenticated_user
use_oauth21 = True
logger.info(f"[{tool_name}] OAuth 2.1 mode: Using OAuth 2.1 for authenticated user '{authenticated_user}'")
else:
# Only use version detection for unauthenticated requests
config = get_oauth_config()
request_params = {}
if mcp_session_id:
request_params["session_id"] = mcp_session_id
# Detect OAuth version based on client capabilities
oauth_version = config.detect_oauth_version(request_params)
use_oauth21 = (oauth_version == "oauth21")
logger.info(f"[{tool_name}] OAuth version detected: {oauth_version}, will use OAuth 2.1: {use_oauth21}")
logger.debug(f"[{tool_name}] OAuth version detected: {oauth_version}, will use OAuth 2.1: {use_oauth21}")
# Override user_google_email with authenticated user when using OAuth 2.1
if use_oauth21 and authenticated_user:
if bound_args.arguments.get('user_google_email') != authenticated_user:
original_email = bound_args.arguments.get('user_google_email')
logger.info(f"[{tool_name}] OAuth 2.1: Overriding user_google_email from '{original_email}' to authenticated user '{authenticated_user}'")
bound_args.arguments['user_google_email'] = authenticated_user
user_google_email = authenticated_user
# Update in kwargs if the parameter exists there
if 'user_google_email' in kwargs:
kwargs['user_google_email'] = authenticated_user
# Update in args if user_google_email is passed positionally
wrapper_params = list(wrapper_sig.parameters.keys())
if 'user_google_email' in wrapper_params:
user_email_index = wrapper_params.index('user_google_email')
if user_email_index < len(args):
args_list = list(args)
args_list[user_email_index] = authenticated_user
args = tuple(args_list)
if use_oauth21:
logger.debug(f"[{tool_name}] Using OAuth 2.1 flow")
# The downstream get_authenticated_google_service_oauth21 will handle
# whether the user's token is valid for the requested resource.
# This decorator should not block the call here.
# The downstream get_authenticated_google_service_oauth21 will handle token validation
service, actual_user_email = await get_authenticated_google_service_oauth21(
service_name=service_name,
version=service_version,
@@ -397,7 +412,6 @@ def require_google_service(
# Re-raise the original error without wrapping it
raise
# --- Call the original function with the service object injected ---
try:
# Prepend the fetched service object to the original arguments
return await func(service, *args, **kwargs)
@@ -469,7 +483,6 @@ def require_multiple_services(service_configs: List[Dict[str, Any]]):
try:
tool_name = func.__name__
# SIMPLIFIED: Get authentication state from context (set by AuthInfoMiddleware)
authenticated_user = None
mcp_session_id = None
@@ -483,10 +496,39 @@ def require_multiple_services(service_configs: List[Dict[str, Any]]):
except Exception as e:
logger.debug(f"[{tool_name}] Could not get FastMCP context: {e}")
# Use the same logic as single service decorator
from auth.oauth_config import is_oauth21_enabled
use_oauth21 = False
if is_oauth21_enabled():
# When OAuth 2.1 is enabled globally, ALWAYS use OAuth 2.1 for authenticated users
if authenticated_user:
use_oauth21 = True
else:
# Only use version detection for unauthenticated requests (rare case)
use_oauth21 = False
# Override user_google_email with authenticated user when using OAuth 2.1
if use_oauth21 and authenticated_user:
if user_google_email != authenticated_user:
logger.info(f"[{tool_name}] OAuth 2.1: Overriding user_google_email from '{user_google_email}' to authenticated user '{authenticated_user}' for service '{service_type}'")
user_google_email = authenticated_user
# Update in kwargs if present
if 'user_google_email' in kwargs:
kwargs['user_google_email'] = authenticated_user
# Update in args if user_google_email is passed positionally
try:
user_email_index = param_names.index('user_google_email')
if user_email_index < len(args):
# Convert args to list, update, convert back to tuple
args_list = list(args)
args_list[user_email_index] = authenticated_user
args = tuple(args_list)
except ValueError:
pass # user_google_email not in positional parameters
if use_oauth21:
logger.debug(f"[{tool_name}] Attempting OAuth 2.1 authentication flow for {service_type}.")
service, _ = await get_authenticated_google_service_oauth21(
service_name=service_name,

View File

@@ -1,5 +1,4 @@
import logging
import os
from typing import Optional, Union
from importlib import metadata
@@ -24,7 +23,6 @@ from core.config import (
get_oauth_redirect_uri as get_oauth_redirect_uri_for_current_mode,
)
# Try to import GoogleRemoteAuthProvider for FastMCP 2.11.1+
try:
from auth.google_remote_auth_provider import GoogleRemoteAuthProvider
GOOGLE_REMOTE_AUTH_AVAILABLE = True
@@ -37,7 +35,6 @@ logger = logging.getLogger(__name__)
_auth_provider: Optional[Union[GoogleWorkspaceAuthProvider, GoogleRemoteAuthProvider]] = None
# --- Middleware Definitions ---
session_middleware = Middleware(MCPSessionMiddleware)
# Custom FastMCP that adds secure middleware stack for OAuth 2.1
@@ -55,7 +52,6 @@ class SecureFastMCP(FastMCP):
logger.info("Added middleware stack: Session Management")
return app
# --- Server Instance ---
server = SecureFastMCP(
name="google_workspace",
auth=None,
@@ -95,7 +91,14 @@ def configure_server_for_http():
logger.warning("⚠️ OAuth 2.1 enabled but OAuth credentials not configured")
return
if GOOGLE_REMOTE_AUTH_AVAILABLE:
if not GOOGLE_REMOTE_AUTH_AVAILABLE:
logger.error("CRITICAL: OAuth 2.1 enabled but FastMCP 2.11.1+ is not properly installed.")
logger.error("Please run: uv sync --frozen")
raise RuntimeError(
"OAuth 2.1 requires FastMCP 2.11.1+ with RemoteAuthProvider support. "
"Please reinstall dependencies using 'uv sync --frozen'."
)
logger.info("🔐 OAuth 2.1 enabled with automatic OAuth 2.0 fallback for legacy clients")
try:
_auth_provider = GoogleRemoteAuthProvider()
@@ -104,8 +107,7 @@ def configure_server_for_http():
logger.debug("OAuth 2.1 authentication enabled")
except Exception as e:
logger.error(f"Failed to initialize GoogleRemoteAuthProvider: {e}", exc_info=True)
else:
logger.error("OAuth 2.1 is enabled, but GoogleRemoteAuthProvider is not available.")
raise
else:
logger.info("OAuth 2.0 mode - Server will use legacy authentication.")
server.auth = None
@@ -114,7 +116,6 @@ def get_auth_provider() -> Optional[Union[GoogleWorkspaceAuthProvider, GoogleRem
"""Gets the global authentication provider instance."""
return _auth_provider
# --- Custom Routes ---
@server.custom_route("/health", methods=["GET"])
async def health_check(request: Request):
try:
@@ -187,7 +188,6 @@ async def oauth2_callback(request: Request) -> HTMLResponse:
logger.error(f"Error processing OAuth callback: {str(e)}", exc_info=True)
return create_server_error_response(str(e))
# --- Tools ---
@server.tool()
async def start_google_auth(service_name: str, user_google_email: str = USER_GOOGLE_EMAIL) -> str:
"""
@@ -221,21 +221,3 @@ async def start_google_auth(service_name: str, user_google_email: str = USER_GOO
logger.error(f"Failed to start Google authentication flow: {e}", exc_info=True)
return f"**Error:** An unexpected error occurred: {e}"
# OAuth 2.1 Discovery Endpoints - register manually when OAuth 2.1 is enabled but GoogleRemoteAuthProvider is not available
# These will only be registered if MCP_ENABLE_OAUTH21=true and we're in fallback mode
if os.getenv("MCP_ENABLE_OAUTH21", "false").lower() == "true" and not GOOGLE_REMOTE_AUTH_AVAILABLE:
from auth.oauth_common_handlers import (
handle_oauth_authorize,
handle_proxy_token_exchange,
handle_oauth_protected_resource,
handle_oauth_authorization_server,
handle_oauth_client_config,
handle_oauth_register
)
server.custom_route("/.well-known/oauth-protected-resource", methods=["GET", "OPTIONS"])(handle_oauth_protected_resource)
server.custom_route("/.well-known/oauth-authorization-server", methods=["GET", "OPTIONS"])(handle_oauth_authorization_server)
server.custom_route("/.well-known/oauth-client", methods=["GET", "OPTIONS"])(handle_oauth_client_config)
server.custom_route("/oauth2/authorize", methods=["GET", "OPTIONS"])(handle_oauth_authorize)
server.custom_route("/oauth2/token", methods=["POST", "OPTIONS"])(handle_proxy_token_exchange)
server.custom_route("/oauth2/register", methods=["POST", "OPTIONS"])(handle_oauth_register)

View File

@@ -241,10 +241,6 @@ def create_table_with_data(
}
})
# We need to calculate where cells will be after table creation
# This is approximate - better to get actual positions after creation
estimated_cells = calculate_cell_positions(index, rows, cols)
# Build text insertion requests for each cell
# Note: In practice, we'd need to get the actual document structure
# after table creation to get accurate indices

View File

@@ -416,11 +416,16 @@ async def modify_doc_text(
requests.append(create_format_text_request(format_start, format_end, bold, italic, underline, font_size, font_family))
format_details = []
if bold is not None: format_details.append(f"bold={bold}")
if italic is not None: format_details.append(f"italic={italic}")
if underline is not None: format_details.append(f"underline={underline}")
if font_size: format_details.append(f"font_size={font_size}")
if font_family: format_details.append(f"font_family={font_family}")
if bold is not None:
format_details.append(f"bold={bold}")
if italic is not None:
format_details.append(f"italic={italic}")
if underline is not None:
format_details.append(f"underline={underline}")
if font_size:
format_details.append(f"font_size={font_size}")
if font_family:
format_details.append(f"font_family={font_family}")
operations.append(f"Applied formatting ({', '.join(format_details)}) to range {format_start}-{format_end}")
@@ -954,7 +959,6 @@ async def create_table_with_data(
link = f"https://docs.google.com/document/d/{document_id}/edit"
rows = metadata.get('rows', 0)
columns = metadata.get('columns', 0)
populated_cells = metadata.get('populated_cells', 0)
return f"SUCCESS: {message}. Table: {rows}x{columns}, Index: {index}. Link: {link}"
else:

View File

@@ -74,9 +74,6 @@ class TableOperationManager:
if not fresh_tables:
return False, "Could not find table after creation", {}
# Use the last table (newly created one)
table_info = fresh_tables[-1]
# Step 3: Populate each cell with proper index refreshing
population_count = await self._populate_table_cells(
document_id, table_data, bold_headers

Binary file not shown.

View File

@@ -2,7 +2,7 @@
"dxt_version": "0.1",
"name": "workspace-mcp",
"display_name": "Google Workspace MCP",
"version": "1.2.0",
"version": "1.3.4",
"description": "Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Tasks, Chat and Custom Search through all MCP clients, AI assistants and developer tools",
"long_description": "A production-ready MCP server that integrates all major Google Workspace services with AI assistants. Includes Google PSE integration for custom web searches.",
"author": {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "workspace-mcp"
version = "1.3.3"
version = "1.3.4"
description = "Comprehensive, highly performant Google Workspace Streamable HTTP & SSE MCP Server for Calendar, Gmail, Docs, Sheets, Slides & Drive"
readme = "README.md"
keywords = [ "mcp", "google", "workspace", "llm", "ai", "claude", "model", "context", "protocol", "server"]