This commit is contained in:
Taylor Wilsdon
2025-07-04 14:56:03 -04:00
13 changed files with 985 additions and 483 deletions

257
core/comments.py Normal file
View File

@@ -0,0 +1,257 @@
"""
Core Comments Module
This module provides reusable comment management functions for Google Workspace applications.
All Google Workspace apps (Docs, Sheets, Slides) use the Drive API for comment operations.
"""
import logging
import asyncio
from typing import Dict, Any
from mcp import types
from googleapiclient.errors import HttpError
from auth.service_decorator import require_google_service
from core.server import server
from core.utils import handle_http_errors
logger = logging.getLogger(__name__)
def create_comment_tools(app_name: str, file_id_param: str):
"""
Factory function to create comment management tools for a specific Google Workspace app.
Args:
app_name: Name of the app (e.g., "document", "spreadsheet", "presentation")
file_id_param: Parameter name for the file ID (e.g., "document_id", "spreadsheet_id", "presentation_id")
Returns:
Dict containing the four comment management functions with unique names
"""
# Create unique function names based on the app type
read_func_name = f"read_{app_name}_comments"
create_func_name = f"create_{app_name}_comment"
reply_func_name = f"reply_to_{app_name}_comment"
resolve_func_name = f"resolve_{app_name}_comment"
# Create read comments function
if file_id_param == "document_id":
@server.tool()
@require_google_service("drive", "drive_read")
@handle_http_errors(read_func_name)
async def read_comments(service, user_google_email: str, document_id: str) -> str:
"""Read all comments from a Google Slide, Sheet or Doc."""
return await _read_comments_impl(service, app_name, document_id)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(create_func_name)
async def create_comment(service, user_google_email: str, document_id: str, comment_content: str) -> str:
"""Create a new comment on a Google Slide, Sheet or Doc."""
return await _create_comment_impl(service, app_name, document_id, comment_content)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(reply_func_name)
async def reply_to_comment(service, user_google_email: str, document_id: str, comment_id: str, reply_content: str) -> str:
"""Reply to a specific comment in a Google Document."""
return await _reply_to_comment_impl(service, app_name, document_id, comment_id, reply_content)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(resolve_func_name)
async def resolve_comment(service, user_google_email: str, document_id: str, comment_id: str) -> str:
"""Resolve a comment in a Google Slide, Sheet or Doc."""
return await _resolve_comment_impl(service, app_name, document_id, comment_id)
elif file_id_param == "spreadsheet_id":
@server.tool()
@require_google_service("drive", "drive_read")
@handle_http_errors(read_func_name)
async def read_comments(service, user_google_email: str, spreadsheet_id: str) -> str:
"""Read all comments from a Google Slide, Sheet or Doc."""
return await _read_comments_impl(service, app_name, spreadsheet_id)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(create_func_name)
async def create_comment(service, user_google_email: str, spreadsheet_id: str, comment_content: str) -> str:
"""Create a new comment on a Google Slide, Sheet or Doc."""
return await _create_comment_impl(service, app_name, spreadsheet_id, comment_content)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(reply_func_name)
async def reply_to_comment(service, user_google_email: str, spreadsheet_id: str, comment_id: str, reply_content: str) -> str:
"""Reply to a specific comment in a Google Slide, Sheet or Doc."""
return await _reply_to_comment_impl(service, app_name, spreadsheet_id, comment_id, reply_content)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(resolve_func_name)
async def resolve_comment(service, user_google_email: str, spreadsheet_id: str, comment_id: str) -> str:
"""Resolve a comment in a Google Slide, Sheet or Doc."""
return await _resolve_comment_impl(service, app_name, spreadsheet_id, comment_id)
elif file_id_param == "presentation_id":
@server.tool()
@require_google_service("drive", "drive_read")
@handle_http_errors(read_func_name)
async def read_comments(service, user_google_email: str, presentation_id: str) -> str:
"""Read all comments from a Google Slide, Sheet or Doc."""
return await _read_comments_impl(service, app_name, presentation_id)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(create_func_name)
async def create_comment(service, user_google_email: str, presentation_id: str, comment_content: str) -> str:
"""Create a new comment on a Google Slide, Sheet or Doc."""
return await _create_comment_impl(service, app_name, presentation_id, comment_content)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(reply_func_name)
async def reply_to_comment(service, user_google_email: str, presentation_id: str, comment_id: str, reply_content: str) -> str:
"""Reply to a specific comment in a Google Slide, Sheet or Doc."""
return await _reply_to_comment_impl(service, app_name, presentation_id, comment_id, reply_content)
@server.tool()
@require_google_service("drive", "drive_file")
@handle_http_errors(resolve_func_name)
async def resolve_comment(service, user_google_email: str, presentation_id: str, comment_id: str) -> str:
"""Resolve a comment in a Google Slide, Sheet or Doc."""
return await _resolve_comment_impl(service, app_name, presentation_id, comment_id)
# Set the proper function names for MCP registration
read_comments.__name__ = read_func_name
create_comment.__name__ = create_func_name
reply_to_comment.__name__ = reply_func_name
resolve_comment.__name__ = resolve_func_name
return {
'read_comments': read_comments,
'create_comment': create_comment,
'reply_to_comment': reply_to_comment,
'resolve_comment': resolve_comment
}
async def _read_comments_impl(service, app_name: str, file_id: str) -> str:
"""Implementation for reading comments from any Google Workspace file."""
logger.info(f"[read_{app_name}_comments] Reading comments for {app_name} {file_id}")
response = await asyncio.to_thread(
service.comments().list(
fileId=file_id,
fields="comments(id,content,author,createdTime,modifiedTime,resolved,replies(content,author,id,createdTime,modifiedTime))"
).execute
)
comments = response.get('comments', [])
if not comments:
return f"No comments found in {app_name} {file_id}"
output = [f"Found {len(comments)} comments in {app_name} {file_id}:\\n"]
for comment in comments:
author = comment.get('author', {}).get('displayName', 'Unknown')
content = comment.get('content', '')
created = comment.get('createdTime', '')
resolved = comment.get('resolved', False)
comment_id = comment.get('id', '')
status = " [RESOLVED]" if resolved else ""
output.append(f"Comment ID: {comment_id}")
output.append(f"Author: {author}")
output.append(f"Created: {created}{status}")
output.append(f"Content: {content}")
# Add replies if any
replies = comment.get('replies', [])
if replies:
output.append(f" Replies ({len(replies)}):")
for reply in replies:
reply_author = reply.get('author', {}).get('displayName', 'Unknown')
reply_content = reply.get('content', '')
reply_created = reply.get('createdTime', '')
reply_id = reply.get('id', '')
output.append(f" Reply ID: {reply_id}")
output.append(f" Author: {reply_author}")
output.append(f" Created: {reply_created}")
output.append(f" Content: {reply_content}")
output.append("") # Empty line between comments
return "\\n".join(output)
async def _create_comment_impl(service, app_name: str, file_id: str, comment_content: str) -> str:
"""Implementation for creating a comment on any Google Workspace file."""
logger.info(f"[create_{app_name}_comment] Creating comment in {app_name} {file_id}")
body = {"content": comment_content}
comment = await asyncio.to_thread(
service.comments().create(
fileId=file_id,
body=body,
fields="id,content,author,createdTime,modifiedTime"
).execute
)
comment_id = comment.get('id', '')
author = comment.get('author', {}).get('displayName', 'Unknown')
created = comment.get('createdTime', '')
return f"Comment created successfully!\\nComment ID: {comment_id}\\nAuthor: {author}\\nCreated: {created}\\nContent: {comment_content}"
async def _reply_to_comment_impl(service, app_name: str, file_id: str, comment_id: str, reply_content: str) -> str:
"""Implementation for replying to a comment on any Google Workspace file."""
logger.info(f"[reply_to_{app_name}_comment] Replying to comment {comment_id} in {app_name} {file_id}")
body = {'content': reply_content}
reply = await asyncio.to_thread(
service.replies().create(
fileId=file_id,
commentId=comment_id,
body=body,
fields="id,content,author,createdTime,modifiedTime"
).execute
)
reply_id = reply.get('id', '')
author = reply.get('author', {}).get('displayName', 'Unknown')
created = reply.get('createdTime', '')
return f"Reply posted successfully!\\nReply ID: {reply_id}\\nAuthor: {author}\\nCreated: {created}\\nContent: {reply_content}"
async def _resolve_comment_impl(service, app_name: str, file_id: str, comment_id: str) -> str:
"""Implementation for resolving a comment on any Google Workspace file."""
logger.info(f"[resolve_{app_name}_comment] Resolving comment {comment_id} in {app_name} {file_id}")
body = {
"content": "This comment has been resolved.",
"action": "resolve"
}
reply = await asyncio.to_thread(
service.replies().create(
fileId=file_id,
commentId=comment_id,
body=body,
fields="id,content,author,createdTime,modifiedTime"
).execute
)
reply_id = reply.get('id', '')
author = reply.get('author', {}).get('displayName', 'Unknown')
created = reply.get('createdTime', '')
return f"Comment {comment_id} has been resolved successfully.\\nResolve reply ID: {reply_id}\\nAuthor: {author}\\nCreated: {created}"

