Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into feat/555-auto-reply-headers

This commit is contained in:
Taylor Wilsdon
2026-03-17 14:33:41 -04:00
6 changed files with 110 additions and 13 deletions

View File

@@ -26,8 +26,7 @@ def create_error_response(error_message: str, status_code: int = 400) -> HTMLRes
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;">
<h2 style="color: #d32f2f;">Authentication Error</h2> <h2 style="color: #d32f2f;">Authentication Error</h2>
<p>{error_message}</p> <p>{error_message}</p>
<p>Please ensure you grant the requested permissions. You can close this window and try again.</p> <p>Please ensure you grant the requested permissions. You can close this tab and try again.</p>
<script>setTimeout(function() {{ window.close(); }}, 10000);</script>
</body> </body>
</html> </html>
""" """
@@ -176,9 +175,17 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo
}} }}
</style> </style>
<script> <script>
setTimeout(function() {{ function tryClose() {{
window.close(); window.close();
}}, 10000); // If window.close() was blocked by the browser, update the UI
setTimeout(function() {{
var btn = document.querySelector('.button');
if (btn) btn.textContent = 'You can close this tab manually';
var ac = document.querySelector('.auto-close');
if (ac) ac.style.display = 'none';
}}, 500);
}}
setTimeout(tryClose, 10000);
</script> </script>
</head> </head>
<body> <body>
@@ -189,10 +196,10 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo
You've been authenticated as <span class="user-id">{user_display}</span> You've been authenticated as <span class="user-id">{user_display}</span>
</div> </div>
<div class="message"> <div class="message">
Your credentials have been securely saved. You can now close this window and retry your original command. Your credentials have been securely saved. You can now close this tab and retry your original command.
</div> </div>
<button class="button" onclick="window.close()">Close Window</button> <button class="button" onclick="tryClose()">Close Tab</button>
<div class="auto-close">This window will close automatically in 10 seconds</div> <div class="auto-close">This tab will close automatically in 10 seconds</div>
</div> </div>
</body> </body>
</html>""" </html>"""
@@ -215,8 +222,7 @@ def create_server_error_response(error_detail: str) -> HTMLResponse:
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;">
<h2 style="color: #d32f2f;">Authentication Processing Error</h2> <h2 style="color: #d32f2f;">Authentication Processing Error</h2>
<p>An unexpected error occurred while processing your authentication: {error_detail}</p> <p>An unexpected error occurred while processing your authentication: {error_detail}</p>
<p>Please try again. You can close this window.</p> <p>Please try again. You can close this tab.</p>
<script>setTimeout(function() {{ window.close(); }}, 10000);</script>
</body> </body>
</html> </html>
""" """

View File

