Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 2 additions & 18 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,45 +1,29 @@
FROM python:3.11-slim

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*

# Install uv for faster dependency management
RUN pip install --no-cache-dir uv

COPY . .

# Install Python dependencies using uv sync
RUN uv sync --frozen --no-dev

# Create non-root user for security
RUN useradd --create-home --shell /bin/bash app \
&& chown -R app:app /app

# Give read and write access to the store_creds volume
RUN mkdir -p /app/store_creds \
&& chown -R app:app /app/store_creds \
&& chmod 755 /app/store_creds

USER app

# Expose port (use default of 8000 if PORT not set)
EXPOSE 8000
# Expose additional port if PORT environment variable is set to a different value
ARG PORT
EXPOSE ${PORT:-8000}

# Health check
# Health check using Python's urllib (no external dependencies needed)
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD sh -c 'curl -f http://localhost:${PORT:-8000}/health || exit 1'

CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
# Set environment variables for Python startup args
ENV TOOL_TIER=""
ENV TOOLS=""

# Use entrypoint for the base command and CMD for args
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["uv run main.py --transport streamable-http ${TOOL_TIER:+--tool-tier \"$TOOL_TIER\"} ${TOOLS:+--tools $TOOLS}"]
53 changes: 41 additions & 12 deletions auth/service_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,20 +285,44 @@ async def get_authenticated_google_service_oauth21(
return service, user_google_email


def _extract_oauth21_user_email(authenticated_user: Optional[str], func_name: str) -> str:
def _extract_oauth21_user_email(
authenticated_user: Optional[str],
func_name: str,
args: tuple = (),
kwargs: dict = {},
wrapper_sig: Optional[inspect.Signature] = None
) -> str:
"""
Extract user email for OAuth 2.1 mode.

Args:
authenticated_user: The authenticated user from context
func_name: Name of the function being decorated (for error messages)
args: Positional arguments (for external OAuth mode)
kwargs: Keyword arguments (for external OAuth mode)
wrapper_sig: Function signature (for external OAuth mode)

Returns:
User email string

Raises:
Exception: If no authenticated user found in OAuth 2.1 mode
"""
# When using external OAuth provider, authenticated_user comes from protocol-level auth
# But if protocol-level auth is disabled, we need to extract from function parameters
config = get_oauth_config()
if config.is_external_oauth21_provider() and not authenticated_user:
# External OAuth mode without protocol-level auth - extract from parameters
if wrapper_sig:
return _extract_oauth20_user_email(args, kwargs, wrapper_sig)
# Fallback: try to get from kwargs directly
if "user_google_email" in kwargs:
return kwargs["user_google_email"]
raise Exception(
f"OAuth 2.1 external provider mode requires user_google_email parameter for {func_name}"
)

# Standard OAuth 2.1 mode - requires authenticated user from context
if not authenticated_user:
raise Exception(
f"OAuth 2.1 mode requires an authenticated user for {func_name}, but none was found."
Expand Down Expand Up @@ -522,16 +546,17 @@ def decorator(func: Callable) -> Callable:
)

# Create a new signature for the wrapper that excludes the 'service' parameter.
# In OAuth 2.1 mode, also exclude 'user_google_email' since it's automatically determined.
if is_oauth21_enabled():
# Remove both 'service' and 'user_google_email' parameters
# In OAuth 2.1 mode with external provider, keep user_google_email parameter
config = get_oauth_config()
if is_oauth21_enabled() and not config.is_external_oauth21_provider():
# Standard OAuth 2.1: Remove both 'service' and 'user_google_email' parameters
filtered_params = [
p for p in params[1:]
if p.name != 'user_google_email'
]
wrapper_sig = original_sig.replace(parameters=filtered_params)
else:
# Only remove 'service' parameter for OAuth 2.0 mode
# OAuth 2.0 or External OAuth 2.1: Only remove 'service' parameter
wrapper_sig = original_sig.replace(parameters=params[1:])

@wraps(func)
Expand All @@ -546,7 +571,9 @@ async def wrapper(*args, **kwargs):

# Extract user_google_email based on OAuth mode
if is_oauth21_enabled():
user_google_email = _extract_oauth21_user_email(authenticated_user, func.__name__)
user_google_email = _extract_oauth21_user_email(
authenticated_user, func.__name__, args, kwargs, wrapper_sig
)
else:
user_google_email = _extract_oauth20_user_email(args, kwargs, wrapper_sig)

Expand Down Expand Up @@ -658,15 +685,18 @@ async def get_doc_with_metadata(drive_service, docs_service, user_google_email:
def decorator(func: Callable) -> Callable:
original_sig = inspect.signature(func)

# In OAuth 2.1 mode, remove user_google_email from the signature
if is_oauth21_enabled():
# In OAuth 2.1 mode with external provider, keep user_google_email parameter
config = get_oauth_config()
if is_oauth21_enabled() and not config.is_external_oauth21_provider():
# Standard OAuth 2.1: Remove user_google_email from signature
params = list(original_sig.parameters.values())
filtered_params = [
p for p in params
if p.name != 'user_google_email'
]
wrapper_sig = original_sig.replace(parameters=filtered_params)
else:
# OAuth 2.0 or External OAuth 2.1: Keep original signature
wrapper_sig = original_sig

@wraps(func)
Expand All @@ -677,7 +707,9 @@ async def wrapper(*args, **kwargs):

# Extract user_google_email based on OAuth mode
if is_oauth21_enabled():
user_google_email = _extract_oauth21_user_email(authenticated_user, tool_name)
user_google_email = _extract_oauth21_user_email(
authenticated_user, tool_name, args, kwargs, wrapper_sig
)
else:
# OAuth 2.0 mode: extract from arguments (original logic)
param_names = list(original_sig.parameters.keys())
Expand Down Expand Up @@ -778,6 +810,3 @@ async def wrapper(*args, **kwargs):
return wrapper

return decorator



2 changes: 2 additions & 0 deletions core/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ def configure_server_for_http():
)
# Disable protocol-level auth, expect bearer tokens in tool calls
server.auth = None
# Register legacy callback route for external OAuth mode
_ensure_legacy_callback_route()
logger.info("OAuth 2.1 enabled with EXTERNAL provider mode - protocol-level auth disabled")
logger.info("Expecting Authorization bearer tokens in tool call headers")
else:
Expand Down
8 changes: 5 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
services:
gws_mcp:
gws_mcp_original:
build: .
container_name: gws_mcp
container_name: gws_mcp_original
ports:
- "8000:8000"
environment:
- GOOGLE_MCP_CREDENTIALS_DIR=/app/store_creds
- TOOLS=sheets drive calendar
- OAUTHLIB_INSECURE_TRANSPORT=1
volumes:
- ./client_secret.json:/app/client_secret.json:ro
- store_creds:/app/store_creds:rw
env_file:
- .env

volumes:
store_creds:
store_creds:
3 changes: 3 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ def main():
safe_print(f" 📝 Log Level: {logging.getLogger().getEffectiveLevel()}")
safe_print("")

# import pdb
# pdb.set_trace()

# Set global single-user mode flag
if args.single_user:
if is_stateless_mode():
Expand Down
69 changes: 69 additions & 0 deletions sheetsTest1_list_spreadsheets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Simple MCP client to test Google Sheets tools.
Uses FastMCP's Client class to connect to the running MCP server.

Usage:
python list_spreadsheets.py <[email protected]>

Example:
python list_spreadsheets.py [email protected]
"""

import asyncio
import sys
from fastmcp import Client


async def test_sheets_tools(user_email: str):
"""Test Google Sheets tools using FastMCP Client."""

print("\n" + "="*70)
print("Google Sheets MCP Client Test")
print("="*70)
print(f"Email: {user_email}")
print(f"Server: http://localhost:8000/mcp")
print("="*70 + "\n")

# Connect to the MCP server via SSE transport
async with Client("http://localhost:8000/mcp") as client:

# Test: List spreadsheets
print("\n" + "="*70)
print("TEST: List Spreadsheets")
print("="*70)
try:
result = await client.call_tool("list_spreadsheets", {
"user_google_email": user_email
})
print("Success!")
for content in result.content:
print(content.text)
except Exception as e:
print(f"Error: {e}")
if "Authorization URL" in str(e) or "ACTION REQUIRED" in str(e):
print("\nAuthentication required. Check the server output for the authorization URL.")
return

print("\n" + "="*70)
print("Test suite completed!")
print("="*70 + "\n")


def main():
"""Main entry point."""
if len(sys.argv) < 2:
print("Error: Please provide your Google email address")
print(f"\nUsage: python {sys.argv[0]} <[email protected]>")
print("\nExample:")
print(f" python {sys.argv[0]} [email protected]")
sys.exit(1)

user_email = sys.argv[1]

# Run the async test function
asyncio.run(test_sheets_tools(user_email))


if __name__ == "__main__":
main()