Skip to content

Commit cf03ef4

Browse files
authored
Merge branch 'main' into dev
2 parents e78d29d + 2a14b50 commit cf03ef4

File tree

10 files changed

+1641
-1162
lines changed

10 files changed

+1641
-1162
lines changed

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
__pycache__/
33
*.py[cod]
44
*.so
5-
.mcp.json
6-
claude.md
75

86
# ---- Packaging ---------------------------------------------------------
97
*.egg-info/
@@ -22,6 +20,7 @@ venv/
2220

2321
# ---- Secrets -----------------------------------------------------------
2422
client_secret.json
23+
.mcp.json
2524

2625
# ---- Logs --------------------------------------------------------------
2726
mcp_server_debug.log

CLAUDE.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Common Commands
6+
7+
### Development
8+
- `uv run main.py` - Start server in stdio mode (default for MCP clients)
9+
- `uv run main.py --transport streamable-http` - Start server in HTTP mode for debugging
10+
- `uv run main.py --single-user` - Run in single-user mode (bypass session mapping)
11+
- `uv run main.py --tools gmail drive calendar` - Start with specific tools only
12+
13+
### Installation & Setup
14+
- `python install_claude.py` - Auto-install MCP server configuration in Claude Desktop
15+
- `uvx workspace-mcp` - Run directly via uvx without local installation
16+
17+
### Docker
18+
- `docker build -t workspace-mcp .`
19+
- `docker run -p 8000:8000 -v $(pwd):/app workspace-mcp --transport streamable-http`
20+
21+
## Architecture Overview
22+
23+
This is a comprehensive Google Workspace MCP server built with FastMCP. The architecture follows a modular design pattern:
24+
25+
### Core Components
26+
- **`main.py`** - Entry point with argument parsing and tool module loading
27+
- **`core/server.py`** - FastMCP server configuration with OAuth callback handling
28+
- **`core/utils.py`** - Shared utilities and credential directory management
29+
30+
### Authentication System (`auth/`)
31+
- **Service Decorator Pattern**: Uses `@require_google_service()` decorators for automatic authentication
32+
- **OAuth 2.0 Flow**: Transport-aware callback handling (stdio mode starts minimal HTTP server on port 8000)
33+
- **Service Caching**: 30-minute TTL to reduce authentication overhead
34+
- **Session Management**: Maps OAuth state to MCP session IDs for multi-user support
35+
- **Scope Management**: Centralized scope definitions in `auth/scopes.py` with predefined scope groups
36+
37+
### Service Modules
38+
Each Google service has its own module in `g{service}/` format:
39+
- `gcalendar/` - Google Calendar API integration
40+
- `gdrive/` - Google Drive API with Office format support
41+
- `gmail/` - Gmail API for email management
42+
- `gdocs/` - Google Docs API operations with full editing support
43+
- `gsheets/` - Google Sheets API with cell operations
44+
- `gforms/` - Google Forms creation and response management
45+
- `gchat/` - Google Chat/Spaces messaging
46+
- `gslides/` - Google Slides presentation management
47+
48+
### Key Patterns
49+
- **Service Injection**: The `@require_google_service(service_name, scope_group)` decorator automatically injects authenticated Google API service objects
50+
- **Multi-Service Support**: Use `@require_multiple_services()` for tools needing multiple Google services
51+
- **Error Handling**: Native Python exceptions are automatically converted to MCP errors
52+
- **Transport Modes**: Supports both stdio (for MCP clients) and streamable-http (for debugging/web interfaces)
53+
54+
### Configuration
55+
- Environment variables: `WORKSPACE_MCP_PORT` (default: 8000), `WORKSPACE_MCP_BASE_URI` (default: http://localhost)
56+
- OAuth credentials: `client_secret.json` in project root or set `GOOGLE_CLIENT_SECRETS` env var
57+
- Single-user mode: Set `MCP_SINGLE_USER_MODE=1` or use `--single-user` flag
58+
59+
### Tool Development
60+
When adding new tools:
61+
1. Use appropriate service decorator: `@require_google_service("service_name", "scope_group")`
62+
2. Service object is automatically injected as first parameter
63+
3. Return native Python objects (automatic JSON serialization)
64+
4. Follow existing naming patterns in scope groups from `auth/scopes.py`
65+
5. Add service configuration to `SERVICE_CONFIGS` in `auth/service_decorator.py`
66+
67+
## Google Docs Editing Capabilities
68+
69+
The Google Docs integration now supports comprehensive document editing through these tools:
70+
71+
### Core Text Operations
72+
- `update_doc_text` - Insert or replace text at specific positions
73+
- `find_and_replace_doc` - Find and replace text throughout the document
74+
- `format_doc_text` - Apply text formatting (bold, italic, underline, font size/family)
75+
76+
### Structural Elements
77+
- `insert_doc_elements` - Add tables, lists, or page breaks
78+
- `insert_doc_image` - Insert images from Google Drive or URLs
79+
- `update_doc_headers_footers` - Modify document headers and footers
80+
81+
### Advanced Operations
82+
- `batch_update_doc` - Execute multiple document operations atomically
83+
84+
### Helper Functions
85+
The `gdocs/docs_helpers.py` module provides utility functions for:
86+
- Building text style objects
87+
- Creating API request structures
88+
- Validating batch operations
89+
- Extracting document text
90+
- Calculating text indices
91+
92+
These tools use the Google Docs API's batchUpdate method for efficient, atomic document modifications. All editing operations require the `docs_write` scope which is already configured in the authentication system.

auth/service_decorator.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -552,18 +552,29 @@ async def get_doc_with_metadata(drive_service, docs_service, user_google_email:
552552
"""
553553

554554
def decorator(func: Callable) -> Callable:
555+
# Inspect the original function signature
556+
original_sig = inspect.signature(func)
557+
params = list(original_sig.parameters.values())
558+
559+
# Create list of service parameter names that will be injected
560+
service_param_names = [config["param_name"] for config in service_configs]
561+
562+
# Filter out the service parameters from the original signature
563+
# to create the wrapper signature that MCP will see
564+
wrapper_params = [p for p in params if p.name not in service_param_names]
565+
wrapper_sig = original_sig.replace(parameters=wrapper_params)
566+
555567
@wraps(func)
556568
async def wrapper(*args, **kwargs):
557-
# Extract user_google_email
558-
sig = inspect.signature(func)
559-
param_names = list(sig.parameters.keys())
560-
569+
# Extract user_google_email - use robust approach that works with injected services
570+
original_param_names = list(original_sig.parameters.keys())
561571
user_google_email = None
562572
if "user_google_email" in kwargs:
563573
user_google_email = kwargs["user_google_email"]
564574
else:
575+
# Look for user_google_email in positional args using original function signature
565576
try:
566-
user_email_index = param_names.index("user_google_email")
577+
user_email_index = original_param_names.index('user_google_email')
567578
if user_email_index < len(args):
568579
user_google_email = args[user_email_index]
569580
except ValueError:
@@ -605,7 +616,7 @@ async def wrapper(*args, **kwargs):
605616
user_google_email,
606617
args,
607618
kwargs,
608-
param_names,
619+
original_param_names,
609620
tool_name,
610621
service_type,
611622
)
@@ -642,6 +653,9 @@ async def wrapper(*args, **kwargs):
642653
)
643654
raise Exception(error_message)
644655

656+
# Set the wrapper's signature to the one without service parameters
657+
# This is critical for MCP compatibility
658+
wrapper.__signature__ = wrapper_sig
645659
return wrapper
646660

647661
return decorator

core/tool_tiers.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ docs:
5050
- insert_doc_elements
5151
complete:
5252
- insert_doc_image
53+
- insert_doc_image_from_drive
54+
- insert_doc_image_url
5355
- update_doc_headers_footers
5456
- batch_update_doc
5557
- inspect_doc_structure

gdocs/docs_tools.py

Lines changed: 155 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ async def create_doc(
295295
doc = await asyncio.to_thread(service.documents().create(body={'title': title}).execute)
296296
doc_id = doc.get('documentId')
297297
if content:
298-
requests = [{'insertText': {'location': {'index': 1}, 'text': content}}]
298+
requests = [create_insert_text_request(1, content)]
299299
await asyncio.to_thread(service.documents().batchUpdate(documentId=doc_id, body={'requests': requests}).execute)
300300
link = f"https://docs.google.com/document/d/{doc_id}/edit"
301301
msg = f"Created Google Doc '{title}' (ID: {doc_id}) for {user_google_email}. Link: {link}"
@@ -626,10 +626,8 @@ async def insert_doc_image(
626626
else:
627627
image_uri = image_source
628628
source_description = "URL image"
629-
630629
# Use helper to create image request
631630
requests = [create_insert_image_request(index, image_uri, width, height)]
632-
633631
await asyncio.to_thread(
634632
docs_service.documents().batchUpdate(
635633
documentId=document_id,
@@ -644,6 +642,160 @@ async def insert_doc_image(
644642
link = f"https://docs.google.com/document/d/{document_id}/edit"
645643
return f"Inserted {source_description}{size_info} at index {index} in document {document_id}. Link: {link}"
646644

645+
@server.tool()
646+
@handle_http_errors("insert_doc_image_from_drive", service_type="docs")
647+
@require_multiple_services([
648+
{"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"},
649+
{"service_type": "docs", "scopes": "docs_write", "param_name": "docs_service"}
650+
])
651+
async def insert_doc_image_from_drive(
652+
drive_service,
653+
docs_service,
654+
user_google_email: str,
655+
document_id: str,
656+
drive_file_name: str,
657+
index: int,
658+
width: int = None,
659+
height: int = None,
660+
) -> str:
661+
"""
662+
Searches for an image in Google Drive by name and inserts it into a Google Doc.
663+
Checks permissions first and provides helpful error messages if the image isn't publicly shared.
664+
665+
Args:
666+
user_google_email: User's Google email address
667+
document_id: ID of the document to update
668+
drive_file_name: Name of the image file in Google Drive (e.g., "product_roadmap_2025.png")
669+
index: Position to insert image (0-based)
670+
width: Image width in points (optional)
671+
height: Image height in points (optional)
672+
673+
Returns:
674+
str: Confirmation message with insertion details or error with instructions
675+
"""
676+
logger.info(f"[insert_doc_image_from_drive] Doc={document_id}, file={drive_file_name}, index={index}")
677+
678+
# Build search query for the specific file name
679+
escaped_name = drive_file_name.replace("'", "\\'")
680+
search_query = f"name = '{escaped_name}'"
681+
682+
# Search for the file in Drive with permission information
683+
list_params = {
684+
"q": search_query,
685+
"pageSize": 5,
686+
"fields": "files(id, name, mimeType, webViewLink, permissions, shared)",
687+
"supportsAllDrives": True,
688+
"includeItemsFromAllDrives": True,
689+
}
690+
691+
search_results = await asyncio.to_thread(
692+
drive_service.files().list(**list_params).execute
693+
)
694+
695+
files = search_results.get('files', [])
696+
if not files:
697+
return f"❌ Error: File '{drive_file_name}' not found in Google Drive"
698+
699+
# Use the first matching file
700+
file_info = files[0]
701+
file_id = file_info.get('id')
702+
file_name = file_info.get('name')
703+
mime_type = file_info.get('mimeType', '')
704+
705+
# Check if it's an image file
706+
if not mime_type.startswith('image/'):
707+
logger.warning(f"File '{drive_file_name}' has MIME type '{mime_type}' which may not be an image")
708+
709+
# Check permissions to see if file has "anyone with link" permission
710+
from gdrive.drive_helpers import check_public_link_permission
711+
permissions = file_info.get('permissions', [])
712+
has_public_link = check_public_link_permission(permissions)
713+
714+
if not has_public_link:
715+
from gdrive.drive_helpers import format_public_sharing_error
716+
return format_public_sharing_error(file_name, file_id)
717+
718+
# File has public access - proceed with insertion
719+
from gdrive.drive_helpers import get_drive_image_url
720+
image_uri = get_drive_image_url(file_id)
721+
722+
# Use helper function to create request
723+
request = create_insert_image_request(index, image_uri, width, height)
724+
requests = [request]
725+
726+
try:
727+
await asyncio.to_thread(
728+
docs_service.documents().batchUpdate(
729+
documentId=document_id,
730+
body={'requests': requests}
731+
).execute
732+
)
733+
734+
size_info = ""
735+
if width or height:
736+
size_info = f" (size: {width or 'auto'}x{height or 'auto'} points)"
737+
738+
link = f"https://docs.google.com/document/d/{document_id}/edit"
739+
return f"✅ Successfully inserted Drive image '{file_name}' (ID: {file_id}){size_info} at index {index} in document {document_id}. Link: {link}"
740+
741+
except Exception as e:
742+
error_str = str(e)
743+
if "publicly accessible" in error_str or "forbidden" in error_str.lower():
744+
return f"❌ API Error: Drive image '{file_name}' access denied despite public sharing. May need propagation time or use insert_doc_image_url with: {get_drive_image_url(file_id)}"
745+
else:
746+
# Some other error occurred
747+
return f"❌ Error inserting image '{file_name}': {e}"
748+
749+
@server.tool()
750+
@handle_http_errors("insert_doc_image_url", service_type="docs")
751+
@require_google_service("docs", "docs_write")
752+
async def insert_doc_image_url(
753+
service,
754+
user_google_email: str,
755+
document_id: str,
756+
image_url: str,
757+
index: int,
758+
width: int = None,
759+
height: int = None,
760+
) -> str:
761+
"""
762+
Inserts an image from a URL into a Google Doc.
763+
Simplified version that only works with URLs, not Drive files.
764+
765+
Args:
766+
user_google_email: User's Google email address
767+
document_id: ID of the document to update
768+
image_url: Public image URL (must start with http:// or https://)
769+
index: Position to insert image (0-based)
770+
width: Image width in points (optional)
771+
height: Image height in points (optional)
772+
773+
Returns:
774+
str: Confirmation message with insertion details
775+
"""
776+
logger.info(f"[insert_doc_image_url] Doc={document_id}, url={image_url}, index={index}")
777+
778+
if not (image_url.startswith('http://') or image_url.startswith('https://')):
779+
return "Error: image_url must be a valid HTTP/HTTPS URL"
780+
781+
# Use helper function to create request
782+
request = create_insert_image_request(index, image_url, width, height)
783+
requests = [request]
784+
785+
await asyncio.to_thread(
786+
service.documents().batchUpdate(
787+
documentId=document_id,
788+
body={'requests': requests}
789+
).execute
790+
)
791+
792+
size_info = ""
793+
if width or height:
794+
size_info = f" (size: {width or 'auto'}x{height or 'auto'} points)"
795+
796+
link = f"https://docs.google.com/document/d/{document_id}/edit"
797+
return f"Inserted image from URL{size_info} at index {index} in document {document_id}. Link: {link}"
798+
647799
@server.tool()
648800
@handle_http_errors("update_doc_headers_footers", service_type="docs")
649801
@require_google_service("docs", "docs_write")

0 commit comments

Comments
 (0)