@@ -172,6 +172,21 @@ def _preserve_existing_fields(
event_body[field_name] = new_value event_body[field_name] = new_value
def _get_meeting_link(item: Dict[str, Any]) -> str:
"""Extract video meeting link from event conference data or hangoutLink."""
conference_data = item.get("conferenceData")
if conference_data and "entryPoints" in conference_data:
for entry_point in conference_data["entryPoints"]:
if entry_point.get("entryPointType") == "video":
uri = entry_point.get("uri", "")
if uri:
return uri
hangout_link = item.get("hangoutLink", "")
if hangout_link:
return hangout_link
return ""
def _format_attendee_details( def _format_attendee_details(
attendees: List[Dict[str, Any]], indent: str = " " attendees: List[Dict[str, Any]], indent: str = " "
) -> str: ) -> str:
@@ -448,6 +463,8 @@ async def get_events(
) )
attendee_details_str = _format_attendee_details(attendees, indent=" ") attendee_details_str = _format_attendee_details(attendees, indent=" ")
meeting_link = _get_meeting_link(item)
event_details = ( event_details = (
f"Event Details:\n" f"Event Details:\n"
f"- Title: {summary}\n" f"- Title: {summary}\n"
@@ -456,6 +473,10 @@ async def get_events(
f"- Description: {description}\n" f"- Description: {description}\n"
f"- Location: {location}\n" f"- Location: {location}\n"
f"- Color ID: {color_id}\n" f"- Color ID: {color_id}\n"
)
if meeting_link:
event_details += f"- Meeting Link: {meeting_link}\n"
event_details += (
f"- Attendees: {attendee_emails}\n" f"- Attendees: {attendee_emails}\n"
f"- Attendee Details: {attendee_details_str}\n" f"- Attendee Details: {attendee_details_str}\n"
) )
@@ -494,10 +515,16 @@ async def get_events(
) )
attendee_details_str = _format_attendee_details(attendees, indent=" ") attendee_details_str = _format_attendee_details(attendees, indent=" ")
meeting_link = _get_meeting_link(item)
event_detail_parts = ( event_detail_parts = (
f'- "{summary}" (Starts: {start_time}, Ends: {end_time})\n' f'- "{summary}" (Starts: {start_time}, Ends: {end_time})\n'
f" Description: {description}\n" f" Description: {description}\n"
f" Location: {location}\n" f" Location: {location}\n"
)
if meeting_link:
event_detail_parts += f" Meeting Link: {meeting_link}\n"
event_detail_parts += (
f" Attendees: {attendee_emails}\n" f" Attendees: {attendee_emails}\n"
f" Attendee Details: {attendee_details_str}\n" f" Attendee Details: {attendee_details_str}\n"
) )
@@ -513,9 +540,12 @@ async def get_events(
event_details_list.append(event_detail_parts) event_details_list.append(event_detail_parts)
else: else:
# Basic output format # Basic output format
event_details_list.append( meeting_link = _get_meeting_link(item)
f'- "{summary}" (Starts: {start_time}, Ends: {end_time}) ID: {item_event_id} | Link: {link}' basic_line = f'- "{summary}" (Starts: {start_time}, Ends: {end_time})'
) if meeting_link:
basic_line += f" Meeting: {meeting_link}"
basic_line += f" ID: {item_event_id} | Link: {link}"
event_details_list.append(basic_line)
if event_id: if event_id:
# Single event basic output # Single event basic output

View File

@@ -1463,6 +1463,7 @@ async def update_paragraph_style(
indent_end: float = None, indent_end: float = None,
space_above: float = None, space_above: float = None,
space_below: float = None, space_below: float = None,
named_style_type: str = None,
list_type: str = None, list_type: str = None,
list_nesting_level: int = None, list_nesting_level: int = None,
) -> str: ) -> str:
@@ -1488,6 +1489,8 @@ async def update_paragraph_style(
indent_end: Right/end indent in points indent_end: Right/end indent in points
space_above: Space above paragraph in points (e.g., 12 for one line) space_above: Space above paragraph in points (e.g., 12 for one line)
space_below: Space below paragraph in points space_below: Space below paragraph in points
named_style_type: Direct named style type - 'NORMAL_TEXT', 'TITLE', 'SUBTITLE',
'HEADING_1' through 'HEADING_6'. Mutually exclusive with heading_level.
list_type: Create a list from existing paragraphs ('UNORDERED' for bullets, 'ORDERED' for numbers) list_type: Create a list from existing paragraphs ('UNORDERED' for bullets, 'ORDERED' for numbers)
list_nesting_level: Nesting level for lists (0-8, where 0 is top level, default is 0) list_nesting_level: Nesting level for lists (0-8, where 0 is top level, default is 0)
Use higher levels for nested/indented list items Use higher levels for nested/indented list items
@@ -1546,12 +1549,30 @@ async def update_paragraph_style(
if list_nesting_level < 0 or list_nesting_level > 8: if list_nesting_level < 0 or list_nesting_level > 8:
return "Error: list_nesting_level must be between 0 and 8" return "Error: list_nesting_level must be between 0 and 8"
# Validate named_style_type
if named_style_type is not None and heading_level is not None:
return "Error: heading_level and named_style_type are mutually exclusive; provide only one"
if named_style_type is not None:
valid_styles = [
"NORMAL_TEXT", "TITLE", "SUBTITLE",
"HEADING_1", "HEADING_2", "HEADING_3",
"HEADING_4", "HEADING_5", "HEADING_6",
]
if named_style_type not in valid_styles:
return f"Error: Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(valid_styles)}"
# Build paragraph style object # Build paragraph style object
paragraph_style = {} paragraph_style = {}
fields = [] fields = []
# Handle named_style_type (direct named style)
if named_style_type is not None:
paragraph_style["namedStyleType"] = named_style_type
fields.append("namedStyleType")
# Handle heading level (named style) # Handle heading level (named style)
if heading_level is not None: elif heading_level is not None:
if heading_level < 0 or heading_level > 6: if heading_level < 0 or heading_level > 6:
return "Error: heading_level must be between 0 (normal text) and 6" return "Error: heading_level must be between 0 (normal text) and 6"
if heading_level == 0: if heading_level == 0:

View File

@@ -475,6 +475,7 @@ class BatchOperationManager:
"indent_end", "indent_end",
"space_above", "space_above",
"space_below", "space_below",
"named_style_type",
], ],
"description": "Apply paragraph-level styling (headings, alignment, spacing, indentation)", "description": "Apply paragraph-level styling (headings, alignment, spacing, indentation)",
}, },

