Merge branch 'main' of https://github.com/taylorwilsdon/google_workspace_mcp into fix/attachment-storage-absolute-path

This commit is contained in:
Taylor Wilsdon
2026-02-15 12:25:08 -05:00
46 changed files with 3527 additions and 1043 deletions

View File

@@ -233,14 +233,18 @@ async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str:
if fn is None:
raise ValueError(f"Tool '{tool_name}' has no callable function")
logger.debug(f"[CLI] Executing tool: {tool_name} with args: {list(args.keys())}")
call_args = dict(args)
try:
logger.debug(
f"[CLI] Executing tool: {tool_name} with args: {list(call_args.keys())}"
)
# Call the tool function
if asyncio.iscoroutinefunction(fn):
result = await fn(**args)
result = await fn(**call_args)
else:
result = fn(**args)
result = fn(**call_args)
# Convert result to string if needed
if isinstance(result, str):
@@ -257,7 +261,7 @@ async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str:
return (
f"Error calling {tool_name}: {error_msg}\n\n"
f"Required parameters: {required}\n"
f"Provided parameters: {list(args.keys())}"
f"Provided parameters: {list(call_args.keys())}"
)
except Exception as e:
logger.error(f"[CLI] Error executing {tool_name}: {e}", exc_info=True)

View File

@@ -420,6 +420,7 @@ def get_auth_provider() -> Optional[GoogleProvider]:
return _auth_provider
@server.custom_route("/", methods=["GET"])
@server.custom_route("/health", methods=["GET"])
async def health_check(request: Request):
try:
@@ -482,7 +483,7 @@ async def legacy_oauth2_callback(request: Request) -> HTMLResponse:
if error_message:
return create_server_error_response(error_message)
logger.info(f"OAuth callback: Received code (state: {state}).")
logger.info("OAuth callback: Received authorization code.")
mcp_session_id = None
if hasattr(request, "state") and hasattr(request.state, "session_id"):

View File

@@ -38,6 +38,7 @@ drive:
- remove_drive_permission
- transfer_drive_ownership
- batch_share_drive_file
- set_drive_file_permissions
complete:
- get_drive_file_permissions
- check_drive_file_public_access
@@ -85,6 +86,7 @@ sheets:
extended:
- list_spreadsheets
- get_spreadsheet_info
- format_sheet_range
complete:
- create_sheet
- read_spreadsheet_comments

View File

@@ -7,6 +7,7 @@ import ssl
import asyncio
import functools
from pathlib import Path
from typing import List, Optional
from googleapiclient.errors import HttpError
@@ -29,6 +30,135 @@ class UserInputError(Exception):
pass
# Directories from which local file reads are allowed.
# The user's home directory is the default safe base.
# Override via ALLOWED_FILE_DIRS env var (os.pathsep-separated paths).
_ALLOWED_FILE_DIRS_ENV = "ALLOWED_FILE_DIRS"
def _get_allowed_file_dirs() -> list[Path]:
"""Return the list of directories from which local file access is permitted."""
env_val = os.environ.get(_ALLOWED_FILE_DIRS_ENV)
if env_val:
return [
Path(p).expanduser().resolve()
for p in env_val.split(os.pathsep)
if p.strip()
]
home = Path.home()
return [home] if home else []
def validate_file_path(file_path: str) -> Path:
"""
Validate that a file path is safe to read from the server filesystem.
Resolves the path canonically (following symlinks), then verifies it falls
within one of the allowed base directories. Rejects paths to sensitive
system locations regardless of allowlist.
Args:
file_path: The raw file path string to validate.
Returns:
Path: The resolved, validated Path object.
Raises:
ValueError: If the path is outside allowed directories or targets
a sensitive location.
"""
resolved = Path(file_path).resolve()
if not resolved.exists():
raise FileNotFoundError(f"Path does not exist: {resolved}")
# Block sensitive file patterns regardless of allowlist
resolved_str = str(resolved)
file_name = resolved.name.lower()
# Block .env files and variants (.env, .env.local, .env.production, etc.)
if file_name == ".env" or file_name.startswith(".env."):
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
".env files may contain secrets and cannot be read, uploaded, or attached."
)
# Block well-known sensitive system paths (including macOS /private variants)
sensitive_prefixes = (
"/proc",
"/sys",
"/dev",
"/etc/shadow",
"/etc/passwd",
"/private/etc/shadow",
"/private/etc/passwd",
)
for prefix in sensitive_prefixes:
if resolved_str == prefix or resolved_str.startswith(prefix + "/"):
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
"path is in a restricted system location."
)
# Block sensitive directories that commonly contain credentials/keys
sensitive_dirs = (
".ssh",
".aws",
".kube",
".gnupg",
".config/gcloud",
)
for sensitive_dir in sensitive_dirs:
home = Path.home()
blocked = home / sensitive_dir
if resolved == blocked or str(resolved).startswith(str(blocked) + "/"):
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
"path is in a directory that commonly contains secrets or credentials."
)
# Block other credential/secret file patterns
sensitive_names = {
".credentials",
".credentials.json",
"credentials.json",
"client_secret.json",
"client_secrets.json",
"service_account.json",
"service-account.json",
".npmrc",
".pypirc",
".netrc",
".git-credentials",
".docker/config.json",
}
if file_name in sensitive_names:
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
"this file commonly contains secrets or credentials."
)
allowed_dirs = _get_allowed_file_dirs()
if not allowed_dirs:
raise ValueError(
"No allowed file directories configured. "
"Set the ALLOWED_FILE_DIRS environment variable or ensure a home directory exists."
)
for allowed in allowed_dirs:
try:
resolved.relative_to(allowed)
return resolved
except ValueError:
continue
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
f"path is outside permitted directories ({', '.join(str(d) for d in allowed_dirs)}). "
"Set ALLOWED_FILE_DIRS to adjust."
)
def check_credentials_directory_permissions(credentials_dir: str = None) -> None:
"""
Check if the service has appropriate permissions to create and write to the .credentials directory.