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;">
<h2 style="color: #d32f2f;">Authentication Error</h2>
<p>{error_message}</p>
<p>Please ensure you grant the requested permissions. You can close this window and try again.</p>
<script>setTimeout(function() {{ window.close(); }}, 10000);</script>
<p>Please ensure you grant the requested permissions. You can close this tab and try again.</p>
</body>
</html>
"""
@@ -176,9 +175,17 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo
}}
</style>
<script>
setTimeout(function() {{
function tryClose() {{
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>
</head>
<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>
</div>
<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>
<button class="button" onclick="window.close()">Close Window</button>
<div class="auto-close">This window will close automatically in 10 seconds</div>
<button class="button" onclick="tryClose()">Close Tab</button>
<div class="auto-close">This tab will close automatically in 10 seconds</div>
</div>
</body>
</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;">
<h2 style="color: #d32f2f;">Authentication Processing Error</h2>
<p>An unexpected error occurred while processing your authentication: {error_detail}</p>
<p>Please try again. You can close this window.</p>
<script>setTimeout(function() {{ window.close(); }}, 10000);</script>
<p>Please try again. You can close this tab.</p>
</body>
</html>
"""

View File

@@ -172,6 +172,21 @@ def _preserve_existing_fields(
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(
attendees: List[Dict[str, Any]], indent: str = " "
) -> str:
@@ -448,6 +463,8 @@ async def get_events(
)
attendee_details_str = _format_attendee_details(attendees, indent=" ")
meeting_link = _get_meeting_link(item)
event_details = (
f"Event Details:\n"
f"- Title: {summary}\n"
@@ -456,6 +473,10 @@ async def get_events(
f"- Description: {description}\n"
f"- Location: {location}\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"- Attendee Details: {attendee_details_str}\n"
)
@@ -494,10 +515,16 @@ async def get_events(
)
attendee_details_str = _format_attendee_details(attendees, indent=" ")
meeting_link = _get_meeting_link(item)
event_detail_parts = (
f'- "{summary}" (Starts: {start_time}, Ends: {end_time})\n'
f" Description: {description}\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" Attendee Details: {attendee_details_str}\n"
)
@@ -513,9 +540,12 @@ async def get_events(
event_details_list.append(event_detail_parts)
else:
# Basic output format
event_details_list.append(
f'- "{summary}" (Starts: {start_time}, Ends: {end_time}) ID: {item_event_id} | Link: {link}'
)
meeting_link = _get_meeting_link(item)
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:
# Single event basic output

View File

@@ -1463,6 +1463,7 @@ async def update_paragraph_style(
indent_end: float = None,
space_above: float = None,
space_below: float = None,
named_style_type: str = None,
list_type: str = None,
list_nesting_level: int = None,
) -> str:
@@ -1488,6 +1489,8 @@ async def update_paragraph_style(
indent_end: Right/end indent in points
space_above: Space above paragraph in points (e.g., 12 for one line)
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_nesting_level: Nesting level for lists (0-8, where 0 is top level, default is 0)
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:
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
paragraph_style = {}
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)
if heading_level is not None:
elif heading_level is not None:
if heading_level < 0 or heading_level > 6:
return "Error: heading_level must be between 0 (normal text) and 6"
if heading_level == 0:

View File

@@ -475,6 +475,7 @@ class BatchOperationManager:
"indent_end",
"space_above",
"space_below",
"named_style_type",
],
"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)",
)
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:
valid_styles = [
"NORMAL_TEXT",

33
main.py
View File

@@ -1,3 +1,4 @@
import io
import argparse
import logging
import os
@@ -6,6 +7,14 @@ import sys
from importlib import metadata, import_module
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
# CLI mode requires OAuth 2.0 since there's no MCP session context
_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():
"""
Main entry point for the Google Workspace MCP server.
Uses FastMCP's native streamable-http transport.
Supports CLI mode for direct tool invocation without running the server.
"""
_restore_stdout()
# Check if CLI mode is enabled - suppress startup messages
if _CLI_MODE:
# Suppress logging output in CLI mode for clean output