diff --git a/auth/oauth_responses.py b/auth/oauth_responses.py index ee31b6e..5c2a0a9 100644 --- a/auth/oauth_responses.py +++ b/auth/oauth_responses.py @@ -26,8 +26,7 @@ def create_error_response(error_message: str, status_code: int = 400) -> HTMLRes

Authentication Error

{error_message}

-

Please ensure you grant the requested permissions. You can close this window and try again.

- +

Please ensure you grant the requested permissions. You can close this tab and try again.

""" @@ -176,9 +175,17 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo }} @@ -189,10 +196,10 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo You've been authenticated as {user_display}
- 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.
- -
This window will close automatically in 10 seconds
+ +
This tab will close automatically in 10 seconds
""" @@ -215,8 +222,7 @@ def create_server_error_response(error_detail: str) -> HTMLResponse:

Authentication Processing Error

An unexpected error occurred while processing your authentication: {error_detail}

-

Please try again. You can close this window.

- +

Please try again. You can close this tab.

""" diff --git a/gcalendar/calendar_tools.py b/gcalendar/calendar_tools.py index 13da74c..60b366d 100644 --- a/gcalendar/calendar_tools.py +++ b/gcalendar/calendar_tools.py @@ -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 diff --git a/gdocs/docs_tools.py b/gdocs/docs_tools.py index c040c0b..3d7b316 100644 --- a/gdocs/docs_tools.py +++ b/gdocs/docs_tools.py @@ -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: diff --git a/gdocs/managers/batch_operation_manager.py b/gdocs/managers/batch_operation_manager.py index 97bd284..c0d5368 100644 --- a/gdocs/managers/batch_operation_manager.py +++ b/gdocs/managers/batch_operation_manager.py @@ -475,6 +475,7 @@ class BatchOperationManager: "indent_end", "space_above", "space_below", + "named_style_type", ], "description": "Apply paragraph-level styling (headings, alignment, spacing, indentation)", }, diff --git a/gdocs/managers/validation_manager.py b/gdocs/managers/validation_manager.py index e2d11b0..69ffd21 100644 --- a/gdocs/managers/validation_manager.py +++ b/gdocs/managers/validation_manager.py @@ -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", diff --git a/main.py b/main.py index b11ef6a..401a270 100644 --- a/main.py +++ b/main.py @@ -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