From e441ade35f68217a6aa7b6afa9b0d7bacb8b2ef1 Mon Sep 17 00:00:00 2001 From: Rein Lemmens Date: Thu, 12 Mar 2026 21:08:16 +0100 Subject: [PATCH 01/11] Display meeting links in calendar event output Conference/meeting links (Google Meet, Zoom, etc.) were fetched from the API but never included in get_events() output. Extract video entry point from conferenceData (with hangoutLink fallback) and display it in all three output modes: single detailed, multi detailed, and basic list. Co-Authored-By: Claude Opus 4.6 --- gcalendar/calendar_tools.py | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) 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 From 00796f39c6a08e49b78cb6d9a9c1b1fb91aeeeac Mon Sep 17 00:00:00 2001 From: Rein Lemmens Date: Thu, 12 Mar 2026 21:15:39 +0100 Subject: [PATCH 02/11] Extract shared named style constant and use keyword args - Extract VALID_NAMED_STYLE_TYPES constant in docs_helpers.py, reuse in validation_manager.py - Switch build_paragraph_style call to keyword arguments for clarity and resilience Co-Authored-By: Claude Opus 4.6 --- gdocs/docs_helpers.py | 39 ++++++++++++++++------------ gdocs/managers/validation_manager.py | 11 +++----- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/gdocs/docs_helpers.py b/gdocs/docs_helpers.py index 85354f7..05d7705 100644 --- a/gdocs/docs_helpers.py +++ b/gdocs/docs_helpers.py @@ -10,6 +10,18 @@ from typing import Dict, Any, Optional logger = logging.getLogger(__name__) +VALID_NAMED_STYLE_TYPES = ( + "NORMAL_TEXT", + "TITLE", + "SUBTITLE", + "HEADING_1", + "HEADING_2", + "HEADING_3", + "HEADING_4", + "HEADING_5", + "HEADING_6", +) + def _normalize_color( color: Optional[str], param_name: str @@ -137,15 +149,10 @@ def build_paragraph_style( fields = [] 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: + if named_style_type not in VALID_NAMED_STYLE_TYPES: raise ValueError( f"Invalid named_style_type '{named_style_type}'. " - f"Must be one of: {', '.join(valid_styles)}" + f"Must be one of: {', '.join(VALID_NAMED_STYLE_TYPES)}" ) paragraph_style["namedStyleType"] = named_style_type fields.append("namedStyleType") @@ -360,15 +367,15 @@ def create_update_paragraph_style_request( Dictionary representing the updateParagraphStyle request, or None if no styles provided """ paragraph_style, fields = build_paragraph_style( - heading_level, - alignment, - line_spacing, - indent_first_line, - indent_start, - indent_end, - space_above, - space_below, - named_style_type, + heading_level=heading_level, + alignment=alignment, + line_spacing=line_spacing, + indent_first_line=indent_first_line, + indent_start=indent_start, + indent_end=indent_end, + space_above=space_above, + space_below=space_below, + named_style_type=named_style_type, ) if not paragraph_style: diff --git a/gdocs/managers/validation_manager.py b/gdocs/managers/validation_manager.py index 3235c1a..7b13c21 100644 --- a/gdocs/managers/validation_manager.py +++ b/gdocs/managers/validation_manager.py @@ -9,7 +9,7 @@ import logging from typing import Dict, Any, List, Tuple, Optional from urllib.parse import urlparse -from gdocs.docs_helpers import validate_operation +from gdocs.docs_helpers import validate_operation, VALID_NAMED_STYLE_TYPES logger = logging.getLogger(__name__) @@ -317,15 +317,10 @@ class ValidationManager: ) 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: + if named_style_type not in VALID_NAMED_STYLE_TYPES: return ( False, - f"Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(valid_styles)}", + f"Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(VALID_NAMED_STYLE_TYPES)}", ) if heading_level is not None: From 9285a5f97ec166688e65451dd941c17c80af4fab Mon Sep 17 00:00:00 2001 From: Rein Lemmens Date: Thu, 12 Mar 2026 21:15:57 +0100 Subject: [PATCH 03/11] Revert "Extract shared named style constant and use keyword args" This reverts commit 00796f39c6a08e49b78cb6d9a9c1b1fb91aeeeac. --- gdocs/docs_helpers.py | 39 ++++++++++++---------------- gdocs/managers/validation_manager.py | 11 +++++--- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/gdocs/docs_helpers.py b/gdocs/docs_helpers.py index 05d7705..85354f7 100644 --- a/gdocs/docs_helpers.py +++ b/gdocs/docs_helpers.py @@ -10,18 +10,6 @@ from typing import Dict, Any, Optional logger = logging.getLogger(__name__) -VALID_NAMED_STYLE_TYPES = ( - "NORMAL_TEXT", - "TITLE", - "SUBTITLE", - "HEADING_1", - "HEADING_2", - "HEADING_3", - "HEADING_4", - "HEADING_5", - "HEADING_6", -) - def _normalize_color( color: Optional[str], param_name: str @@ -149,10 +137,15 @@ def build_paragraph_style( fields = [] if named_style_type is not None: - if named_style_type not in VALID_NAMED_STYLE_TYPES: + 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: raise ValueError( f"Invalid named_style_type '{named_style_type}'. " - f"Must be one of: {', '.join(VALID_NAMED_STYLE_TYPES)}" + f"Must be one of: {', '.join(valid_styles)}" ) paragraph_style["namedStyleType"] = named_style_type fields.append("namedStyleType") @@ -367,15 +360,15 @@ def create_update_paragraph_style_request( Dictionary representing the updateParagraphStyle request, or None if no styles provided """ paragraph_style, fields = build_paragraph_style( - heading_level=heading_level, - alignment=alignment, - line_spacing=line_spacing, - indent_first_line=indent_first_line, - indent_start=indent_start, - indent_end=indent_end, - space_above=space_above, - space_below=space_below, - named_style_type=named_style_type, + heading_level, + alignment, + line_spacing, + indent_first_line, + indent_start, + indent_end, + space_above, + space_below, + named_style_type, ) if not paragraph_style: diff --git a/gdocs/managers/validation_manager.py b/gdocs/managers/validation_manager.py index 7b13c21..3235c1a 100644 --- a/gdocs/managers/validation_manager.py +++ b/gdocs/managers/validation_manager.py @@ -9,7 +9,7 @@ import logging from typing import Dict, Any, List, Tuple, Optional from urllib.parse import urlparse -from gdocs.docs_helpers import validate_operation, VALID_NAMED_STYLE_TYPES +from gdocs.docs_helpers import validate_operation logger = logging.getLogger(__name__) @@ -317,10 +317,15 @@ class ValidationManager: ) if named_style_type is not None: - if named_style_type not in VALID_NAMED_STYLE_TYPES: + 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 ( False, - f"Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(VALID_NAMED_STYLE_TYPES)}", + f"Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(valid_styles)}", ) if heading_level is not None: From 59b3a2492c42693ab942b9e3a6946a1e86f39bac Mon Sep 17 00:00:00 2001 From: Rein Lemmens Date: Thu, 12 Mar 2026 21:20:17 +0100 Subject: [PATCH 04/11] Reject heading_level and named_style_type together in validation Add mutual exclusion guard so clients get a clear error instead of named_style_type silently overriding heading_level. Co-Authored-By: Claude Opus 4.6 --- gdocs/managers/validation_manager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gdocs/managers/validation_manager.py b/gdocs/managers/validation_manager.py index 3235c1a..d5917e7 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", "TITLE", "SUBTITLE", From 31e27b76b634d18dbb3f6a0aefa14e54fff8b74d Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Sat, 14 Mar 2026 01:36:34 +0200 Subject: [PATCH 05/11] fix: improve OAuth response pages for browser compatibility window.close() is blocked by modern browsers for tabs not opened via window.open(). The success page's close button and auto-close timer silently fail as a result. - Add tryClose() that attempts window.close() and falls back to showing "You can close this tab manually" after 500ms - Remove ineffective auto-close scripts from error pages --- auth/oauth_responses.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/auth/oauth_responses.py b/auth/oauth_responses.py index ee31b6e..ef79ef6 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 }} @@ -191,7 +198,7 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo
Your credentials have been securely saved. You can now close this window and retry your original command.
- +
This window 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.

""" From 4bdc96a554e322bef4b7515e915034e555ea2f95 Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Sat, 14 Mar 2026 07:35:18 +0200 Subject: [PATCH 06/11] fix: use consistent "tab" wording in success page CTAs Change "Close Window" to "Close Tab" and "This window will close" to "This tab will close" on the success page to match the rest of the PR. --- auth/oauth_responses.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/auth/oauth_responses.py b/auth/oauth_responses.py index ef79ef6..5c2a0a9 100644 --- a/auth/oauth_responses.py +++ b/auth/oauth_responses.py @@ -196,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
""" From b5d7270feaabcae27ff6743483946b8ec4a9b5fb Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Sun, 15 Mar 2026 17:13:07 -0400 Subject: [PATCH 07/11] fix: suppress platform string output to stdout on macOS --- main.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/main.py b/main.py index 8fb97c5..adbd69d 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +import io import argparse import logging import os @@ -6,6 +7,13 @@ import sys from importlib import metadata, import_module from dotenv import load_dotenv +# Prevent any stray output (e.g. platform identifiers like "darwin" on macOS) +# 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 +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 +125,21 @@ def narrow_permissions_to_services( } +def _restore_stdout(): + """Restore the real stdout and replay any captured output to stderr.""" + captured = sys.stdout.getvalue() + 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 From 2ab22ee6307ee63c2b22abbb1185b639a709baa1 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Mon, 16 Mar 2026 14:44:45 -0400 Subject: [PATCH 08/11] refac --- gdocs/docs_tools.py | 23 ++++++++++++++++++++++- gdocs/managers/batch_operation_manager.py | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) 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)", }, From 16ce566d887ffa326f3f6035c8d83dd4d672a9cf Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Tue, 17 Mar 2026 10:21:58 -0400 Subject: [PATCH 09/11] refac --- main.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index aa2910a..02b66e1 100644 --- a/main.py +++ b/main.py @@ -125,9 +125,22 @@ def narrow_permissions_to_services( } -def _restore_stdout(): +def _restore_stdout() -> None: """Restore the real stdout and replay any captured output to stderr.""" - captured = sys.stdout.getvalue() + captured_stdout = sys.stdout + + # Idempotent: if already restored, nothing to do. + if captured_stdout is _original_stdout: + return + + required_stringio_methods = ("getvalue", "write", "flush") + if not all( + callable(getattr(captured_stdout, method_name, None)) + for method_name in required_stringio_methods + ): + return + + captured = captured_stdout.getvalue() sys.stdout = _original_stdout if captured: print(captured, end="", file=sys.stderr) From 96df53c5e9b0a231309049ae42012d2c6704845a Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Tue, 17 Mar 2026 10:34:30 -0400 Subject: [PATCH 10/11] refac --- main.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 02b66e1..5051ce2 100644 --- a/main.py +++ b/main.py @@ -133,15 +133,17 @@ def _restore_stdout() -> None: if captured_stdout is _original_stdout: return + captured = "" required_stringio_methods = ("getvalue", "write", "flush") - if not all( - callable(getattr(captured_stdout, method_name, None)) - for method_name in required_stringio_methods - ): - return + 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 - captured = captured_stdout.getvalue() - sys.stdout = _original_stdout if captured: print(captured, end="", file=sys.stderr) From ffcea58fdc83d571d25efb55d00c8413c5b9cf9c Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Tue, 17 Mar 2026 10:42:32 -0400 Subject: [PATCH 11/11] refac --- main.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 5051ce2..401a270 100644 --- a/main.py +++ b/main.py @@ -7,12 +7,13 @@ import sys from importlib import metadata, import_module from dotenv import load_dotenv -# Prevent any stray output (e.g. platform identifiers like "darwin" on macOS) -# 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. +# 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 -sys.stdout = io.StringIO() +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