workspace-mcp 1.1.5__tar.gz → 1.1.6__tar.gz
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.
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/PKG-INFO +14 -7
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/README.md +13 -6
- workspace_mcp-1.1.6/core/utils.py +296 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gTasks/tasks_tools.py +13 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gcalendar/calendar_tools.py +6 -6
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gdocs/docs_tools.py +4 -4
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gdrive/drive_tools.py +5 -5
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gforms/forms_tools.py +5 -5
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gmail/gmail_tools.py +199 -81
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gsheets/sheets_tools.py +7 -7
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gslides/slides_tools.py +25 -25
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/pyproject.toml +1 -1
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/workspace_mcp.egg-info/PKG-INFO +14 -7
- workspace_mcp-1.1.5/core/utils.py +0 -197
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/LICENSE +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/auth/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/auth/google_auth.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/auth/oauth_callback_server.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/auth/oauth_responses.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/auth/scopes.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/auth/service_decorator.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/core/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/core/comments.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/core/context.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/core/server.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gTasks/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gcalendar/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gchat/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gchat/chat_tools.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gdocs/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gdrive/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gforms/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gmail/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gsheets/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/gslides/__init__.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/main.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/setup.cfg +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/tests/test_auth.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/tests/test_oauth_callback_server.py +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/workspace_mcp.egg-info/SOURCES.txt +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/workspace_mcp.egg-info/dependency_links.txt +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/workspace_mcp.egg-info/entry_points.txt +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/workspace_mcp.egg-info/requires.txt +0 -0
- {workspace_mcp-1.1.5 → workspace_mcp-1.1.6}/workspace_mcp.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: workspace-mcp
|
3
|
-
Version: 1.1.
|
3
|
+
Version: 1.1.6
|
4
4
|
Summary: Comprehensive, highly performant Google Workspace Streamable HTTP & SSE MCP Server for Calendar, Gmail, Docs, Sheets, Slides & Drive
|
5
5
|
Author-email: Taylor Wilsdon <taylor@taylorwilsdon.com>
|
6
6
|
License: MIT
|
@@ -47,11 +47,11 @@ Dynamic: license-file
|
|
47
47
|
[](https://opensource.org/licenses/MIT)
|
48
48
|
[](https://www.python.org/downloads/)
|
49
49
|
[](https://pypi.org/project/workspace-mcp/)
|
50
|
-
[](https://pepy.tech/projects/workspace-mcp)
|
51
51
|
[](https://workspacemcp.com)
|
52
52
|
[](https://mseep.ai/app/eebbc4a6-0f8c-41b2-ace8-038e5516dba0)
|
53
53
|
|
54
|
-
**This is the single most feature-complete Google Workspace MCP server**
|
54
|
+
**This is the single most feature-complete Google Workspace MCP server** now with 1-click Claude installation
|
55
55
|
|
56
56
|
*Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Tasks, and Chat through all MCP clients, AI assistants and developer tools.*
|
57
57
|
|
@@ -122,11 +122,12 @@ A production-ready MCP server that integrates all major Google Workspace service
|
|
122
122
|
|
123
123
|
>
|
124
124
|
**Why DXT?**
|
125
|
-
> Desktop Extensions (`.dxt`) bundle the server, dependencies, and manifest so users go from download → working MCP in **
|
125
|
+
> Desktop Extensions (`.dxt`) bundle the server, dependencies, and manifest so users go from download → working MCP in **one click** – no terminal, no JSON editing, no version conflicts.
|
126
126
|
|
127
127
|
#### Required Configuration
|
128
128
|
<details>
|
129
129
|
<summary>Environment - you will configure these in Claude itself, see screenshot:</summary>
|
130
|
+
|
130
131
|
| Variable | Purpose |
|
131
132
|
|----------|---------|
|
132
133
|
| `GOOGLE_OAUTH_CLIENT_ID` | OAuth client ID from Google Cloud |
|
@@ -136,8 +137,10 @@ A production-ready MCP server that integrates all major Google Workspace service
|
|
136
137
|
|
137
138
|
Claude Desktop stores these securely in the OS keychain; set them once in the extension pane.
|
138
139
|
</details>
|
139
|
-
Screenshot here
|
140
140
|
|
141
|
+
<div align="center">
|
142
|
+
<video width="832" src="https://github.com/user-attachments/assets/83cca4b3-5e94-448b-acb3-6e3a27341d3a"></video>
|
143
|
+
</div>
|
141
144
|
---
|
142
145
|
|
143
146
|
### 2. Advanced / Cross-Platform Installation
|
@@ -308,8 +311,12 @@ After running the script, just restart Claude Desktop and you're ready to go.
|
|
308
311
|
"mcpServers": {
|
309
312
|
"google_workspace": {
|
310
313
|
"command": "uv",
|
311
|
-
"args": [
|
312
|
-
|
314
|
+
"args": [
|
315
|
+
"run",
|
316
|
+
"--directory",
|
317
|
+
"/path/to/repo/google_workspace_mcp",
|
318
|
+
"main.py"
|
319
|
+
],
|
313
320
|
"env": {
|
314
321
|
"GOOGLE_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
|
315
322
|
"GOOGLE_OAUTH_CLIENT_SECRET": "your-client-secret",
|
@@ -5,11 +5,11 @@
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
6
6
|
[](https://www.python.org/downloads/)
|
7
7
|
[](https://pypi.org/project/workspace-mcp/)
|
8
|
-
[](https://pepy.tech/projects/workspace-mcp)
|
9
9
|
[](https://workspacemcp.com)
|
10
10
|
[](https://mseep.ai/app/eebbc4a6-0f8c-41b2-ace8-038e5516dba0)
|
11
11
|
|
12
|
-
**This is the single most feature-complete Google Workspace MCP server**
|
12
|
+
**This is the single most feature-complete Google Workspace MCP server** now with 1-click Claude installation
|
13
13
|
|
14
14
|
*Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Tasks, and Chat through all MCP clients, AI assistants and developer tools.*
|
15
15
|
|
@@ -80,11 +80,12 @@ A production-ready MCP server that integrates all major Google Workspace service
|
|
80
80
|
|
81
81
|
>
|
82
82
|
**Why DXT?**
|
83
|
-
> Desktop Extensions (`.dxt`) bundle the server, dependencies, and manifest so users go from download → working MCP in **
|
83
|
+
> Desktop Extensions (`.dxt`) bundle the server, dependencies, and manifest so users go from download → working MCP in **one click** – no terminal, no JSON editing, no version conflicts.
|
84
84
|
|
85
85
|
#### Required Configuration
|
86
86
|
<details>
|
87
87
|
<summary>Environment - you will configure these in Claude itself, see screenshot:</summary>
|
88
|
+
|
88
89
|
| Variable | Purpose |
|
89
90
|
|----------|---------|
|
90
91
|
| `GOOGLE_OAUTH_CLIENT_ID` | OAuth client ID from Google Cloud |
|
@@ -94,8 +95,10 @@ A production-ready MCP server that integrates all major Google Workspace service
|
|
94
95
|
|
95
96
|
Claude Desktop stores these securely in the OS keychain; set them once in the extension pane.
|
96
97
|
</details>
|
97
|
-
Screenshot here
|
98
98
|
|
99
|
+
<div align="center">
|
100
|
+
<video width="832" src="https://github.com/user-attachments/assets/83cca4b3-5e94-448b-acb3-6e3a27341d3a"></video>
|
101
|
+
</div>
|
99
102
|
---
|
100
103
|
|
101
104
|
### 2. Advanced / Cross-Platform Installation
|
@@ -266,8 +269,12 @@ After running the script, just restart Claude Desktop and you're ready to go.
|
|
266
269
|
"mcpServers": {
|
267
270
|
"google_workspace": {
|
268
271
|
"command": "uv",
|
269
|
-
"args": [
|
270
|
-
|
272
|
+
"args": [
|
273
|
+
"run",
|
274
|
+
"--directory",
|
275
|
+
"/path/to/repo/google_workspace_mcp",
|
276
|
+
"main.py"
|
277
|
+
],
|
271
278
|
"env": {
|
272
279
|
"GOOGLE_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
|
273
280
|
"GOOGLE_OAUTH_CLIENT_SECRET": "your-client-secret",
|
@@ -0,0 +1,296 @@
|
|
1
|
+
import io
|
2
|
+
import logging
|
3
|
+
import os
|
4
|
+
import tempfile
|
5
|
+
import zipfile, xml.etree.ElementTree as ET
|
6
|
+
import ssl
|
7
|
+
import time
|
8
|
+
import asyncio
|
9
|
+
import functools
|
10
|
+
|
11
|
+
from typing import List, Optional
|
12
|
+
|
13
|
+
from googleapiclient.errors import HttpError
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class TransientNetworkError(Exception):
|
19
|
+
"""Custom exception for transient network errors after retries."""
|
20
|
+
|
21
|
+
pass
|
22
|
+
|
23
|
+
|
24
|
+
def check_credentials_directory_permissions(credentials_dir: str = None) -> None:
|
25
|
+
"""
|
26
|
+
Check if the service has appropriate permissions to create and write to the .credentials directory.
|
27
|
+
|
28
|
+
Args:
|
29
|
+
credentials_dir: Path to the credentials directory (default: uses get_default_credentials_dir())
|
30
|
+
|
31
|
+
Raises:
|
32
|
+
PermissionError: If the service lacks necessary permissions
|
33
|
+
OSError: If there are other file system issues
|
34
|
+
"""
|
35
|
+
if credentials_dir is None:
|
36
|
+
from auth.google_auth import get_default_credentials_dir
|
37
|
+
|
38
|
+
credentials_dir = get_default_credentials_dir()
|
39
|
+
|
40
|
+
try:
|
41
|
+
# Check if directory exists
|
42
|
+
if os.path.exists(credentials_dir):
|
43
|
+
# Directory exists, check if we can write to it
|
44
|
+
test_file = os.path.join(credentials_dir, ".permission_test")
|
45
|
+
try:
|
46
|
+
with open(test_file, "w") as f:
|
47
|
+
f.write("test")
|
48
|
+
os.remove(test_file)
|
49
|
+
logger.info(
|
50
|
+
f"Credentials directory permissions check passed: {os.path.abspath(credentials_dir)}"
|
51
|
+
)
|
52
|
+
except (PermissionError, OSError) as e:
|
53
|
+
raise PermissionError(
|
54
|
+
f"Cannot write to existing credentials directory '{os.path.abspath(credentials_dir)}': {e}"
|
55
|
+
)
|
56
|
+
else:
|
57
|
+
# Directory doesn't exist, try to create it and its parent directories
|
58
|
+
try:
|
59
|
+
os.makedirs(credentials_dir, exist_ok=True)
|
60
|
+
# Test writing to the new directory
|
61
|
+
test_file = os.path.join(credentials_dir, ".permission_test")
|
62
|
+
with open(test_file, "w") as f:
|
63
|
+
f.write("test")
|
64
|
+
os.remove(test_file)
|
65
|
+
logger.info(
|
66
|
+
f"Created credentials directory with proper permissions: {os.path.abspath(credentials_dir)}"
|
67
|
+
)
|
68
|
+
except (PermissionError, OSError) as e:
|
69
|
+
# Clean up if we created the directory but can't write to it
|
70
|
+
try:
|
71
|
+
if os.path.exists(credentials_dir):
|
72
|
+
os.rmdir(credentials_dir)
|
73
|
+
except:
|
74
|
+
pass
|
75
|
+
raise PermissionError(
|
76
|
+
f"Cannot create or write to credentials directory '{os.path.abspath(credentials_dir)}': {e}"
|
77
|
+
)
|
78
|
+
|
79
|
+
except PermissionError:
|
80
|
+
raise
|
81
|
+
except Exception as e:
|
82
|
+
raise OSError(
|
83
|
+
f"Unexpected error checking credentials directory permissions: {e}"
|
84
|
+
)
|
85
|
+
|
86
|
+
|
87
|
+
def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
|
88
|
+
"""
|
89
|
+
Very light-weight XML scraper for Word, Excel, PowerPoint files.
|
90
|
+
Returns plain-text if something readable is found, else None.
|
91
|
+
No external deps – just std-lib zipfile + ElementTree.
|
92
|
+
"""
|
93
|
+
shared_strings: List[str] = []
|
94
|
+
ns_excel_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
95
|
+
|
96
|
+
try:
|
97
|
+
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
98
|
+
targets: List[str] = []
|
99
|
+
# Map MIME → iterable of XML files to inspect
|
100
|
+
if (
|
101
|
+
mime_type
|
102
|
+
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
103
|
+
):
|
104
|
+
targets = ["word/document.xml"]
|
105
|
+
elif (
|
106
|
+
mime_type
|
107
|
+
== "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
108
|
+
):
|
109
|
+
targets = [n for n in zf.namelist() if n.startswith("ppt/slides/slide")]
|
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
|
+
]
|
119
|
+
# Attempt to parse sharedStrings.xml for Excel files
|
120
|
+
try:
|
121
|
+
shared_strings_xml = zf.read("xl/sharedStrings.xml")
|
122
|
+
shared_strings_root = ET.fromstring(shared_strings_xml)
|
123
|
+
for si_element in shared_strings_root.findall(
|
124
|
+
f"{{{ns_excel_main}}}si"
|
125
|
+
):
|
126
|
+
text_parts = []
|
127
|
+
# Find all <t> elements, simple or within <r> runs, and concatenate their text
|
128
|
+
for t_element in si_element.findall(f".//{{{ns_excel_main}}}t"):
|
129
|
+
if t_element.text:
|
130
|
+
text_parts.append(t_element.text)
|
131
|
+
shared_strings.append("".join(text_parts))
|
132
|
+
except KeyError:
|
133
|
+
logger.info(
|
134
|
+
"No sharedStrings.xml found in Excel file (this is optional)."
|
135
|
+
)
|
136
|
+
except ET.ParseError as e:
|
137
|
+
logger.error(f"Error parsing sharedStrings.xml: {e}")
|
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
|
+
)
|
145
|
+
else:
|
146
|
+
return None
|
147
|
+
|
148
|
+
pieces: List[str] = []
|
149
|
+
for member in targets:
|
150
|
+
try:
|
151
|
+
xml_content = zf.read(member)
|
152
|
+
xml_root = ET.fromstring(xml_content)
|
153
|
+
member_texts: List[str] = []
|
154
|
+
|
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>
|
165
|
+
|
166
|
+
# Skip if cell has no value element or value element has no text
|
167
|
+
if value_element is None or value_element.text is None:
|
168
|
+
continue
|
169
|
+
|
170
|
+
cell_type = cell_element.get("t")
|
171
|
+
if cell_type == "s": # Shared string
|
172
|
+
try:
|
173
|
+
ss_idx = int(value_element.text)
|
174
|
+
if 0 <= ss_idx < len(shared_strings):
|
175
|
+
member_texts.append(shared_strings[ss_idx])
|
176
|
+
else:
|
177
|
+
logger.warning(
|
178
|
+
f"Invalid shared string index {ss_idx} in {member}. Max index: {len(shared_strings)-1}"
|
179
|
+
)
|
180
|
+
except ValueError:
|
181
|
+
logger.warning(
|
182
|
+
f"Non-integer shared string index: '{value_element.text}' in {member}."
|
183
|
+
)
|
184
|
+
else: # Direct value (number, boolean, inline string if not 's')
|
185
|
+
member_texts.append(value_element.text)
|
186
|
+
else: # Word or PowerPoint
|
187
|
+
for elem in xml_root.iter():
|
188
|
+
# For Word: <w:t> where w is "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
189
|
+
# For PowerPoint: <a:t> where a is "http://schemas.openxmlformats.org/drawingml/2006/main"
|
190
|
+
if (
|
191
|
+
elem.tag.endswith("}t") and elem.text
|
192
|
+
): # Check for any namespaced tag ending with 't'
|
193
|
+
cleaned_text = elem.text.strip()
|
194
|
+
if (
|
195
|
+
cleaned_text
|
196
|
+
): # Add only if there's non-whitespace text
|
197
|
+
member_texts.append(cleaned_text)
|
198
|
+
|
199
|
+
if member_texts:
|
200
|
+
pieces.append(
|
201
|
+
" ".join(member_texts)
|
202
|
+
) # Join texts from one member with spaces
|
203
|
+
|
204
|
+
except ET.ParseError as e:
|
205
|
+
logger.warning(
|
206
|
+
f"Could not parse XML in member '{member}' for {mime_type} file: {e}"
|
207
|
+
)
|
208
|
+
except Exception as e:
|
209
|
+
logger.error(
|
210
|
+
f"Error processing member '{member}' for {mime_type}: {e}",
|
211
|
+
exc_info=True,
|
212
|
+
)
|
213
|
+
# continue processing other members
|
214
|
+
|
215
|
+
if not pieces: # If no text was extracted at all
|
216
|
+
return None
|
217
|
+
|
218
|
+
# Join content from different members (sheets/slides) with double newlines for separation
|
219
|
+
text = "\n\n".join(pieces).strip()
|
220
|
+
return text or None # Ensure None is returned if text is empty after strip
|
221
|
+
|
222
|
+
except zipfile.BadZipFile:
|
223
|
+
logger.warning(f"File is not a valid ZIP archive (mime_type: {mime_type}).")
|
224
|
+
return None
|
225
|
+
except (
|
226
|
+
ET.ParseError
|
227
|
+
) as e: # Catch parsing errors at the top level if zipfile itself is XML-like
|
228
|
+
logger.error(f"XML parsing error at a high level for {mime_type}: {e}")
|
229
|
+
return None
|
230
|
+
except Exception as e:
|
231
|
+
logger.error(
|
232
|
+
f"Failed to extract office XML text for {mime_type}: {e}", exc_info=True
|
233
|
+
)
|
234
|
+
return None
|
235
|
+
|
236
|
+
|
237
|
+
def handle_http_errors(tool_name: str, is_read_only: bool = False):
|
238
|
+
"""
|
239
|
+
A decorator to handle Google API HttpErrors and transient SSL errors in a standardized way.
|
240
|
+
|
241
|
+
It wraps a tool function, catches HttpError, logs a detailed error message,
|
242
|
+
and raises a generic Exception with a user-friendly message.
|
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
|
+
|
247
|
+
Args:
|
248
|
+
tool_name (str): The name of the tool being decorated (e.g., 'list_calendars').
|
249
|
+
is_read_only (bool): If True, the operation is considered safe to retry on
|
250
|
+
transient network errors. Defaults to False.
|
251
|
+
"""
|
252
|
+
|
253
|
+
def decorator(func):
|
254
|
+
@functools.wraps(func)
|
255
|
+
async def wrapper(*args, **kwargs):
|
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
|
+
|
294
|
+
return wrapper
|
295
|
+
|
296
|
+
return decorator
|
@@ -13,12 +13,14 @@ from googleapiclient.errors import HttpError
|
|
13
13
|
|
14
14
|
from auth.service_decorator import require_google_service
|
15
15
|
from core.server import server
|
16
|
+
from core.utils import handle_http_errors
|
16
17
|
|
17
18
|
logger = logging.getLogger(__name__)
|
18
19
|
|
19
20
|
|
20
21
|
@server.tool()
|
21
22
|
@require_google_service("tasks", "tasks_read")
|
23
|
+
@handle_http_errors("list_task_lists")
|
22
24
|
async def list_task_lists(
|
23
25
|
service,
|
24
26
|
user_google_email: str,
|
@@ -78,6 +80,7 @@ async def list_task_lists(
|
|
78
80
|
|
79
81
|
@server.tool()
|
80
82
|
@require_google_service("tasks", "tasks_read")
|
83
|
+
@handle_http_errors("get_task_list")
|
81
84
|
async def get_task_list(
|
82
85
|
service,
|
83
86
|
user_google_email: str,
|
@@ -121,6 +124,7 @@ async def get_task_list(
|
|
121
124
|
|
122
125
|
@server.tool()
|
123
126
|
@require_google_service("tasks", "tasks")
|
127
|
+
@handle_http_errors("create_task_list")
|
124
128
|
async def create_task_list(
|
125
129
|
service,
|
126
130
|
user_google_email: str,
|
@@ -168,6 +172,7 @@ async def create_task_list(
|
|
168
172
|
|
169
173
|
@server.tool()
|
170
174
|
@require_google_service("tasks", "tasks")
|
175
|
+
@handle_http_errors("update_task_list")
|
171
176
|
async def update_task_list(
|
172
177
|
service,
|
173
178
|
user_google_email: str,
|
@@ -217,6 +222,7 @@ async def update_task_list(
|
|
217
222
|
|
218
223
|
@server.tool()
|
219
224
|
@require_google_service("tasks", "tasks")
|
225
|
+
@handle_http_errors("delete_task_list")
|
220
226
|
async def delete_task_list(
|
221
227
|
service,
|
222
228
|
user_google_email: str,
|
@@ -256,6 +262,7 @@ async def delete_task_list(
|
|
256
262
|
|
257
263
|
@server.tool()
|
258
264
|
@require_google_service("tasks", "tasks_read")
|
265
|
+
@handle_http_errors("list_tasks")
|
259
266
|
async def list_tasks(
|
260
267
|
service,
|
261
268
|
user_google_email: str,
|
@@ -361,6 +368,7 @@ async def list_tasks(
|
|
361
368
|
|
362
369
|
@server.tool()
|
363
370
|
@require_google_service("tasks", "tasks_read")
|
371
|
+
@handle_http_errors("get_task")
|
364
372
|
async def get_task(
|
365
373
|
service,
|
366
374
|
user_google_email: str,
|
@@ -421,6 +429,7 @@ async def get_task(
|
|
421
429
|
|
422
430
|
@server.tool()
|
423
431
|
@require_google_service("tasks", "tasks")
|
432
|
+
@handle_http_errors("create_task")
|
424
433
|
async def create_task(
|
425
434
|
service,
|
426
435
|
user_google_email: str,
|
@@ -495,6 +504,7 @@ async def create_task(
|
|
495
504
|
|
496
505
|
@server.tool()
|
497
506
|
@require_google_service("tasks", "tasks")
|
507
|
+
@handle_http_errors("update_task")
|
498
508
|
async def update_task(
|
499
509
|
service,
|
500
510
|
user_google_email: str,
|
@@ -576,6 +586,7 @@ async def update_task(
|
|
576
586
|
|
577
587
|
@server.tool()
|
578
588
|
@require_google_service("tasks", "tasks")
|
589
|
+
@handle_http_errors("delete_task")
|
579
590
|
async def delete_task(
|
580
591
|
service,
|
581
592
|
user_google_email: str,
|
@@ -617,6 +628,7 @@ async def delete_task(
|
|
617
628
|
|
618
629
|
@server.tool()
|
619
630
|
@require_google_service("tasks", "tasks")
|
631
|
+
@handle_http_errors("move_task")
|
620
632
|
async def move_task(
|
621
633
|
service,
|
622
634
|
user_google_email: str,
|
@@ -695,6 +707,7 @@ async def move_task(
|
|
695
707
|
|
696
708
|
@server.tool()
|
697
709
|
@require_google_service("tasks", "tasks")
|
710
|
+
@handle_http_errors("clear_completed_tasks")
|
698
711
|
async def clear_completed_tasks(
|
699
712
|
service,
|
700
713
|
user_google_email: str,
|
@@ -80,8 +80,8 @@ def _correct_time_format_for_api(
|
|
80
80
|
|
81
81
|
|
82
82
|
@server.tool()
|
83
|
+
@handle_http_errors("list_calendars", is_read_only=True)
|
83
84
|
@require_google_service("calendar", "calendar_read")
|
84
|
-
@handle_http_errors("list_calendars")
|
85
85
|
async def list_calendars(service, user_google_email: str) -> str:
|
86
86
|
"""
|
87
87
|
Retrieves a list of calendars accessible to the authenticated user.
|
@@ -114,8 +114,8 @@ async def list_calendars(service, user_google_email: str) -> str:
|
|
114
114
|
|
115
115
|
|
116
116
|
@server.tool()
|
117
|
+
@handle_http_errors("get_events", is_read_only=True)
|
117
118
|
@require_google_service("calendar", "calendar_read")
|
118
|
-
@handle_http_errors("get_events")
|
119
119
|
async def get_events(
|
120
120
|
service,
|
121
121
|
user_google_email: str,
|
@@ -202,8 +202,8 @@ async def get_events(
|
|
202
202
|
|
203
203
|
|
204
204
|
@server.tool()
|
205
|
-
@require_google_service("calendar", "calendar_events")
|
206
205
|
@handle_http_errors("create_event")
|
206
|
+
@require_google_service("calendar", "calendar_events")
|
207
207
|
async def create_event(
|
208
208
|
service,
|
209
209
|
user_google_email: str,
|
@@ -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,
|
@@ -20,8 +20,8 @@ from core.comments import create_comment_tools
|
|
20
20
|
logger = logging.getLogger(__name__)
|
21
21
|
|
22
22
|
@server.tool()
|
23
|
+
@handle_http_errors("search_docs", is_read_only=True)
|
23
24
|
@require_google_service("drive", "drive_read")
|
24
|
-
@handle_http_errors("search_docs")
|
25
25
|
async def search_docs(
|
26
26
|
service,
|
27
27
|
user_google_email: str,
|
@@ -57,11 +57,11 @@ async def search_docs(
|
|
57
57
|
return "\n".join(output)
|
58
58
|
|
59
59
|
@server.tool()
|
60
|
+
@handle_http_errors("get_doc_content", is_read_only=True)
|
60
61
|
@require_multiple_services([
|
61
62
|
{"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"},
|
62
63
|
{"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"}
|
63
64
|
])
|
64
|
-
@handle_http_errors("get_doc_content")
|
65
65
|
async def get_doc_content(
|
66
66
|
drive_service,
|
67
67
|
docs_service,
|
@@ -157,8 +157,8 @@ async def get_doc_content(
|
|
157
157
|
return header + body_text
|
158
158
|
|
159
159
|
@server.tool()
|
160
|
+
@handle_http_errors("list_docs_in_folder", is_read_only=True)
|
160
161
|
@require_google_service("drive", "drive_read")
|
161
|
-
@handle_http_errors("list_docs_in_folder")
|
162
162
|
async def list_docs_in_folder(
|
163
163
|
service,
|
164
164
|
user_google_email: str,
|
@@ -189,8 +189,8 @@ async def list_docs_in_folder(
|
|
189
189
|
return "\n".join(out)
|
190
190
|
|
191
191
|
@server.tool()
|
192
|
-
@require_google_service("docs", "docs_write")
|
193
192
|
@handle_http_errors("create_doc")
|
193
|
+
@require_google_service("docs", "docs_write")
|
194
194
|
async def create_doc(
|
195
195
|
service,
|
196
196
|
user_google_email: str,
|
@@ -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,
|