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 +6 -8
- auth/oauth_callback_server.py +22 -8
- core/server.py +1 -1
- core/utils.py +153 -54
- gcalendar/calendar_tools.py +7 -7
- gdocs/docs_tools.py +4 -5
- gdrive/drive_tools.py +5 -5
- gforms/forms_tools.py +22 -23
- gmail/gmail_tools.py +199 -82
- gsheets/sheets_tools.py +7 -7
- gslides/slides_tools.py +25 -25
- gtasks/tasks_tools.py +13 -0
- {workspace_mcp-1.1.4.dist-info → workspace_mcp-1.1.6.dist-info}/METADATA +62 -13
- {workspace_mcp-1.1.4.dist-info → workspace_mcp-1.1.6.dist-info}/RECORD +18 -18
- {workspace_mcp-1.1.4.dist-info → workspace_mcp-1.1.6.dist-info}/WHEEL +0 -0
- {workspace_mcp-1.1.4.dist-info → workspace_mcp-1.1.6.dist-info}/entry_points.txt +0 -0
- {workspace_mcp-1.1.4.dist-info → workspace_mcp-1.1.6.dist-info}/licenses/LICENSE +0 -0
- {workspace_mcp-1.1.4.dist-info → workspace_mcp-1.1.6.dist-info}/top_level.txt +0 -0
auth/google_auth.py
CHANGED
@@ -1,14 +1,16 @@
|
|
1
1
|
# auth/google_auth.py
|
2
2
|
|
3
|
-
import
|
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
|
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}
|
auth/oauth_callback_server.py
CHANGED
@@ -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
|
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=
|
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(
|
184
|
+
def get_oauth_redirect_uri(port: int = 8000, base_uri: str = "http://localhost") -> str:
|
183
185
|
"""
|
184
|
-
Get the appropriate OAuth redirect URI
|
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
|
-
|
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(
|
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,
|
46
|
+
with open(test_file, "w") as f:
|
33
47
|
f.write("test")
|
34
48
|
os.remove(test_file)
|
35
|
-
logger.info(
|
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(
|
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,
|
62
|
+
with open(test_file, "w") as f:
|
45
63
|
f.write("test")
|
46
64
|
os.remove(test_file)
|
47
|
-
logger.info(
|
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(
|
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(
|
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 =
|
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
|
100
|
+
if (
|
101
|
+
mime_type
|
102
|
+
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
103
|
+
):
|
76
104
|
targets = ["word/document.xml"]
|
77
|
-
elif
|
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
|
80
|
-
|
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(
|
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(
|
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
|
97
|
-
|
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
|
109
|
-
|
110
|
-
|
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(
|
117
|
-
if cell_type ==
|
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(
|
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(
|
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
|
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
|
135
|
-
|
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(
|
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(
|
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(
|
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:
|
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
|
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
|
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(
|
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
|
-
|
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
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
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
|
gcalendar/calendar_tools.py
CHANGED
@@ -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,
|