View File

@@ -316,6 +316,12 @@ class ValidationManager:
"At least one paragraph style parameter must be provided (heading_level, alignment, line_spacing, indent_first_line, indent_start, indent_end, space_above, space_below, or named_style_type)", "At least one paragraph style parameter must be provided (heading_level, alignment, line_spacing, indent_first_line, indent_start, indent_end, space_above, space_below, or named_style_type)",
) )
if heading_level is not None and named_style_type is not None:
return (
False,
"heading_level and named_style_type are mutually exclusive; provide only one",
)
if named_style_type is not None: if named_style_type is not None:
valid_styles = [ valid_styles = [
"NORMAL_TEXT", "NORMAL_TEXT",

33
main.py
View File

@@ -1,3 +1,4 @@
import io
import argparse import argparse
import logging import logging
import os import os
@@ -6,6 +7,14 @@ import sys
from importlib import metadata, import_module from importlib import metadata, import_module
from dotenv import load_dotenv from dotenv import load_dotenv
# Prevent any stray startup output on macOS (e.g. platform identifiers) from
# corrupting the MCP JSON-RPC handshake on stdout. We capture anything written
# to stdout during module-level initialisation and replay it to stderr so that
# diagnostic information is not lost.
_original_stdout = sys.stdout
if sys.platform == "darwin":
sys.stdout = io.StringIO()
# Check for CLI mode early - before loading oauth_config # Check for CLI mode early - before loading oauth_config
# CLI mode requires OAuth 2.0 since there's no MCP session context # CLI mode requires OAuth 2.0 since there's no MCP session context
_CLI_MODE = "--cli" in sys.argv _CLI_MODE = "--cli" in sys.argv
@@ -117,12 +126,36 @@ def narrow_permissions_to_services(
} }
def _restore_stdout() -> None:
"""Restore the real stdout and replay any captured output to stderr."""
captured_stdout = sys.stdout
# Idempotent: if already restored, nothing to do.
if captured_stdout is _original_stdout:
return
captured = ""
required_stringio_methods = ("getvalue", "write", "flush")
try:
if all(
callable(getattr(captured_stdout, method_name, None))
for method_name in required_stringio_methods
):
captured = captured_stdout.getvalue()
finally:
sys.stdout = _original_stdout
if captured:
print(captured, end="", file=sys.stderr)
def main(): def main():
""" """
Main entry point for the Google Workspace MCP server. Main entry point for the Google Workspace MCP server.
Uses FastMCP's native streamable-http transport. Uses FastMCP's native streamable-http transport.
Supports CLI mode for direct tool invocation without running the server. Supports CLI mode for direct tool invocation without running the server.
""" """
_restore_stdout()
# Check if CLI mode is enabled - suppress startup messages # Check if CLI mode is enabled - suppress startup messages
if _CLI_MODE: if _CLI_MODE:
# Suppress logging output in CLI mode for clean output # Suppress logging output in CLI mode for clean output