2025-04-27 14:30:11 -04:00
|
|
|
import logging
|
2025-10-05 18:00:10 -04:00
|
|
|
from typing import List, Optional
|
2025-06-14 13:15:36 -04:00
|
|
|
from importlib import metadata
|
2025-05-06 09:36:48 -04:00
|
|
|
|
2025-11-29 15:06:57 +01:00
|
|
|
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
2025-08-02 18:55:35 -04:00
|
|
|
from starlette.applications import Starlette
|
2025-05-22 17:02:00 -04:00
|
|
|
from starlette.requests import Request
|
2025-08-02 09:52:16 -04:00
|
|
|
from starlette.middleware import Middleware
|
2025-05-06 09:36:48 -04:00
|
|
|
|
2025-08-05 14:34:11 -04:00
|
|
|
from fastmcp import FastMCP
|
2025-10-05 18:00:10 -04:00
|
|
|
from fastmcp.server.auth.providers.google import GoogleProvider
|
2025-08-05 14:34:11 -04:00
|
|
|
|
|
|
|
|
from auth.oauth21_session_store import get_oauth21_session_store, set_auth_provider
|
|
|
|
|
from auth.google_auth import handle_auth_callback, start_auth_flow, check_client_secrets
|
2025-08-02 18:55:35 -04:00
|
|
|
from auth.mcp_session_middleware import MCPSessionMiddleware
|
2025-12-13 13:49:28 -08:00
|
|
|
from auth.oauth_responses import (
|
|
|
|
|
create_error_response,
|
|
|
|
|
create_success_response,
|
|
|
|
|
create_server_error_response,
|
|
|
|
|
)
|
2025-08-05 10:22:01 -04:00
|
|
|
from auth.auth_info_middleware import AuthInfoMiddleware
|
2025-12-13 13:49:28 -08:00
|
|
|
from auth.scopes import SCOPES, get_current_scopes # noqa
|
2025-08-03 10:30:04 -04:00
|
|
|
from core.config import (
|
|
|
|
|
USER_GOOGLE_EMAIL,
|
|
|
|
|
get_transport_mode,
|
|
|
|
|
set_transport_mode as _set_transport_mode,
|
|
|
|
|
get_oauth_redirect_uri as get_oauth_redirect_uri_for_current_mode,
|
2025-05-13 12:36:53 -04:00
|
|
|
)
|
|
|
|
|
|
2025-04-27 14:30:11 -04:00
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2025-10-05 18:00:10 -04:00
|
|
|
_auth_provider: Optional[GoogleProvider] = None
|
|
|
|
|
_legacy_callback_registered = False
|
2025-08-02 09:52:16 -04:00
|
|
|
|
2025-08-02 15:40:23 -04:00
|
|
|
session_middleware = Middleware(MCPSessionMiddleware)
|
|
|
|
|
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-08-08 16:43:33 -04:00
|
|
|
# Custom FastMCP that adds secure middleware stack for OAuth 2.1
|
|
|
|
|
class SecureFastMCP(FastMCP):
|
2025-08-02 09:52:16 -04:00
|
|
|
def streamable_http_app(self) -> "Starlette":
|
2025-08-08 16:43:33 -04:00
|
|
|
"""Override to add secure middleware stack for OAuth 2.1."""
|
2025-08-02 09:52:16 -04:00
|
|
|
app = super().streamable_http_app()
|
2025-08-09 10:46:31 -04:00
|
|
|
|
2025-08-08 16:43:33 -04:00
|
|
|
# Add middleware in order (first added = outermost layer)
|
2025-08-09 11:44:12 -04:00
|
|
|
# Session Management - extracts session info for MCP context
|
|
|
|
|
app.user_middleware.insert(0, session_middleware)
|
2025-08-09 10:46:31 -04:00
|
|
|
|
2025-08-02 09:52:16 -04:00
|
|
|
# Rebuild middleware stack
|
|
|
|
|
app.middleware_stack = app.build_middleware_stack()
|
2025-08-09 11:44:12 -04:00
|
|
|
logger.info("Added middleware stack: Session Management")
|
2025-08-02 09:52:16 -04:00
|
|
|
return app
|
|
|
|
|
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-08-08 16:43:33 -04:00
|
|
|
server = SecureFastMCP(
|
2025-08-09 10:46:31 -04:00
|
|
|
name="google_workspace",
|
2025-08-05 14:34:11 -04:00
|
|
|
auth=None,
|
2025-05-11 10:07:37 -04:00
|
|
|
)
|
|
|
|
|
|
2025-08-05 10:22:01 -04:00
|
|
|
# Add the AuthInfo middleware to inject authentication into FastMCP context
|
|
|
|
|
auth_info_middleware = AuthInfoMiddleware()
|
|
|
|
|
server.add_middleware(auth_info_middleware)
|
|
|
|
|
|
2025-08-03 12:00:41 -04:00
|
|
|
|
2025-06-07 16:00:55 -04:00
|
|
|
def set_transport_mode(mode: str):
|
2025-08-05 14:34:11 -04:00
|
|
|
"""Sets the transport mode for the server."""
|
2025-08-03 10:30:04 -04:00
|
|
|
_set_transport_mode(mode)
|
2025-09-09 11:09:58 -04:00
|
|
|
logger.info(f"Transport: {mode}")
|
2025-06-07 16:00:55 -04:00
|
|
|
|
2025-10-05 18:00:10 -04:00
|
|
|
|
|
|
|
|
def _ensure_legacy_callback_route() -> None:
|
|
|
|
|
global _legacy_callback_registered
|
|
|
|
|
if _legacy_callback_registered:
|
|
|
|
|
return
|
|
|
|
|
server.custom_route("/oauth2callback", methods=["GET"])(legacy_oauth2_callback)
|
|
|
|
|
_legacy_callback_registered = True
|
|
|
|
|
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-08-05 14:34:11 -04:00
|
|
|
def configure_server_for_http():
|
|
|
|
|
"""
|
|
|
|
|
Configures the authentication provider for HTTP transport.
|
|
|
|
|
This must be called BEFORE server.run().
|
|
|
|
|
"""
|
2025-08-02 14:32:42 -04:00
|
|
|
global _auth_provider
|
2025-08-09 10:46:31 -04:00
|
|
|
|
2025-08-05 14:34:11 -04:00
|
|
|
transport_mode = get_transport_mode()
|
2025-08-02 14:32:42 -04:00
|
|
|
|
2025-08-05 14:34:11 -04:00
|
|
|
if transport_mode != "streamable-http":
|
|
|
|
|
return
|
2025-08-02 09:52:16 -04:00
|
|
|
|
2025-08-09 10:46:31 -04:00
|
|
|
# Use centralized OAuth configuration
|
|
|
|
|
from auth.oauth_config import get_oauth_config
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-08-09 10:46:31 -04:00
|
|
|
config = get_oauth_config()
|
2025-08-12 08:45:24 -04:00
|
|
|
|
2025-08-09 10:46:31 -04:00
|
|
|
# Check if OAuth 2.1 is enabled via centralized config
|
|
|
|
|
oauth21_enabled = config.is_oauth21_enabled()
|
2025-08-02 09:52:16 -04:00
|
|
|
|
2025-08-05 14:34:11 -04:00
|
|
|
if oauth21_enabled:
|
2025-08-09 10:46:31 -04:00
|
|
|
if not config.is_configured():
|
2025-09-09 11:13:30 -04:00
|
|
|
logger.warning("OAuth 2.1 enabled but OAuth credentials not configured")
|
2025-08-05 14:34:11 -04:00
|
|
|
return
|
2025-08-03 12:15:44 -04:00
|
|
|
|
2025-08-12 08:45:24 -04:00
|
|
|
try:
|
2025-10-05 18:00:10 -04:00
|
|
|
required_scopes: List[str] = sorted(get_current_scopes())
|
2025-10-24 15:43:29 +03:00
|
|
|
|
|
|
|
|
# Check if external OAuth provider is configured
|
|
|
|
|
if config.is_external_oauth21_provider():
|
|
|
|
|
# External OAuth mode: use custom provider that handles ya29.* access tokens
|
|
|
|
|
from auth.external_oauth_provider import ExternalOAuthProvider
|
|
|
|
|
|
|
|
|
|
provider = ExternalOAuthProvider(
|
|
|
|
|
client_id=config.client_id,
|
|
|
|
|
client_secret=config.client_secret,
|
|
|
|
|
base_url=config.get_oauth_base_url(),
|
|
|
|
|
redirect_path=config.redirect_path,
|
|
|
|
|
required_scopes=required_scopes,
|
|
|
|
|
)
|
|
|
|
|
# Disable protocol-level auth, expect bearer tokens in tool calls
|
|
|
|
|
server.auth = None
|
2025-12-13 13:49:28 -08:00
|
|
|
logger.info(
|
|
|
|
|
"OAuth 2.1 enabled with EXTERNAL provider mode - protocol-level auth disabled"
|
|
|
|
|
)
|
|
|
|
|
logger.info(
|
|
|
|
|
"Expecting Authorization bearer tokens in tool call headers"
|
|
|
|
|
)
|
2025-10-24 15:43:29 +03:00
|
|
|
else:
|
|
|
|
|
# Standard OAuth 2.1 mode: use FastMCP's GoogleProvider
|
|
|
|
|
provider = GoogleProvider(
|
|
|
|
|
client_id=config.client_id,
|
|
|
|
|
client_secret=config.client_secret,
|
|
|
|
|
base_url=config.get_oauth_base_url(),
|
|
|
|
|
redirect_path=config.redirect_path,
|
|
|
|
|
required_scopes=required_scopes,
|
|
|
|
|
)
|
|
|
|
|
# Enable protocol-level auth
|
|
|
|
|
server.auth = provider
|
2025-12-13 13:49:28 -08:00
|
|
|
logger.info(
|
|
|
|
|
"OAuth 2.1 enabled using FastMCP GoogleProvider with protocol-level auth"
|
|
|
|
|
)
|
2025-10-24 15:43:29 +03:00
|
|
|
|
|
|
|
|
# Always set auth provider for token validation in middleware
|
2025-10-05 18:00:10 -04:00
|
|
|
set_auth_provider(provider)
|
|
|
|
|
_auth_provider = provider
|
|
|
|
|
except Exception as exc:
|
2025-12-13 13:49:28 -08:00
|
|
|
logger.error(
|
|
|
|
|
"Failed to initialize FastMCP GoogleProvider: %s", exc, exc_info=True
|
|
|
|
|
)
|
2025-08-12 08:45:24 -04:00
|
|
|
raise
|
2025-08-05 14:34:11 -04:00
|
|
|
else:
|
2025-08-09 10:46:31 -04:00
|
|
|
logger.info("OAuth 2.0 mode - Server will use legacy authentication.")
|
2025-08-05 14:34:11 -04:00
|
|
|
server.auth = None
|
2025-10-05 18:00:10 -04:00
|
|
|
_auth_provider = None
|
|
|
|
|
set_auth_provider(None)
|
|
|
|
|
_ensure_legacy_callback_route()
|
|
|
|
|
|
2025-07-19 19:33:55 -04:00
|
|
|
|
2025-10-05 18:00:10 -04:00
|
|
|
def get_auth_provider() -> Optional[GoogleProvider]:
|
2025-08-05 14:34:11 -04:00
|
|
|
"""Gets the global authentication provider instance."""
|
2025-08-02 14:32:42 -04:00
|
|
|
return _auth_provider
|
2025-07-19 19:33:55 -04:00
|
|
|
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-06-07 18:49:54 -04:00
|
|
|
@server.custom_route("/health", methods=["GET"])
|
2025-06-08 14:23:28 -04:00
|
|
|
async def health_check(request: Request):
|
2025-06-14 13:15:36 -04:00
|
|
|
try:
|
2025-06-14 13:32:30 -04:00
|
|
|
version = metadata.version("workspace-mcp")
|
2025-06-14 13:15:36 -04:00
|
|
|
except metadata.PackageNotFoundError:
|
|
|
|
|
version = "dev"
|
2025-12-13 13:49:28 -08:00
|
|
|
return JSONResponse(
|
|
|
|
|
{
|
|
|
|
|
"status": "healthy",
|
|
|
|
|
"service": "workspace-mcp",
|
|
|
|
|
"version": version,
|
|
|
|
|
"transport": get_transport_mode(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2025-06-07 18:49:54 -04:00
|
|
|
|
2025-11-29 15:06:57 +01:00
|
|
|
@server.custom_route("/attachments/{file_id}", methods=["GET"])
|
|
|
|
|
async def serve_attachment(file_id: str, request: Request):
|
|
|
|
|
"""Serve a stored attachment file."""
|
|
|
|
|
from core.attachment_storage import get_attachment_storage
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-11-29 15:06:57 +01:00
|
|
|
storage = get_attachment_storage()
|
|
|
|
|
metadata = storage.get_attachment_metadata(file_id)
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-11-29 15:06:57 +01:00
|
|
|
if not metadata:
|
|
|
|
|
return JSONResponse(
|
2025-12-13 13:49:28 -08:00
|
|
|
{"error": "Attachment not found or expired"}, status_code=404
|
2025-11-29 15:06:57 +01:00
|
|
|
)
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-11-29 15:06:57 +01:00
|
|
|
file_path = storage.get_attachment_path(file_id)
|
|
|
|
|
if not file_path:
|
2025-12-13 13:49:28 -08:00
|
|
|
return JSONResponse({"error": "Attachment file not found"}, status_code=404)
|
|
|
|
|
|
2025-11-29 15:06:57 +01:00
|
|
|
return FileResponse(
|
|
|
|
|
path=str(file_path),
|
|
|
|
|
filename=metadata["filename"],
|
2025-12-13 13:49:28 -08:00
|
|
|
media_type=metadata["mime_type"],
|
2025-11-29 15:06:57 +01:00
|
|
|
)
|
|
|
|
|
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-10-05 18:00:10 -04:00
|
|
|
async def legacy_oauth2_callback(request: Request) -> HTMLResponse:
|
2025-05-11 15:37:44 -04:00
|
|
|
state = request.query_params.get("state")
|
|
|
|
|
code = request.query_params.get("code")
|
|
|
|
|
error = request.query_params.get("error")
|
|
|
|
|
|
|
|
|
|
if error:
|
2025-12-13 13:49:28 -08:00
|
|
|
msg = (
|
|
|
|
|
f"Authentication failed: Google returned an error: {error}. State: {state}."
|
|
|
|
|
)
|
2025-08-05 14:34:11 -04:00
|
|
|
logger.error(msg)
|
|
|
|
|
return create_error_response(msg)
|
2025-05-11 15:37:44 -04:00
|
|
|
|
|
|
|
|
if not code:
|
2025-08-05 14:34:11 -04:00
|
|
|
msg = "Authentication failed: No authorization code received from Google."
|
|
|
|
|
logger.error(msg)
|
|
|
|
|
return create_error_response(msg)
|
2025-05-11 10:07:37 -04:00
|
|
|
|
|
|
|
|
try:
|
2025-06-28 12:56:43 -07:00
|
|
|
error_message = check_client_secrets()
|
|
|
|
|
if error_message:
|
|
|
|
|
return create_server_error_response(error_message)
|
2025-05-11 15:37:44 -04:00
|
|
|
|
2025-08-05 14:34:11 -04:00
|
|
|
logger.info(f"OAuth callback: Received code (state: {state}).")
|
2025-05-13 12:36:53 -04:00
|
|
|
|
2025-09-28 16:08:41 -04:00
|
|
|
mcp_session_id = None
|
2025-12-13 13:49:28 -08:00
|
|
|
if hasattr(request, "state") and hasattr(request.state, "session_id"):
|
2025-09-28 16:08:41 -04:00
|
|
|
mcp_session_id = request.state.session_id
|
|
|
|
|
|
2025-05-11 17:15:05 -04:00
|
|
|
verified_user_id, credentials = handle_auth_callback(
|
2025-08-24 14:00:21 -04:00
|
|
|
scopes=get_current_scopes(),
|
2025-05-11 17:15:05 -04:00
|
|
|
authorization_response=str(request.url),
|
2025-06-07 16:00:55 -04:00
|
|
|
redirect_uri=get_oauth_redirect_uri_for_current_mode(),
|
2025-12-13 13:49:28 -08:00
|
|
|
session_id=mcp_session_id,
|
2025-05-11 10:07:37 -04:00
|
|
|
)
|
2025-05-13 12:36:53 -04:00
|
|
|
|
2025-12-13 13:49:28 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"OAuth callback: Successfully authenticated user: {verified_user_id}."
|
|
|
|
|
)
|
2025-08-02 14:32:42 -04:00
|
|
|
|
2025-08-02 10:38:11 -04:00
|
|
|
try:
|
|
|
|
|
store = get_oauth21_session_store()
|
2025-08-05 14:34:11 -04:00
|
|
|
|
2025-08-02 10:38:11 -04:00
|
|
|
store.store_session(
|
|
|
|
|
user_email=verified_user_id,
|
|
|
|
|
access_token=credentials.token,
|
|
|
|
|
refresh_token=credentials.refresh_token,
|
|
|
|
|
token_uri=credentials.token_uri,
|
|
|
|
|
client_id=credentials.client_id,
|
|
|
|
|
client_secret=credentials.client_secret,
|
|
|
|
|
scopes=credentials.scopes,
|
|
|
|
|
expiry=credentials.expiry,
|
2025-08-05 14:34:11 -04:00
|
|
|
session_id=f"google-{state}",
|
|
|
|
|
mcp_session_id=mcp_session_id,
|
2025-08-02 10:38:11 -04:00
|
|
|
)
|
2025-12-13 13:49:28 -08:00
|
|
|
logger.info(
|
|
|
|
|
f"Stored Google credentials in OAuth 2.1 session store for {verified_user_id}"
|
|
|
|
|
)
|
2025-08-02 10:38:11 -04:00
|
|
|
except Exception as e:
|
2025-08-05 14:34:11 -04:00
|
|
|
logger.error(f"Failed to store credentials in OAuth 2.1 store: {e}")
|
2025-05-13 12:36:53 -04:00
|
|
|
|
2025-06-07 16:16:48 -04:00
|
|
|
return create_success_response(verified_user_id)
|
2025-05-11 10:07:37 -04:00
|
|
|
except Exception as e:
|
2025-08-05 14:34:11 -04:00
|
|
|
logger.error(f"Error processing OAuth callback: {str(e)}", exc_info=True)
|
2025-06-07 16:16:48 -04:00
|
|
|
return create_server_error_response(str(e))
|
2025-05-20 14:51:10 -04:00
|
|
|
|
2025-12-13 13:49:28 -08:00
|
|
|
|
2025-05-20 14:51:10 -04:00
|
|
|
@server.tool()
|
2025-12-13 13:49:28 -08:00
|
|
|
async def start_google_auth(
|
|
|
|
|
service_name: str, user_google_email: str = USER_GOOGLE_EMAIL
|
|
|
|
|
) -> str:
|
2025-08-10 17:40:27 -04:00
|
|
|
"""
|
|
|
|
|
Manually initiate Google OAuth authentication flow.
|
2025-08-12 08:45:24 -04:00
|
|
|
|
|
|
|
|
NOTE: This tool should typically NOT be called directly. The authentication system
|
2025-08-10 17:40:27 -04:00
|
|
|
automatically handles credential checks and prompts for authentication when needed.
|
|
|
|
|
Only use this tool if:
|
|
|
|
|
1. You need to re-authenticate with different credentials
|
|
|
|
|
2. You want to proactively authenticate before using other tools
|
|
|
|
|
3. The automatic authentication flow failed and you need to retry
|
2025-08-12 08:45:24 -04:00
|
|
|
|
|
|
|
|
In most cases, simply try calling the Google Workspace tool you need - it will
|
2025-08-10 17:40:27 -04:00
|
|
|
automatically handle authentication if required.
|
|
|
|
|
"""
|
2025-08-05 14:34:11 -04:00
|
|
|
if not user_google_email:
|
|
|
|
|
raise ValueError("user_google_email must be provided.")
|
2025-08-03 12:15:44 -04:00
|
|
|
|
2025-08-05 14:34:11 -04:00
|
|
|
error_message = check_client_secrets()
|
|
|
|
|
if error_message:
|
|
|
|
|
return f"**Authentication Error:** {error_message}"
|
2025-08-02 15:40:23 -04:00
|
|
|
|
2025-08-05 14:34:11 -04:00
|
|
|
try:
|
2025-08-07 15:58:22 -04:00
|
|
|
auth_message = await start_auth_flow(
|
|
|
|
|
user_google_email=user_google_email,
|
|
|
|
|
service_name=service_name,
|
2025-12-13 13:49:28 -08:00
|
|
|
redirect_uri=get_oauth_redirect_uri_for_current_mode(),
|
2025-08-05 14:34:11 -04:00
|
|
|
)
|
2025-08-07 15:58:22 -04:00
|
|
|
return auth_message
|
2025-08-05 14:34:11 -04:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to start Google authentication flow: {e}", exc_info=True)
|
|
|
|
|
return f"**Error:** An unexpected error occurred: {e}"
|