22
core/context.py Normal file
View File

@@ -0,0 +1,22 @@
# core/context.py
import contextvars
from typing import Optional
# Context variable to hold injected credentials for the life of a single request.
_injected_oauth_credentials = contextvars.ContextVar(
"injected_oauth_credentials", default=None
)
def get_injected_oauth_credentials():
"""
Retrieve injected OAuth credentials for the current request context.
This is called by the authentication layer to check for request-scoped credentials.
"""
return _injected_oauth_credentials.get()
def set_injected_oauth_credentials(credentials: Optional[dict]):
"""
Set or clear the injected OAuth credentials for the current request context.
This is called by the service decorator.
"""
_injected_oauth_credentials.set(credentials)

View File

@@ -11,7 +11,7 @@ from mcp import types
from mcp.server.fastmcp import FastMCP
from starlette.requests import Request
from auth.google_auth import handle_auth_callback, start_auth_flow, CONFIG_CLIENT_SECRETS_PATH
from auth.google_auth import handle_auth_callback, start_auth_flow, check_client_secrets
from auth.oauth_callback_server import get_oauth_redirect_uri, ensure_oauth_callback_available
from auth.oauth_responses import create_error_response, create_success_response, create_server_error_response
@@ -120,11 +120,10 @@ async def oauth2_callback(request: Request) -> HTMLResponse:
return create_error_response(error_message)
try:
client_secrets_path = CONFIG_CLIENT_SECRETS_PATH
if not os.path.exists(client_secrets_path):
logger.error(f"OAuth client secrets file not found at {client_secrets_path}")
# This is a server configuration error, should not happen in a deployed environment.
return HTMLResponse(content="Server Configuration Error: Client secrets not found.", status_code=500)
# Check if we have credentials available (environment variables or file)
error_message = check_client_secrets()
if error_message:
return create_server_error_response(error_message)
logger.info(f"OAuth callback: Received code (state: {state}). Attempting to exchange for tokens.")
@@ -137,7 +136,6 @@ async def oauth2_callback(request: Request) -> HTMLResponse:
# Exchange code for credentials. handle_auth_callback will save them.
# The user_id returned here is the Google-verified email.
verified_user_id, credentials = handle_auth_callback(
client_secrets_path=client_secrets_path,
scopes=SCOPES, # Ensure all necessary scopes are requested
authorization_response=str(request.url),
redirect_uri=get_oauth_redirect_uri_for_current_mode(),