workspace-mcp 1.1.4__py3-none-any.whl → 1.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auth/google_auth.py CHANGED
@@ -1,14 +1,16 @@
1
1
  # auth/google_auth.py
2
2
 
3
- import os
3
+ import asyncio
4
4
  import json
5
+ import jwt
5
6
  import logging
6
- import asyncio
7
- from typing import List, Optional, Tuple, Dict, Any, Callable
8
7
  import os
9
8
 
9
+ from datetime import datetime
10
+ from typing import List, Optional, Tuple, Dict, Any
11
+
10
12
  from google.oauth2.credentials import Credentials
11
- from google_auth_oauthlib.flow import Flow, InstalledAppFlow
13
+ from google_auth_oauthlib.flow import Flow
12
14
  from google.auth.transport.requests import Request
13
15
  from google.auth.exceptions import RefreshError
14
16
  from googleapiclient.discovery import build
@@ -161,8 +163,6 @@ def load_credentials_from_file(
161
163
  expiry = None
162
164
  if creds_data.get("expiry"):
163
165
  try:
164
- from datetime import datetime
165
-
166
166
  expiry = datetime.fromisoformat(creds_data["expiry"])
167
167
  except (ValueError, TypeError) as e:
168
168
  logger.warning(
@@ -789,8 +789,6 @@ async def get_authenticated_google_service(
789
789
  # Try to get email from credentials if needed for validation
790
790
  if credentials and credentials.id_token:
791
791
  try:
792
- import jwt
793
-
794
792
  # Decode without verification (just to get email for logging)
795
793
  decoded_token = jwt.decode(
796
794
  credentials.id_token, options={"verify_signature": False}
@@ -5,15 +5,17 @@ In streamable-http mode: Uses the existing FastAPI server
5
5
  In stdio mode: Starts a minimal HTTP server just for OAuth callbacks
6
6
  """
7
7
 
8
+ import os
8
9
  import asyncio
9
10
  import logging
10
11
  import threading
11
12
  import time
12
- from typing import Optional, Dict, Any
13
13
  import socket
14
+ import uvicorn
14
15
 
15
16
  from fastapi import FastAPI, Request
16
- import uvicorn
17
+ from typing import Optional
18
+ from urllib.parse import urlparse
17
19
 
18
20
  from auth.google_auth import handle_auth_callback, check_client_secrets
19
21
  from auth.scopes import OAUTH_STATE_TO_SESSION_ID_MAP, SCOPES
@@ -73,10 +75,11 @@ class MinimalOAuthServer:
73
75
  logger.warning(f"OAuth callback: No MCP session ID found for state '{state}'. Auth will not be tied to a specific session.")
74
76
 
75
77
  # Exchange code for credentials
78
+ redirect_uri = get_oauth_redirect_uri(port=self.port, base_uri=self.base_uri)
76
79
  verified_user_id, credentials = handle_auth_callback(
77
80
  scopes=SCOPES,
78
81
  authorization_response=str(request.url),
79
- redirect_uri=f"{self.base_uri}:{self.port}/oauth2callback",
82
+ redirect_uri=redirect_uri,
80
83
  session_id=mcp_session_id
81
84
  )
82
85
 
@@ -105,7 +108,6 @@ class MinimalOAuthServer:
105
108
  # Check if port is available
106
109
  # Extract hostname from base_uri (e.g., "http://localhost" -> "localhost")
107
110
  try:
108
- from urllib.parse import urlparse
109
111
  parsed_uri = urlparse(self.base_uri)
110
112
  hostname = parsed_uri.hostname or 'localhost'
111
113
  except Exception:
@@ -179,19 +181,31 @@ class MinimalOAuthServer:
179
181
  # Global instance for stdio mode
180
182
  _minimal_oauth_server: Optional[MinimalOAuthServer] = None
181
183
 
182
- def get_oauth_redirect_uri(transport_mode: str = "stdio", port: int = 8000, base_uri: str = "http://localhost") -> str:
184
+ def get_oauth_redirect_uri(port: int = 8000, base_uri: str = "http://localhost") -> str:
183
185
  """
184
- Get the appropriate OAuth redirect URI based on transport mode.
186
+ Get the appropriate OAuth redirect URI.
187
+
188
+ Priority:
189
+ 1. GOOGLE_OAUTH_REDIRECT_URI environment variable
190
+ 2. Constructed from port and base URI
185
191
 
186
192
  Args:
187
- transport_mode: "stdio" or "streamable-http"
188
193
  port: Port number (default 8000)
189
194
  base_uri: Base URI (default "http://localhost")
190
195
 
191
196
  Returns:
192
197
  OAuth redirect URI
193
198
  """
194
- return f"{base_uri}:{port}/oauth2callback"
199
+ # Highest priority: Use the environment variable if it's set
200
+ env_redirect_uri = os.getenv("GOOGLE_OAUTH_REDIRECT_URI")
201
+ if env_redirect_uri:
202
+ logger.info(f"Using redirect URI from GOOGLE_OAUTH_REDIRECT_URI: {env_redirect_uri}")
203
+ return env_redirect_uri
204
+
205
+ # Fallback to constructing the URI based on server settings
206
+ constructed_uri = f"{base_uri}:{port}/oauth2callback"
207
+ logger.info(f"Constructed redirect URI: {constructed_uri}")
208
+ return constructed_uri
195
209
 
196
210
  def ensure_oauth_callback_available(transport_mode: str = "stdio", port: int = 8000, base_uri: str = "http://localhost") -> bool:
197
211
  """
core/server.py CHANGED
@@ -82,7 +82,7 @@ def set_transport_mode(mode: str):
82
82
 
83
83
  def get_oauth_redirect_uri_for_current_mode() -> str:
84
84
  """Get OAuth redirect URI based on current transport mode."""
85
- return get_oauth_redirect_uri(_current_transport_mode, WORKSPACE_MCP_PORT, WORKSPACE_MCP_BASE_URI)
85
+ return get_oauth_redirect_uri(WORKSPACE_MCP_PORT, WORKSPACE_MCP_BASE_URI)
86
86
 
87
87
  # Health check endpoint
88
88
  @server.custom_route("/health", methods=["GET"])
core/utils.py CHANGED
@@ -3,11 +3,24 @@ import logging
3
3
  import os
4
4
  import tempfile
5
5
  import zipfile, xml.etree.ElementTree as ET
6
+ import ssl
7
+ import time
8
+ import asyncio
9
+ import functools
6
10
 
7
11
  from typing import List, Optional
8
12
 
13
+ from googleapiclient.errors import HttpError
14
+
9
15
  logger = logging.getLogger(__name__)
10
16
 
17
+
18
+ class TransientNetworkError(Exception):
19
+ """Custom exception for transient network errors after retries."""
20
+
21
+ pass
22
+
23
+
11
24
  def check_credentials_directory_permissions(credentials_dir: str = None) -> None:
12
25
  """
13
26
  Check if the service has appropriate permissions to create and write to the .credentials directory.
@@ -21,6 +34,7 @@ def check_credentials_directory_permissions(credentials_dir: str = None) -> None
21
34
  """
22
35
  if credentials_dir is None:
23
36
  from auth.google_auth import get_default_credentials_dir
37
+
24
38
  credentials_dir = get_default_credentials_dir()
25
39
 
26
40
  try:
@@ -29,22 +43,28 @@ def check_credentials_directory_permissions(credentials_dir: str = None) -> None
29
43
  # Directory exists, check if we can write to it
30
44
  test_file = os.path.join(credentials_dir, ".permission_test")
31
45
  try:
32
- with open(test_file, 'w') as f:
46
+ with open(test_file, "w") as f:
33
47
  f.write("test")
34
48
  os.remove(test_file)
35
- logger.info(f"Credentials directory permissions check passed: {os.path.abspath(credentials_dir)}")
49
+ logger.info(
50
+ f"Credentials directory permissions check passed: {os.path.abspath(credentials_dir)}"
51
+ )
36
52
  except (PermissionError, OSError) as e:
37
- raise PermissionError(f"Cannot write to existing credentials directory '{os.path.abspath(credentials_dir)}': {e}")
53
+ raise PermissionError(
54
+ f"Cannot write to existing credentials directory '{os.path.abspath(credentials_dir)}': {e}"
55
+ )
38
56
  else:
39
57
  # Directory doesn't exist, try to create it and its parent directories
40
58
  try:
41
59
  os.makedirs(credentials_dir, exist_ok=True)
42
60
  # Test writing to the new directory
43
61
  test_file = os.path.join(credentials_dir, ".permission_test")
44
- with open(test_file, 'w') as f:
62
+ with open(test_file, "w") as f:
45
63
  f.write("test")
46
64
  os.remove(test_file)
47
- logger.info(f"Created credentials directory with proper permissions: {os.path.abspath(credentials_dir)}")
65
+ logger.info(
66
+ f"Created credentials directory with proper permissions: {os.path.abspath(credentials_dir)}"
67
+ )
48
68
  except (PermissionError, OSError) as e:
49
69
  # Clean up if we created the directory but can't write to it
50
70
  try:
@@ -52,12 +72,17 @@ def check_credentials_directory_permissions(credentials_dir: str = None) -> None
52
72
  os.rmdir(credentials_dir)
53
73
  except:
54
74
  pass
55
- raise PermissionError(f"Cannot create or write to credentials directory '{os.path.abspath(credentials_dir)}': {e}")
75
+ raise PermissionError(
76
+ f"Cannot create or write to credentials directory '{os.path.abspath(credentials_dir)}': {e}"
77
+ )
56
78
 
57
79
  except PermissionError:
58
80
  raise
59
81
  except Exception as e:
60
- raise OSError(f"Unexpected error checking credentials directory permissions: {e}")
82
+ raise OSError(
83
+ f"Unexpected error checking credentials directory permissions: {e}"
84
+ )
85
+
61
86
 
62
87
  def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
63
88
  """
@@ -66,23 +91,38 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
66
91
  No external deps – just std-lib zipfile + ElementTree.
67
92
  """
68
93
  shared_strings: List[str] = []
69
- ns_excel_main = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
94
+ ns_excel_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
70
95
 
71
96
  try:
72
97
  with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
73
98
  targets: List[str] = []
74
99
  # Map MIME → iterable of XML files to inspect
75
- if mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
100
+ if (
101
+ mime_type
102
+ == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
103
+ ):
76
104
  targets = ["word/document.xml"]
77
- elif mime_type == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
105
+ elif (
106
+ mime_type
107
+ == "application/vnd.openxmlformats-officedocument.presentationml.presentation"
108
+ ):
78
109
  targets = [n for n in zf.namelist() if n.startswith("ppt/slides/slide")]
79
- elif mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
80
- targets = [n for n in zf.namelist() if n.startswith("xl/worksheets/sheet") and "drawing" not in n]
110
+ elif (
111
+ mime_type
112
+ == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
113
+ ):
114
+ targets = [
115
+ n
116
+ for n in zf.namelist()
117
+ if n.startswith("xl/worksheets/sheet") and "drawing" not in n
118
+ ]
81
119
  # Attempt to parse sharedStrings.xml for Excel files
82
120
  try:
83
121
  shared_strings_xml = zf.read("xl/sharedStrings.xml")
84
122
  shared_strings_root = ET.fromstring(shared_strings_xml)
85
- for si_element in shared_strings_root.findall(f"{{{ns_excel_main}}}si"):
123
+ for si_element in shared_strings_root.findall(
124
+ f"{{{ns_excel_main}}}si"
125
+ ):
86
126
  text_parts = []
87
127
  # Find all <t> elements, simple or within <r> runs, and concatenate their text
88
128
  for t_element in si_element.findall(f".//{{{ns_excel_main}}}t"):
@@ -90,11 +130,18 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
90
130
  text_parts.append(t_element.text)
91
131
  shared_strings.append("".join(text_parts))
92
132
  except KeyError:
93
- logger.info("No sharedStrings.xml found in Excel file (this is optional).")
133
+ logger.info(
134
+ "No sharedStrings.xml found in Excel file (this is optional)."
135
+ )
94
136
  except ET.ParseError as e:
95
137
  logger.error(f"Error parsing sharedStrings.xml: {e}")
96
- except Exception as e: # Catch any other unexpected error during sharedStrings parsing
97
- logger.error(f"Unexpected error processing sharedStrings.xml: {e}", exc_info=True)
138
+ except (
139
+ Exception
140
+ ) as e: # Catch any other unexpected error during sharedStrings parsing
141
+ logger.error(
142
+ f"Unexpected error processing sharedStrings.xml: {e}",
143
+ exc_info=True,
144
+ )
98
145
  else:
99
146
  return None
100
147
 
@@ -105,93 +152,145 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
105
152
  xml_root = ET.fromstring(xml_content)
106
153
  member_texts: List[str] = []
107
154
 
108
- if mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
109
- for cell_element in xml_root.findall(f".//{{{ns_excel_main}}}c"): # Find all <c> elements
110
- value_element = cell_element.find(f"{{{ns_excel_main}}}v") # Find <v> under <c>
155
+ if (
156
+ mime_type
157
+ == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
158
+ ):
159
+ for cell_element in xml_root.findall(
160
+ f".//{{{ns_excel_main}}}c"
161
+ ): # Find all <c> elements
162
+ value_element = cell_element.find(
163
+ f"{{{ns_excel_main}}}v"
164
+ ) # Find <v> under <c>
111
165
 
112
166
  # Skip if cell has no value element or value element has no text
113
167
  if value_element is None or value_element.text is None:
114
168
  continue
115
169
 
116
- cell_type = cell_element.get('t')
117
- if cell_type == 's': # Shared string
170
+ cell_type = cell_element.get("t")
171
+ if cell_type == "s": # Shared string
118
172
  try:
119
173
  ss_idx = int(value_element.text)
120
174
  if 0 <= ss_idx < len(shared_strings):
121
175
  member_texts.append(shared_strings[ss_idx])
122
176
  else:
123
- logger.warning(f"Invalid shared string index {ss_idx} in {member}. Max index: {len(shared_strings)-1}")
177
+ logger.warning(
178
+ f"Invalid shared string index {ss_idx} in {member}. Max index: {len(shared_strings)-1}"
179
+ )
124
180
  except ValueError:
125
- logger.warning(f"Non-integer shared string index: '{value_element.text}' in {member}.")
181
+ logger.warning(
182
+ f"Non-integer shared string index: '{value_element.text}' in {member}."
183
+ )
126
184
  else: # Direct value (number, boolean, inline string if not 's')
127
185
  member_texts.append(value_element.text)
128
186
  else: # Word or PowerPoint
129
187
  for elem in xml_root.iter():
130
188
  # For Word: <w:t> where w is "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
131
189
  # For PowerPoint: <a:t> where a is "http://schemas.openxmlformats.org/drawingml/2006/main"
132
- if elem.tag.endswith("}t") and elem.text: # Check for any namespaced tag ending with 't'
190
+ if (
191
+ elem.tag.endswith("}t") and elem.text
192
+ ): # Check for any namespaced tag ending with 't'
133
193
  cleaned_text = elem.text.strip()
134
- if cleaned_text: # Add only if there's non-whitespace text
135
- member_texts.append(cleaned_text)
194
+ if (
195
+ cleaned_text
196
+ ): # Add only if there's non-whitespace text
197
+ member_texts.append(cleaned_text)
136
198
 
137
199
  if member_texts:
138
- pieces.append(" ".join(member_texts)) # Join texts from one member with spaces
200
+ pieces.append(
201
+ " ".join(member_texts)
202
+ ) # Join texts from one member with spaces
139
203
 
140
204
  except ET.ParseError as e:
141
- logger.warning(f"Could not parse XML in member '{member}' for {mime_type} file: {e}")
205
+ logger.warning(
206
+ f"Could not parse XML in member '{member}' for {mime_type} file: {e}"
207
+ )
142
208
  except Exception as e:
143
- logger.error(f"Error processing member '{member}' for {mime_type}: {e}", exc_info=True)
209
+ logger.error(
210
+ f"Error processing member '{member}' for {mime_type}: {e}",
211
+ exc_info=True,
212
+ )
144
213
  # continue processing other members
145
214
 
146
- if not pieces: # If no text was extracted at all
215
+ if not pieces: # If no text was extracted at all
147
216
  return None
148
217
 
149
218
  # Join content from different members (sheets/slides) with double newlines for separation
150
219
  text = "\n\n".join(pieces).strip()
151
- return text or None # Ensure None is returned if text is empty after strip
220
+ return text or None # Ensure None is returned if text is empty after strip
152
221
 
153
222
  except zipfile.BadZipFile:
154
223
  logger.warning(f"File is not a valid ZIP archive (mime_type: {mime_type}).")
155
224
  return None
156
- except ET.ParseError as e: # Catch parsing errors at the top level if zipfile itself is XML-like
225
+ except (
226
+ ET.ParseError
227
+ ) as e: # Catch parsing errors at the top level if zipfile itself is XML-like
157
228
  logger.error(f"XML parsing error at a high level for {mime_type}: {e}")
158
229
  return None
159
230
  except Exception as e:
160
- logger.error(f"Failed to extract office XML text for {mime_type}: {e}", exc_info=True)
231
+ logger.error(
232
+ f"Failed to extract office XML text for {mime_type}: {e}", exc_info=True
233
+ )
161
234
  return None
162
235
 
163
- import functools
164
- from googleapiclient.errors import HttpError
165
236
 
166
- def handle_http_errors(tool_name: str):
237
+ def handle_http_errors(tool_name: str, is_read_only: bool = False):
167
238
  """
168
- A decorator to handle Google API HttpErrors in a standardized way.
239
+ A decorator to handle Google API HttpErrors and transient SSL errors in a standardized way.
169
240
 
170
241
  It wraps a tool function, catches HttpError, logs a detailed error message,
171
242
  and raises a generic Exception with a user-friendly message.
172
243
 
244
+ If is_read_only is True, it will also catch ssl.SSLError and retry with
245
+ exponential backoff. After exhausting retries, it raises a TransientNetworkError.
246
+
173
247
  Args:
174
248
  tool_name (str): The name of the tool being decorated (e.g., 'list_calendars').
175
- This is used for logging purposes.
249
+ is_read_only (bool): If True, the operation is considered safe to retry on
250
+ transient network errors. Defaults to False.
176
251
  """
252
+
177
253
  def decorator(func):
178
254
  @functools.wraps(func)
179
255
  async def wrapper(*args, **kwargs):
180
- try:
181
- return await func(*args, **kwargs)
182
- except HttpError as error:
183
- user_google_email = kwargs.get('user_google_email', 'N/A')
184
- message = (
185
- f"API error in {tool_name}: {error}. "
186
- f"You might need to re-authenticate for user '{user_google_email}'. "
187
- f"LLM: Try 'start_google_auth' with the user's email and the appropriate service_name."
188
- )
189
- logger.error(message, exc_info=True)
190
- raise Exception(message)
191
- except Exception as e:
192
- # Catch any other unexpected errors
193
- message = f"An unexpected error occurred in {tool_name}: {e}"
194
- logger.exception(message)
195
- raise Exception(message)
256
+ max_retries = 3
257
+ base_delay = 1
258
+
259
+ for attempt in range(max_retries):
260
+ try:
261
+ return await func(*args, **kwargs)
262
+ except ssl.SSLError as e:
263
+ if is_read_only and attempt < max_retries - 1:
264
+ delay = base_delay * (2**attempt)
265
+ logger.warning(
266
+ f"SSL error in {tool_name} on attempt {attempt + 1}: {e}. Retrying in {delay} seconds..."
267
+ )
268
+ await asyncio.sleep(delay)
269
+ else:
270
+ logger.error(
271
+ f"SSL error in {tool_name} on final attempt: {e}. Raising exception."
272
+ )
273
+ raise TransientNetworkError(
274
+ f"A transient SSL error occurred in '{tool_name}' after {max_retries} attempts. "
275
+ "This is likely a temporary network or certificate issue. Please try again shortly."
276
+ ) from e
277
+ except HttpError as error:
278
+ user_google_email = kwargs.get("user_google_email", "N/A")
279
+ message = (
280
+ f"API error in {tool_name}: {error}. "
281
+ f"You might need to re-authenticate for user '{user_google_email}'. "
282
+ f"LLM: Try 'start_google_auth' with the user's email and the appropriate service_name."
283
+ )
284
+ logger.error(message, exc_info=True)
285
+ raise Exception(message) from error
286
+ except TransientNetworkError:
287
+ # Re-raise without wrapping to preserve the specific error type
288
+ raise
289
+ except Exception as e:
290
+ message = f"An unexpected error occurred in {tool_name}: {e}"
291
+ logger.exception(message)
292
+ raise Exception(message) from e
293
+
196
294
  return wrapper
295
+
197
296
  return decorator
@@ -12,6 +12,7 @@ from typing import List, Optional, Dict, Any
12
12
 
13
13
  from mcp import types
14
14
  from googleapiclient.errors import HttpError
15
+ from googleapiclient.discovery import build
15
16
 
16
17
  from auth.service_decorator import require_google_service
17
18
  from core.utils import handle_http_errors
@@ -79,8 +80,8 @@ def _correct_time_format_for_api(
79
80
 
80
81
 
81
82
  @server.tool()
83
+ @handle_http_errors("list_calendars", is_read_only=True)
82
84
  @require_google_service("calendar", "calendar_read")
83
- @handle_http_errors("list_calendars")
84
85
  async def list_calendars(service, user_google_email: str) -> str:
85
86
  """
86
87
  Retrieves a list of calendars accessible to the authenticated user.
@@ -113,8 +114,8 @@ async def list_calendars(service, user_google_email: str) -> str:
113
114
 
114
115
 
115
116
  @server.tool()
117
+ @handle_http_errors("get_events", is_read_only=True)
116
118
  @require_google_service("calendar", "calendar_read")
117
- @handle_http_errors("get_events")
118
119
  async def get_events(
119
120
  service,
120
121
  user_google_email: str,
@@ -201,8 +202,8 @@ async def get_events(
201
202
 
202
203
 
203
204
  @server.tool()
204
- @require_google_service("calendar", "calendar_events")
205
205
  @handle_http_errors("create_event")
206
+ @require_google_service("calendar", "calendar_events")
206
207
  async def create_event(
207
208
  service,
208
209
  user_google_email: str,
@@ -268,7 +269,6 @@ async def create_event(
268
269
  if attachments:
269
270
  # Accept both file URLs and file IDs. If a URL, extract the fileId.
270
271
  event_body["attachments"] = []
271
- from googleapiclient.discovery import build
272
272
  drive_service = None
273
273
  try:
274
274
  drive_service = service._http and build("drive", "v3", http=service._http)
@@ -326,8 +326,8 @@ async def create_event(
326
326
 
327
327
 
328
328
  @server.tool()
329
- @require_google_service("calendar", "calendar_events")
330
329
  @handle_http_errors("modify_event")
330
+ @require_google_service("calendar", "calendar_events")
331
331
  async def modify_event(
332
332
  service,
333
333
  user_google_email: str,
@@ -446,8 +446,8 @@ async def modify_event(
446
446
 
447
447
 
448
448
  @server.tool()
449
- @require_google_service("calendar", "calendar_events")
450
449
  @handle_http_errors("delete_event")
450
+ @require_google_service("calendar", "calendar_events")
451
451
  async def delete_event(service, user_google_email: str, event_id: str, calendar_id: str = "primary") -> str:
452
452
  """
453
453
  Deletes an existing event.
@@ -500,8 +500,8 @@ async def delete_event(service, user_google_email: str, event_id: str, calendar_
500
500
 
501
501
 
502
502
  @server.tool()
503
+ @handle_http_errors("get_event", is_read_only=True)
503
504
  @require_google_service("calendar", "calendar_read")
504
- @handle_http_errors("get_event")
505
505
  async def get_event(
506
506
  service,
507
507
  user_google_email: str,
gdocs/docs_tools.py CHANGED
@@ -9,7 +9,6 @@ import io
9
9
  from typing import List
10
10
 
11
11
  from mcp import types
12
- from googleapiclient.errors import HttpError
13
12
  from googleapiclient.http import MediaIoBaseDownload
14
13
 
15
14
  # Auth & server utilities
@@ -21,8 +20,8 @@ from core.comments import create_comment_tools
21
20
  logger = logging.getLogger(__name__)
22
21
 
23
22
  @server.tool()
23
+ @handle_http_errors("search_docs", is_read_only=True)
24
24
  @require_google_service("drive", "drive_read")
25
- @handle_http_errors("search_docs")
26
25
  async def search_docs(
27
26
  service,
28
27
  user_google_email: str,
@@ -58,11 +57,11 @@ async def search_docs(
58
57
  return "\n".join(output)
59
58
 
60
59
  @server.tool()
60
+ @handle_http_errors("get_doc_content", is_read_only=True)
61
61
  @require_multiple_services([
62
62
  {"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"},
63
63
  {"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"}
64
64
  ])
65
- @handle_http_errors("get_doc_content")
66
65
  async def get_doc_content(
67
66
  drive_service,
68
67
  docs_service,
@@ -158,8 +157,8 @@ async def get_doc_content(
158
157
  return header + body_text
159
158
 
160
159
  @server.tool()
160
+ @handle_http_errors("list_docs_in_folder", is_read_only=True)
161
161
  @require_google_service("drive", "drive_read")
162
- @handle_http_errors("list_docs_in_folder")
163
162
  async def list_docs_in_folder(
164
163
  service,
165
164
  user_google_email: str,
@@ -190,8 +189,8 @@ async def list_docs_in_folder(
190
189
  return "\n".join(out)
191
190
 
192
191
  @server.tool()
193
- @require_google_service("docs", "docs_write")
194
192
  @handle_http_errors("create_doc")
193
+ @require_google_service("docs", "docs_write")
195
194
  async def create_doc(
196
195
  service,
197
196
  user_google_email: str,
gdrive/drive_tools.py CHANGED
@@ -76,8 +76,8 @@ def _build_drive_list_params(
76
76
  return list_params
77
77
 
78
78
  @server.tool()
79
+ @handle_http_errors("search_drive_files", is_read_only=True)
79
80
  @require_google_service("drive", "drive_read")
80
- @handle_http_errors("search_drive_files")
81
81
  async def search_drive_files(
82
82
  service,
83
83
  user_google_email: str,
@@ -143,8 +143,8 @@ async def search_drive_files(
143
143
  return text_output
144
144
 
145
145
  @server.tool()
146
+ @handle_http_errors("get_drive_file_content", is_read_only=True)
146
147
  @require_google_service("drive", "drive_read")
147
- @handle_http_errors("get_drive_file_content")
148
148
  async def get_drive_file_content(
149
149
  service,
150
150
  user_google_email: str,
@@ -200,7 +200,7 @@ async def get_drive_file_content(
200
200
  "application/vnd.openxmlformats-officedocument.presentationml.presentation",
201
201
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
202
202
  }
203
-
203
+
204
204
  if mime_type in office_mime_types:
205
205
  office_text = extract_office_xml_text(file_content_bytes, mime_type)
206
206
  if office_text:
@@ -233,8 +233,8 @@ async def get_drive_file_content(
233
233
 
234
234
 
235
235
  @server.tool()
236
+ @handle_http_errors("list_drive_items", is_read_only=True)
236
237
  @require_google_service("drive", "drive_read")
237
- @handle_http_errors("list_drive_items")
238
238
  async def list_drive_items(
239
239
  service,
240
240
  user_google_email: str,
@@ -289,8 +289,8 @@ async def list_drive_items(
289
289
  return text_output
290
290
 
291
291
  @server.tool()
292
- @require_google_service("drive", "drive_file")
293
292
  @handle_http_errors("create_drive_file")
293
+ @require_google_service("drive", "drive_file")
294
294
  async def create_drive_file(
295
295
  service,
296
296
  user_google_email: str,