Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into feat/555-auto-reply-headers
This commit is contained in:
@@ -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>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
33
main.py
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user