workspace-mcp 1.0.5__py3-none-any.whl → 1.1.0__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 +293 -137
- core/utils.py +7 -8
- main.py +17 -10
- {workspace_mcp-1.0.5.dist-info → workspace_mcp-1.1.0.dist-info}/METADATA +1 -1
- {workspace_mcp-1.0.5.dist-info → workspace_mcp-1.1.0.dist-info}/RECORD +9 -9
- {workspace_mcp-1.0.5.dist-info → workspace_mcp-1.1.0.dist-info}/WHEEL +0 -0
- {workspace_mcp-1.0.5.dist-info → workspace_mcp-1.1.0.dist-info}/entry_points.txt +0 -0
- {workspace_mcp-1.0.5.dist-info → workspace_mcp-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {workspace_mcp-1.0.5.dist-info → workspace_mcp-1.1.0.dist-info}/top_level.txt +0 -0
auth/google_auth.py
CHANGED
@@ -19,27 +19,48 @@ from auth.scopes import OAUTH_STATE_TO_SESSION_ID_MAP, SCOPES
|
|
19
19
|
logging.basicConfig(level=logging.INFO)
|
20
20
|
logger = logging.getLogger(__name__)
|
21
21
|
|
22
|
+
|
22
23
|
# Constants
|
23
|
-
|
24
|
+
def get_default_credentials_dir():
|
25
|
+
"""Get the default credentials directory path, preferring user-specific locations."""
|
26
|
+
# Check for explicit environment variable override
|
27
|
+
if os.getenv("GOOGLE_MCP_CREDENTIALS_DIR"):
|
28
|
+
return os.getenv("GOOGLE_MCP_CREDENTIALS_DIR")
|
29
|
+
|
30
|
+
# Use user home directory for credentials storage
|
31
|
+
home_dir = os.path.expanduser("~")
|
32
|
+
if home_dir and home_dir != "~": # Valid home directory found
|
33
|
+
return os.path.join(home_dir, ".google_workspace_mcp", "credentials")
|
34
|
+
|
35
|
+
# Fallback to current working directory if home directory is not accessible
|
36
|
+
return os.path.join(os.getcwd(), ".credentials")
|
37
|
+
|
38
|
+
|
39
|
+
DEFAULT_CREDENTIALS_DIR = get_default_credentials_dir()
|
24
40
|
|
25
41
|
# In-memory cache for session credentials, maps session_id to Credentials object
|
26
42
|
# This is brittle and bad, but our options are limited with Claude in present state.
|
27
43
|
# This should be more robust in a production system once OAuth2.1 is implemented in client.
|
28
44
|
_SESSION_CREDENTIALS_CACHE: Dict[str, Credentials] = {}
|
29
45
|
# Centralized Client Secrets Path Logic
|
30
|
-
_client_secrets_env = os.getenv("GOOGLE_CLIENT_SECRET_PATH") or os.getenv(
|
46
|
+
_client_secrets_env = os.getenv("GOOGLE_CLIENT_SECRET_PATH") or os.getenv(
|
47
|
+
"GOOGLE_CLIENT_SECRETS"
|
48
|
+
)
|
31
49
|
if _client_secrets_env:
|
32
50
|
CONFIG_CLIENT_SECRETS_PATH = _client_secrets_env
|
33
51
|
else:
|
34
52
|
# Assumes this file is in auth/ and client_secret.json is in the root
|
35
53
|
CONFIG_CLIENT_SECRETS_PATH = os.path.join(
|
36
54
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
37
|
-
|
55
|
+
"client_secret.json",
|
38
56
|
)
|
39
57
|
|
40
58
|
# --- Helper Functions ---
|
41
59
|
|
42
|
-
|
60
|
+
|
61
|
+
def _find_any_credentials(
|
62
|
+
base_dir: str = DEFAULT_CREDENTIALS_DIR,
|
63
|
+
) -> Optional[Credentials]:
|
43
64
|
"""
|
44
65
|
Find and load any valid credentials from the credentials directory.
|
45
66
|
Used in single-user mode to bypass session-to-OAuth mapping.
|
@@ -53,104 +74,135 @@ def _find_any_credentials(base_dir: str = DEFAULT_CREDENTIALS_DIR) -> Optional[C
|
|
53
74
|
|
54
75
|
# Scan for any .json credential files
|
55
76
|
for filename in os.listdir(base_dir):
|
56
|
-
if filename.endswith(
|
77
|
+
if filename.endswith(".json"):
|
57
78
|
filepath = os.path.join(base_dir, filename)
|
58
79
|
try:
|
59
|
-
with open(filepath,
|
80
|
+
with open(filepath, "r") as f:
|
60
81
|
creds_data = json.load(f)
|
61
82
|
credentials = Credentials(
|
62
|
-
token=creds_data.get(
|
63
|
-
refresh_token=creds_data.get(
|
64
|
-
token_uri=creds_data.get(
|
65
|
-
client_id=creds_data.get(
|
66
|
-
client_secret=creds_data.get(
|
67
|
-
scopes=creds_data.get(
|
83
|
+
token=creds_data.get("token"),
|
84
|
+
refresh_token=creds_data.get("refresh_token"),
|
85
|
+
token_uri=creds_data.get("token_uri"),
|
86
|
+
client_id=creds_data.get("client_id"),
|
87
|
+
client_secret=creds_data.get("client_secret"),
|
88
|
+
scopes=creds_data.get("scopes"),
|
68
89
|
)
|
69
90
|
logger.info(f"[single-user] Found credentials in {filepath}")
|
70
91
|
return credentials
|
71
92
|
except (IOError, json.JSONDecodeError, KeyError) as e:
|
72
|
-
logger.warning(
|
93
|
+
logger.warning(
|
94
|
+
f"[single-user] Error loading credentials from {filepath}: {e}"
|
95
|
+
)
|
73
96
|
continue
|
74
97
|
|
75
98
|
logger.info(f"[single-user] No valid credentials found in {base_dir}")
|
76
99
|
return None
|
77
100
|
|
78
|
-
|
101
|
+
|
102
|
+
def _get_user_credential_path(
|
103
|
+
user_google_email: str, base_dir: str = DEFAULT_CREDENTIALS_DIR
|
104
|
+
) -> str:
|
79
105
|
"""Constructs the path to a user's credential file."""
|
80
106
|
if not os.path.exists(base_dir):
|
81
107
|
os.makedirs(base_dir)
|
82
108
|
logger.info(f"Created credentials directory: {base_dir}")
|
83
109
|
return os.path.join(base_dir, f"{user_google_email}.json")
|
84
110
|
|
85
|
-
|
111
|
+
|
112
|
+
def save_credentials_to_file(
|
113
|
+
user_google_email: str,
|
114
|
+
credentials: Credentials,
|
115
|
+
base_dir: str = DEFAULT_CREDENTIALS_DIR,
|
116
|
+
):
|
86
117
|
"""Saves user credentials to a file."""
|
87
118
|
creds_path = _get_user_credential_path(user_google_email, base_dir)
|
88
119
|
creds_data = {
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
120
|
+
"token": credentials.token,
|
121
|
+
"refresh_token": credentials.refresh_token,
|
122
|
+
"token_uri": credentials.token_uri,
|
123
|
+
"client_id": credentials.client_id,
|
124
|
+
"client_secret": credentials.client_secret,
|
125
|
+
"scopes": credentials.scopes,
|
126
|
+
"expiry": credentials.expiry.isoformat() if credentials.expiry else None,
|
96
127
|
}
|
97
128
|
try:
|
98
|
-
with open(creds_path,
|
129
|
+
with open(creds_path, "w") as f:
|
99
130
|
json.dump(creds_data, f)
|
100
131
|
logger.info(f"Credentials saved for user {user_google_email} to {creds_path}")
|
101
132
|
except IOError as e:
|
102
|
-
logger.error(
|
133
|
+
logger.error(
|
134
|
+
f"Error saving credentials for user {user_google_email} to {creds_path}: {e}"
|
135
|
+
)
|
103
136
|
raise
|
104
137
|
|
138
|
+
|
105
139
|
def save_credentials_to_session(session_id: str, credentials: Credentials):
|
106
140
|
"""Saves user credentials to the in-memory session cache."""
|
107
141
|
_SESSION_CREDENTIALS_CACHE[session_id] = credentials
|
108
142
|
logger.debug(f"Credentials saved to session cache for session_id: {session_id}")
|
109
143
|
|
110
|
-
|
144
|
+
|
145
|
+
def load_credentials_from_file(
|
146
|
+
user_google_email: str, base_dir: str = DEFAULT_CREDENTIALS_DIR
|
147
|
+
) -> Optional[Credentials]:
|
111
148
|
"""Loads user credentials from a file."""
|
112
149
|
creds_path = _get_user_credential_path(user_google_email, base_dir)
|
113
150
|
if not os.path.exists(creds_path):
|
114
|
-
logger.info(
|
151
|
+
logger.info(
|
152
|
+
f"No credentials file found for user {user_google_email} at {creds_path}"
|
153
|
+
)
|
115
154
|
return None
|
116
155
|
|
117
156
|
try:
|
118
|
-
with open(creds_path,
|
157
|
+
with open(creds_path, "r") as f:
|
119
158
|
creds_data = json.load(f)
|
120
159
|
|
121
160
|
# Parse expiry if present
|
122
161
|
expiry = None
|
123
|
-
if creds_data.get(
|
162
|
+
if creds_data.get("expiry"):
|
124
163
|
try:
|
125
164
|
from datetime import datetime
|
126
|
-
|
165
|
+
|
166
|
+
expiry = datetime.fromisoformat(creds_data["expiry"])
|
127
167
|
except (ValueError, TypeError) as e:
|
128
|
-
logger.warning(
|
168
|
+
logger.warning(
|
169
|
+
f"Could not parse expiry time for {user_google_email}: {e}"
|
170
|
+
)
|
129
171
|
|
130
172
|
credentials = Credentials(
|
131
|
-
token=creds_data.get(
|
132
|
-
refresh_token=creds_data.get(
|
133
|
-
token_uri=creds_data.get(
|
134
|
-
client_id=creds_data.get(
|
135
|
-
client_secret=creds_data.get(
|
136
|
-
scopes=creds_data.get(
|
137
|
-
expiry=expiry
|
173
|
+
token=creds_data.get("token"),
|
174
|
+
refresh_token=creds_data.get("refresh_token"),
|
175
|
+
token_uri=creds_data.get("token_uri"),
|
176
|
+
client_id=creds_data.get("client_id"),
|
177
|
+
client_secret=creds_data.get("client_secret"),
|
178
|
+
scopes=creds_data.get("scopes"),
|
179
|
+
expiry=expiry,
|
180
|
+
)
|
181
|
+
logger.debug(
|
182
|
+
f"Credentials loaded for user {user_google_email} from {creds_path}"
|
138
183
|
)
|
139
|
-
logger.debug(f"Credentials loaded for user {user_google_email} from {creds_path}")
|
140
184
|
return credentials
|
141
185
|
except (IOError, json.JSONDecodeError, KeyError) as e:
|
142
|
-
logger.error(
|
186
|
+
logger.error(
|
187
|
+
f"Error loading or parsing credentials for user {user_google_email} from {creds_path}: {e}"
|
188
|
+
)
|
143
189
|
return None
|
144
190
|
|
191
|
+
|
145
192
|
def load_credentials_from_session(session_id: str) -> Optional[Credentials]:
|
146
193
|
"""Loads user credentials from the in-memory session cache."""
|
147
194
|
credentials = _SESSION_CREDENTIALS_CACHE.get(session_id)
|
148
195
|
if credentials:
|
149
|
-
logger.debug(
|
196
|
+
logger.debug(
|
197
|
+
f"Credentials loaded from session cache for session_id: {session_id}"
|
198
|
+
)
|
150
199
|
else:
|
151
|
-
logger.debug(
|
200
|
+
logger.debug(
|
201
|
+
f"No credentials found in session cache for session_id: {session_id}"
|
202
|
+
)
|
152
203
|
return credentials
|
153
204
|
|
205
|
+
|
154
206
|
def load_client_secrets_from_env() -> Optional[Dict[str, Any]]:
|
155
207
|
"""
|
156
208
|
Loads the client secrets from environment variables.
|
@@ -175,7 +227,7 @@ def load_client_secrets_from_env() -> Optional[Dict[str, Any]]:
|
|
175
227
|
"client_secret": client_secret,
|
176
228
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
177
229
|
"token_uri": "https://oauth2.googleapis.com/token",
|
178
|
-
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs"
|
230
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
179
231
|
}
|
180
232
|
|
181
233
|
# Add redirect_uri if provided via environment variable
|
@@ -191,6 +243,7 @@ def load_client_secrets_from_env() -> Optional[Dict[str, Any]]:
|
|
191
243
|
logger.debug("OAuth client credentials not found in environment variables")
|
192
244
|
return None
|
193
245
|
|
246
|
+
|
194
247
|
def load_client_secrets(client_secrets_path: str) -> Dict[str, Any]:
|
195
248
|
"""
|
196
249
|
Loads the client secrets from environment variables (preferred) or from the client secrets file.
|
@@ -217,21 +270,29 @@ def load_client_secrets(client_secrets_path: str) -> Dict[str, Any]:
|
|
217
270
|
|
218
271
|
# Fall back to loading from file
|
219
272
|
try:
|
220
|
-
with open(client_secrets_path,
|
273
|
+
with open(client_secrets_path, "r") as f:
|
221
274
|
client_config = json.load(f)
|
222
275
|
# The file usually contains a top-level key like "web" or "installed"
|
223
276
|
if "web" in client_config:
|
224
|
-
logger.info(
|
277
|
+
logger.info(
|
278
|
+
f"Loaded OAuth client credentials from file: {client_secrets_path}"
|
279
|
+
)
|
225
280
|
return client_config["web"]
|
226
281
|
elif "installed" in client_config:
|
227
|
-
logger.info(
|
282
|
+
logger.info(
|
283
|
+
f"Loaded OAuth client credentials from file: {client_secrets_path}"
|
284
|
+
)
|
228
285
|
return client_config["installed"]
|
229
286
|
else:
|
230
|
-
|
231
|
-
|
287
|
+
logger.error(
|
288
|
+
f"Client secrets file {client_secrets_path} has unexpected format."
|
289
|
+
)
|
290
|
+
raise ValueError("Invalid client secrets file format")
|
232
291
|
except (IOError, json.JSONDecodeError) as e:
|
233
292
|
logger.error(f"Error loading client secrets file {client_secrets_path}: {e}")
|
234
293
|
raise
|
294
|
+
|
295
|
+
|
235
296
|
def check_client_secrets() -> Optional[str]:
|
236
297
|
"""
|
237
298
|
Checks for the presence of OAuth client secrets, either as environment
|
@@ -242,45 +303,53 @@ def check_client_secrets() -> Optional[str]:
|
|
242
303
|
"""
|
243
304
|
env_config = load_client_secrets_from_env()
|
244
305
|
if not env_config and not os.path.exists(CONFIG_CLIENT_SECRETS_PATH):
|
245
|
-
logger.error(
|
306
|
+
logger.error(
|
307
|
+
f"OAuth client credentials not found. No environment variables set and no file at {CONFIG_CLIENT_SECRETS_PATH}"
|
308
|
+
)
|
246
309
|
return f"OAuth client credentials not found. Please set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET environment variables or provide a client secrets file at {CONFIG_CLIENT_SECRETS_PATH}."
|
247
310
|
return None
|
248
311
|
|
249
|
-
|
312
|
+
|
313
|
+
def create_oauth_flow(
|
314
|
+
scopes: List[str], redirect_uri: str, state: Optional[str] = None
|
315
|
+
) -> Flow:
|
250
316
|
"""Creates an OAuth flow using environment variables or client secrets file."""
|
251
317
|
# Try environment variables first
|
252
318
|
env_config = load_client_secrets_from_env()
|
253
319
|
if env_config:
|
254
320
|
# Use client config directly
|
255
321
|
flow = Flow.from_client_config(
|
256
|
-
env_config,
|
257
|
-
scopes=scopes,
|
258
|
-
redirect_uri=redirect_uri,
|
259
|
-
state=state
|
322
|
+
env_config, scopes=scopes, redirect_uri=redirect_uri, state=state
|
260
323
|
)
|
261
324
|
logger.debug("Created OAuth flow from environment variables")
|
262
325
|
return flow
|
263
326
|
|
264
327
|
# Fall back to file-based config
|
265
328
|
if not os.path.exists(CONFIG_CLIENT_SECRETS_PATH):
|
266
|
-
raise FileNotFoundError(
|
329
|
+
raise FileNotFoundError(
|
330
|
+
f"OAuth client secrets file not found at {CONFIG_CLIENT_SECRETS_PATH} and no environment variables set"
|
331
|
+
)
|
267
332
|
|
268
333
|
flow = Flow.from_client_secrets_file(
|
269
334
|
CONFIG_CLIENT_SECRETS_PATH,
|
270
335
|
scopes=scopes,
|
271
336
|
redirect_uri=redirect_uri,
|
272
|
-
state=state
|
337
|
+
state=state,
|
338
|
+
)
|
339
|
+
logger.debug(
|
340
|
+
f"Created OAuth flow from client secrets file: {CONFIG_CLIENT_SECRETS_PATH}"
|
273
341
|
)
|
274
|
-
logger.debug(f"Created OAuth flow from client secrets file: {CONFIG_CLIENT_SECRETS_PATH}")
|
275
342
|
return flow
|
276
343
|
|
344
|
+
|
277
345
|
# --- Core OAuth Logic ---
|
278
346
|
|
347
|
+
|
279
348
|
async def start_auth_flow(
|
280
349
|
mcp_session_id: Optional[str],
|
281
350
|
user_google_email: Optional[str],
|
282
|
-
service_name: str,
|
283
|
-
redirect_uri: str,
|
351
|
+
service_name: str, # e.g., "Google Calendar", "Gmail" for user messages
|
352
|
+
redirect_uri: str, # Added redirect_uri as a required parameter
|
284
353
|
) -> str:
|
285
354
|
"""
|
286
355
|
Initiates the Google OAuth flow and returns an actionable message for the user.
|
@@ -297,29 +366,47 @@ async def start_auth_flow(
|
|
297
366
|
Raises:
|
298
367
|
Exception: If the OAuth flow cannot be initiated.
|
299
368
|
"""
|
300
|
-
initial_email_provided = bool(
|
301
|
-
|
369
|
+
initial_email_provided = bool(
|
370
|
+
user_google_email
|
371
|
+
and user_google_email.strip()
|
372
|
+
and user_google_email.lower() != "default"
|
373
|
+
)
|
374
|
+
user_display_name = (
|
375
|
+
f"{service_name} for '{user_google_email}'"
|
376
|
+
if initial_email_provided
|
377
|
+
else service_name
|
378
|
+
)
|
302
379
|
|
303
|
-
logger.info(
|
380
|
+
logger.info(
|
381
|
+
f"[start_auth_flow] Initiating auth for {user_display_name} (session: {mcp_session_id}) with global SCOPES."
|
382
|
+
)
|
304
383
|
|
305
384
|
try:
|
306
|
-
if
|
307
|
-
|
308
|
-
|
385
|
+
if "OAUTHLIB_INSECURE_TRANSPORT" not in os.environ and (
|
386
|
+
"localhost" in redirect_uri or "127.0.0.1" in redirect_uri
|
387
|
+
): # Use passed redirect_uri
|
388
|
+
logger.warning(
|
389
|
+
"OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost/local development."
|
390
|
+
)
|
391
|
+
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
|
309
392
|
|
310
393
|
oauth_state = os.urandom(16).hex()
|
311
394
|
if mcp_session_id:
|
312
395
|
OAUTH_STATE_TO_SESSION_ID_MAP[oauth_state] = mcp_session_id
|
313
|
-
logger.info(
|
396
|
+
logger.info(
|
397
|
+
f"[start_auth_flow] Stored mcp_session_id '{mcp_session_id}' for oauth_state '{oauth_state}'."
|
398
|
+
)
|
314
399
|
|
315
400
|
flow = create_oauth_flow(
|
316
|
-
scopes=SCOPES,
|
317
|
-
redirect_uri=redirect_uri,
|
318
|
-
state=oauth_state
|
401
|
+
scopes=SCOPES, # Use global SCOPES
|
402
|
+
redirect_uri=redirect_uri, # Use passed redirect_uri
|
403
|
+
state=oauth_state,
|
319
404
|
)
|
320
405
|
|
321
|
-
auth_url, _ = flow.authorization_url(access_type=
|
322
|
-
logger.info(
|
406
|
+
auth_url, _ = flow.authorization_url(access_type="offline", prompt="consent")
|
407
|
+
logger.info(
|
408
|
+
f"Auth flow started for {user_display_name}. State: {oauth_state}. Advise user to visit: {auth_url}"
|
409
|
+
)
|
323
410
|
|
324
411
|
message_lines = [
|
325
412
|
f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n",
|
@@ -330,18 +417,28 @@ async def start_auth_flow(
|
|
330
417
|
"**LLM, after presenting the link, instruct the user as follows:**",
|
331
418
|
"1. Click the link and complete the authorization in their browser.",
|
332
419
|
]
|
333
|
-
session_info_for_llm =
|
420
|
+
session_info_for_llm = (
|
421
|
+
f" (this will link to your current session {mcp_session_id})"
|
422
|
+
if mcp_session_id
|
423
|
+
else ""
|
424
|
+
)
|
334
425
|
|
335
426
|
if not initial_email_provided:
|
336
|
-
message_lines.extend(
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
427
|
+
message_lines.extend(
|
428
|
+
[
|
429
|
+
f"2. After successful authorization{session_info_for_llm}, the browser page will display the authenticated email address.",
|
430
|
+
" **LLM: Instruct the user to provide you with this email address.**",
|
431
|
+
"3. Once you have the email, **retry their original command, ensuring you include this `user_google_email`.**",
|
432
|
+
]
|
433
|
+
)
|
341
434
|
else:
|
342
|
-
message_lines.append(
|
435
|
+
message_lines.append(
|
436
|
+
f"2. After successful authorization{session_info_for_llm}, **retry their original command**."
|
437
|
+
)
|
343
438
|
|
344
|
-
message_lines.append(
|
439
|
+
message_lines.append(
|
440
|
+
f"\nThe application will use the new credentials. If '{user_google_email}' was provided, it must match the authenticated account."
|
441
|
+
)
|
345
442
|
return "\n".join(message_lines)
|
346
443
|
|
347
444
|
except FileNotFoundError as e:
|
@@ -350,16 +447,22 @@ async def start_auth_flow(
|
|
350
447
|
raise Exception(error_text)
|
351
448
|
except Exception as e:
|
352
449
|
error_text = f"Could not initiate authentication for {user_display_name} due to an unexpected error: {str(e)}"
|
353
|
-
logger.error(
|
450
|
+
logger.error(
|
451
|
+
f"Failed to start the OAuth flow for {user_display_name}: {e}",
|
452
|
+
exc_info=True,
|
453
|
+
)
|
354
454
|
raise Exception(error_text)
|
355
455
|
|
456
|
+
|
356
457
|
def handle_auth_callback(
|
357
458
|
scopes: List[str],
|
358
459
|
authorization_response: str,
|
359
460
|
redirect_uri: str,
|
360
461
|
credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR,
|
361
462
|
session_id: Optional[str] = None,
|
362
|
-
client_secrets_path: Optional[
|
463
|
+
client_secrets_path: Optional[
|
464
|
+
str
|
465
|
+
] = None, # Deprecated: kept for backward compatibility
|
363
466
|
) -> Tuple[str, Credentials]:
|
364
467
|
"""
|
365
468
|
Handles the callback from Google, exchanges the code for credentials,
|
@@ -385,17 +488,18 @@ def handle_auth_callback(
|
|
385
488
|
try:
|
386
489
|
# Log deprecation warning if old parameter is used
|
387
490
|
if client_secrets_path:
|
388
|
-
logger.warning(
|
491
|
+
logger.warning(
|
492
|
+
"The 'client_secrets_path' parameter is deprecated. Use GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET environment variables instead."
|
493
|
+
)
|
389
494
|
|
390
495
|
# Allow HTTP for localhost in development
|
391
|
-
if
|
392
|
-
logger.warning(
|
393
|
-
|
496
|
+
if "OAUTHLIB_INSECURE_TRANSPORT" not in os.environ:
|
497
|
+
logger.warning(
|
498
|
+
"OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost development."
|
499
|
+
)
|
500
|
+
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
|
394
501
|
|
395
|
-
flow = create_oauth_flow(
|
396
|
-
scopes=scopes,
|
397
|
-
redirect_uri=redirect_uri
|
398
|
-
)
|
502
|
+
flow = create_oauth_flow(scopes=scopes, redirect_uri=redirect_uri)
|
399
503
|
|
400
504
|
# Exchange the authorization code for credentials
|
401
505
|
# Note: fetch_token will use the redirect_uri configured in the flow
|
@@ -405,11 +509,11 @@ def handle_auth_callback(
|
|
405
509
|
|
406
510
|
# Get user info to determine user_id (using email here)
|
407
511
|
user_info = get_user_info(credentials)
|
408
|
-
if not user_info or
|
409
|
-
|
410
|
-
|
512
|
+
if not user_info or "email" not in user_info:
|
513
|
+
logger.error("Could not retrieve user email from Google.")
|
514
|
+
raise ValueError("Failed to get user email for identification.")
|
411
515
|
|
412
|
-
user_google_email = user_info[
|
516
|
+
user_google_email = user_info["email"]
|
413
517
|
logger.info(f"Identified user_google_email: {user_google_email}")
|
414
518
|
|
415
519
|
# Save the credentials to file
|
@@ -421,16 +525,17 @@ def handle_auth_callback(
|
|
421
525
|
|
422
526
|
return user_google_email, credentials
|
423
527
|
|
424
|
-
except Exception as e:
|
528
|
+
except Exception as e: # Catch specific exceptions like FlowExchangeError if needed
|
425
529
|
logger.error(f"Error handling auth callback: {e}")
|
426
|
-
raise
|
530
|
+
raise # Re-raise for the caller
|
531
|
+
|
427
532
|
|
428
533
|
def get_credentials(
|
429
|
-
user_google_email: Optional[str],
|
534
|
+
user_google_email: Optional[str], # Can be None if relying on session_id
|
430
535
|
required_scopes: List[str],
|
431
536
|
client_secrets_path: Optional[str] = None,
|
432
537
|
credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR,
|
433
|
-
session_id: Optional[str] = None
|
538
|
+
session_id: Optional[str] = None,
|
434
539
|
) -> Optional[Credentials]:
|
435
540
|
"""
|
436
541
|
Retrieves stored credentials, prioritizing session, then file. Refreshes if necessary.
|
@@ -448,11 +553,15 @@ def get_credentials(
|
|
448
553
|
Valid Credentials object or None.
|
449
554
|
"""
|
450
555
|
# Check for single-user mode
|
451
|
-
if os.getenv(
|
452
|
-
logger.info(
|
556
|
+
if os.getenv("MCP_SINGLE_USER_MODE") == "1":
|
557
|
+
logger.info(
|
558
|
+
f"[get_credentials] Single-user mode: bypassing session mapping, finding any credentials"
|
559
|
+
)
|
453
560
|
credentials = _find_any_credentials(credentials_base_dir)
|
454
561
|
if not credentials:
|
455
|
-
logger.info(
|
562
|
+
logger.info(
|
563
|
+
f"[get_credentials] Single-user mode: No credentials found in {credentials_base_dir}"
|
564
|
+
)
|
456
565
|
return None
|
457
566
|
|
458
567
|
# In single-user mode, if user_google_email wasn't provided, try to get it from user info
|
@@ -460,11 +569,15 @@ def get_credentials(
|
|
460
569
|
if not user_google_email and credentials.valid:
|
461
570
|
try:
|
462
571
|
user_info = get_user_info(credentials)
|
463
|
-
if user_info and
|
464
|
-
user_google_email = user_info[
|
465
|
-
logger.debug(
|
572
|
+
if user_info and "email" in user_info:
|
573
|
+
user_google_email = user_info["email"]
|
574
|
+
logger.debug(
|
575
|
+
f"[get_credentials] Single-user mode: extracted user email {user_google_email} from credentials"
|
576
|
+
)
|
466
577
|
except Exception as e:
|
467
|
-
logger.debug(
|
578
|
+
logger.debug(
|
579
|
+
f"[get_credentials] Single-user mode: could not extract user email: {e}"
|
580
|
+
)
|
468
581
|
else:
|
469
582
|
credentials: Optional[Credentials] = None
|
470
583
|
|
@@ -472,61 +585,100 @@ def get_credentials(
|
|
472
585
|
if not session_id:
|
473
586
|
logger.debug("[get_credentials] No session_id provided")
|
474
587
|
|
475
|
-
logger.debug(
|
588
|
+
logger.debug(
|
589
|
+
f"[get_credentials] Called for user_google_email: '{user_google_email}', session_id: '{session_id}', required_scopes: {required_scopes}"
|
590
|
+
)
|
476
591
|
|
477
592
|
if session_id:
|
478
593
|
credentials = load_credentials_from_session(session_id)
|
479
594
|
if credentials:
|
480
|
-
logger.debug(
|
595
|
+
logger.debug(
|
596
|
+
f"[get_credentials] Loaded credentials from session for session_id '{session_id}'."
|
597
|
+
)
|
481
598
|
|
482
599
|
if not credentials and user_google_email:
|
483
|
-
logger.debug(
|
484
|
-
|
600
|
+
logger.debug(
|
601
|
+
f"[get_credentials] No session credentials, trying file for user_google_email '{user_google_email}'."
|
602
|
+
)
|
603
|
+
credentials = load_credentials_from_file(
|
604
|
+
user_google_email, credentials_base_dir
|
605
|
+
)
|
485
606
|
if credentials and session_id:
|
486
|
-
logger.debug(
|
487
|
-
|
607
|
+
logger.debug(
|
608
|
+
f"[get_credentials] Loaded from file for user '{user_google_email}', caching to session '{session_id}'."
|
609
|
+
)
|
610
|
+
save_credentials_to_session(
|
611
|
+
session_id, credentials
|
612
|
+
) # Cache for current session
|
488
613
|
|
489
614
|
if not credentials:
|
490
|
-
logger.info(
|
615
|
+
logger.info(
|
616
|
+
f"[get_credentials] No credentials found for user '{user_google_email}' or session '{session_id}'."
|
617
|
+
)
|
491
618
|
return None
|
492
619
|
|
493
|
-
logger.debug(
|
620
|
+
logger.debug(
|
621
|
+
f"[get_credentials] Credentials found. Scopes: {credentials.scopes}, Valid: {credentials.valid}, Expired: {credentials.expired}"
|
622
|
+
)
|
494
623
|
|
495
624
|
if not all(scope in credentials.scopes for scope in required_scopes):
|
496
|
-
logger.warning(
|
497
|
-
|
625
|
+
logger.warning(
|
626
|
+
f"[get_credentials] Credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}. User: '{user_google_email}', Session: '{session_id}'"
|
627
|
+
)
|
628
|
+
return None # Re-authentication needed for scopes
|
498
629
|
|
499
|
-
logger.debug(
|
630
|
+
logger.debug(
|
631
|
+
f"[get_credentials] Credentials have sufficient scopes. User: '{user_google_email}', Session: '{session_id}'"
|
632
|
+
)
|
500
633
|
|
501
634
|
if credentials.valid:
|
502
|
-
logger.debug(
|
635
|
+
logger.debug(
|
636
|
+
f"[get_credentials] Credentials are valid. User: '{user_google_email}', Session: '{session_id}'"
|
637
|
+
)
|
503
638
|
return credentials
|
504
639
|
elif credentials.expired and credentials.refresh_token:
|
505
|
-
logger.info(
|
640
|
+
logger.info(
|
641
|
+
f"[get_credentials] Credentials expired. Attempting refresh. User: '{user_google_email}', Session: '{session_id}'"
|
642
|
+
)
|
506
643
|
if not client_secrets_path:
|
507
|
-
|
508
|
-
|
644
|
+
logger.error(
|
645
|
+
"[get_credentials] Client secrets path required for refresh but not provided."
|
646
|
+
)
|
647
|
+
return None
|
509
648
|
try:
|
510
|
-
logger.debug(
|
649
|
+
logger.debug(
|
650
|
+
f"[get_credentials] Refreshing token using client_secrets_path: {client_secrets_path}"
|
651
|
+
)
|
511
652
|
# client_config = load_client_secrets(client_secrets_path) # Not strictly needed if creds have client_id/secret
|
512
653
|
credentials.refresh(Request())
|
513
|
-
logger.info(
|
654
|
+
logger.info(
|
655
|
+
f"[get_credentials] Credentials refreshed successfully. User: '{user_google_email}', Session: '{session_id}'"
|
656
|
+
)
|
514
657
|
|
515
658
|
# Save refreshed credentials
|
516
|
-
if user_google_email:
|
517
|
-
save_credentials_to_file(
|
518
|
-
|
659
|
+
if user_google_email: # Always save to file if email is known
|
660
|
+
save_credentials_to_file(
|
661
|
+
user_google_email, credentials, credentials_base_dir
|
662
|
+
)
|
663
|
+
if session_id: # Update session cache if it was the source or is active
|
519
664
|
save_credentials_to_session(session_id, credentials)
|
520
665
|
return credentials
|
521
666
|
except RefreshError as e:
|
522
|
-
logger.warning(
|
667
|
+
logger.warning(
|
668
|
+
f"[get_credentials] RefreshError - token expired/revoked: {e}. User: '{user_google_email}', Session: '{session_id}'"
|
669
|
+
)
|
523
670
|
# For RefreshError, we should return None to trigger reauthentication
|
524
671
|
return None
|
525
672
|
except Exception as e:
|
526
|
-
logger.error(
|
527
|
-
|
673
|
+
logger.error(
|
674
|
+
f"[get_credentials] Error refreshing credentials: {e}. User: '{user_google_email}', Session: '{session_id}'",
|
675
|
+
exc_info=True,
|
676
|
+
)
|
677
|
+
return None # Failed to refresh
|
528
678
|
else:
|
529
|
-
logger.warning(
|
679
|
+
logger.warning(
|
680
|
+
f"[get_credentials] Credentials invalid/cannot refresh. Valid: {credentials.valid}, Refresh Token: {credentials.refresh_token is not None}. User: '{user_google_email}', Session: '{session_id}'"
|
681
|
+
)
|
530
682
|
return None
|
531
683
|
|
532
684
|
|
@@ -538,7 +690,7 @@ def get_user_info(credentials: Credentials) -> Optional[Dict[str, Any]]:
|
|
538
690
|
try:
|
539
691
|
# Using googleapiclient discovery to get user info
|
540
692
|
# Requires 'google-api-python-client' library
|
541
|
-
service = build(
|
693
|
+
service = build("oauth2", "v2", credentials=credentials)
|
542
694
|
user_info = service.userinfo().get().execute()
|
543
695
|
logger.info(f"Successfully fetched user info: {user_info.get('email')}")
|
544
696
|
return user_info
|
@@ -553,18 +705,20 @@ def get_user_info(credentials: Credentials) -> Optional[Dict[str, Any]]:
|
|
553
705
|
|
554
706
|
# --- Centralized Google Service Authentication ---
|
555
707
|
|
708
|
+
|
556
709
|
class GoogleAuthenticationError(Exception):
|
557
710
|
"""Exception raised when Google authentication is required or fails."""
|
711
|
+
|
558
712
|
def __init__(self, message: str, auth_url: Optional[str] = None):
|
559
713
|
super().__init__(message)
|
560
714
|
self.auth_url = auth_url
|
561
715
|
|
562
716
|
|
563
717
|
async def get_authenticated_google_service(
|
564
|
-
service_name: str,
|
565
|
-
version: str,
|
566
|
-
tool_name: str,
|
567
|
-
user_google_email: str,
|
718
|
+
service_name: str, # "gmail", "calendar", "drive", "docs"
|
719
|
+
version: str, # "v1", "v3"
|
720
|
+
tool_name: str, # For logging/debugging
|
721
|
+
user_google_email: str, # Required - no more Optional
|
568
722
|
required_scopes: List[str],
|
569
723
|
) -> tuple[Any, str]:
|
570
724
|
"""
|
@@ -602,7 +756,6 @@ async def get_authenticated_google_service(
|
|
602
756
|
session_id=None, # Session ID not available in service layer
|
603
757
|
)
|
604
758
|
|
605
|
-
|
606
759
|
if not credentials or not credentials.valid:
|
607
760
|
logger.warning(
|
608
761
|
f"[{tool_name}] No valid credentials. Email: '{user_google_email}'."
|
@@ -637,8 +790,11 @@ async def get_authenticated_google_service(
|
|
637
790
|
if credentials and credentials.id_token:
|
638
791
|
try:
|
639
792
|
import jwt
|
793
|
+
|
640
794
|
# Decode without verification (just to get email for logging)
|
641
|
-
decoded_token = jwt.decode(
|
795
|
+
decoded_token = jwt.decode(
|
796
|
+
credentials.id_token, options={"verify_signature": False}
|
797
|
+
)
|
642
798
|
token_email = decoded_token.get("email")
|
643
799
|
if token_email:
|
644
800
|
log_user_email = token_email
|
@@ -646,12 +802,12 @@ async def get_authenticated_google_service(
|
|
646
802
|
except Exception as e:
|
647
803
|
logger.debug(f"[{tool_name}] Could not decode id_token: {e}")
|
648
804
|
|
649
|
-
logger.info(
|
805
|
+
logger.info(
|
806
|
+
f"[{tool_name}] Successfully authenticated {service_name} service for user: {log_user_email}"
|
807
|
+
)
|
650
808
|
return service, log_user_email
|
651
809
|
|
652
810
|
except Exception as e:
|
653
811
|
error_msg = f"[{tool_name}] Failed to build {service_name} service: {str(e)}"
|
654
812
|
logger.error(error_msg, exc_info=True)
|
655
813
|
raise GoogleAuthenticationError(error_msg)
|
656
|
-
|
657
|
-
|
core/utils.py
CHANGED
@@ -8,17 +8,21 @@ from typing import List, Optional
|
|
8
8
|
|
9
9
|
logger = logging.getLogger(__name__)
|
10
10
|
|
11
|
-
def check_credentials_directory_permissions(credentials_dir: str =
|
11
|
+
def check_credentials_directory_permissions(credentials_dir: str = None) -> None:
|
12
12
|
"""
|
13
13
|
Check if the service has appropriate permissions to create and write to the .credentials directory.
|
14
14
|
|
15
15
|
Args:
|
16
|
-
credentials_dir: Path to the credentials directory (default:
|
16
|
+
credentials_dir: Path to the credentials directory (default: uses get_default_credentials_dir())
|
17
17
|
|
18
18
|
Raises:
|
19
19
|
PermissionError: If the service lacks necessary permissions
|
20
20
|
OSError: If there are other file system issues
|
21
21
|
"""
|
22
|
+
if credentials_dir is None:
|
23
|
+
from auth.google_auth import get_default_credentials_dir
|
24
|
+
credentials_dir = get_default_credentials_dir()
|
25
|
+
|
22
26
|
try:
|
23
27
|
# Check if directory exists
|
24
28
|
if os.path.exists(credentials_dir):
|
@@ -32,12 +36,7 @@ def check_credentials_directory_permissions(credentials_dir: str = ".credentials
|
|
32
36
|
except (PermissionError, OSError) as e:
|
33
37
|
raise PermissionError(f"Cannot write to existing credentials directory '{os.path.abspath(credentials_dir)}': {e}")
|
34
38
|
else:
|
35
|
-
# Directory doesn't exist,
|
36
|
-
parent_dir = os.path.dirname(os.path.abspath(credentials_dir)) or "."
|
37
|
-
if not os.access(parent_dir, os.W_OK):
|
38
|
-
raise PermissionError(f"Cannot create credentials directory '{os.path.abspath(credentials_dir)}': insufficient permissions in parent directory '{parent_dir}'")
|
39
|
-
|
40
|
-
# Test creating the directory
|
39
|
+
# Directory doesn't exist, try to create it and its parent directories
|
41
40
|
try:
|
42
41
|
os.makedirs(credentials_dir, exist_ok=True)
|
43
42
|
# Test writing to the new directory
|
main.py
CHANGED
@@ -34,6 +34,13 @@ except Exception as e:
|
|
34
34
|
sys.stderr.write(f"CRITICAL: Failed to set up file logging to '{log_file_path}': {e}\n")
|
35
35
|
|
36
36
|
def safe_print(text):
|
37
|
+
# Don't print to stderr when running as MCP server via uvx to avoid JSON parsing errors
|
38
|
+
# Check if we're running as MCP server (no TTY and uvx in process name)
|
39
|
+
if not sys.stderr.isatty():
|
40
|
+
# Running as MCP server, suppress output to avoid JSON parsing errors
|
41
|
+
logger.debug(f"[MCP Server] {text}")
|
42
|
+
return
|
43
|
+
|
37
44
|
try:
|
38
45
|
print(text, file=sys.stderr)
|
39
46
|
except UnicodeEncodeError:
|
@@ -47,7 +54,7 @@ def main():
|
|
47
54
|
# Parse command line arguments
|
48
55
|
parser = argparse.ArgumentParser(description='Google Workspace MCP Server')
|
49
56
|
parser.add_argument('--single-user', action='store_true',
|
50
|
-
help='Run in single-user mode - bypass session mapping and use any credentials from
|
57
|
+
help='Run in single-user mode - bypass session mapping and use any credentials from the credentials directory')
|
51
58
|
parser.add_argument('--tools', nargs='*',
|
52
59
|
choices=['gmail', 'drive', 'calendar', 'docs', 'sheets', 'chat', 'forms', 'slides'],
|
53
60
|
help='Specify which tools to register. If not provided, all tools are registered.')
|
@@ -73,7 +80,7 @@ def main():
|
|
73
80
|
safe_print(f" 🔐 OAuth Callback: {base_uri}:{port}/oauth2callback")
|
74
81
|
safe_print(f" 👤 Mode: {'Single-user' if args.single_user else 'Multi-user'}")
|
75
82
|
safe_print(f" 🐍 Python: {sys.version.split()[0]}")
|
76
|
-
|
83
|
+
safe_print("")
|
77
84
|
|
78
85
|
# Import tool modules to register them with the MCP server via decorators
|
79
86
|
tool_imports = {
|
@@ -104,29 +111,29 @@ def main():
|
|
104
111
|
for tool in tools_to_import:
|
105
112
|
tool_imports[tool]()
|
106
113
|
safe_print(f" {tool_icons[tool]} {tool.title()} - Google {tool.title()} API integration")
|
107
|
-
|
114
|
+
safe_print("")
|
108
115
|
|
109
116
|
safe_print(f"📊 Configuration Summary:")
|
110
117
|
safe_print(f" 🔧 Tools Enabled: {len(tools_to_import)}/{len(tool_imports)}")
|
111
118
|
safe_print(f" 🔑 Auth Method: OAuth 2.0 with PKCE")
|
112
119
|
safe_print(f" 📝 Log Level: {logging.getLogger().getEffectiveLevel()}")
|
113
|
-
|
120
|
+
safe_print("")
|
114
121
|
|
115
122
|
# Set global single-user mode flag
|
116
123
|
if args.single_user:
|
117
124
|
os.environ['MCP_SINGLE_USER_MODE'] = '1'
|
118
125
|
safe_print("🔐 Single-user mode enabled")
|
119
|
-
|
126
|
+
safe_print("")
|
120
127
|
|
121
128
|
# Check credentials directory permissions before starting
|
122
129
|
try:
|
123
130
|
safe_print("🔍 Checking credentials directory permissions...")
|
124
131
|
check_credentials_directory_permissions()
|
125
132
|
safe_print("✅ Credentials directory permissions verified")
|
126
|
-
|
133
|
+
safe_print("")
|
127
134
|
except (PermissionError, OSError) as e:
|
128
135
|
safe_print(f"❌ Credentials directory permission check failed: {e}")
|
129
|
-
|
136
|
+
safe_print(" Please ensure the service has write permissions to create/access the credentials directory")
|
130
137
|
logger.error(f"Failed credentials directory permission check: {e}")
|
131
138
|
sys.exit(1)
|
132
139
|
|
@@ -141,12 +148,12 @@ def main():
|
|
141
148
|
# Start minimal OAuth callback server for stdio mode
|
142
149
|
from auth.oauth_callback_server import ensure_oauth_callback_available
|
143
150
|
if ensure_oauth_callback_available('stdio', port, base_uri):
|
144
|
-
|
151
|
+
safe_print(f" OAuth callback server started on {base_uri}:{port}/oauth2callback")
|
145
152
|
else:
|
146
153
|
safe_print(" ⚠️ Warning: Failed to start OAuth callback server")
|
147
154
|
|
148
|
-
|
149
|
-
|
155
|
+
safe_print(" Ready for MCP connections!")
|
156
|
+
safe_print("")
|
150
157
|
|
151
158
|
if args.transport == 'streamable-http':
|
152
159
|
# The server is already configured with port and server_url in core/server.py
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: workspace-mcp
|
3
|
-
Version: 1.0
|
3
|
+
Version: 1.1.0
|
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
|
@@ -1,6 +1,6 @@
|
|
1
|
-
main.py,sha256=
|
1
|
+
main.py,sha256=2WLURMeCnoVws_OJOj2dm6yz7cegYJl5FqzYBFa2YOI,7513
|
2
2
|
auth/__init__.py,sha256=gPCU3GE-SLy91S3D3CbX-XfKBm6hteK_VSPKx7yjT5s,42
|
3
|
-
auth/google_auth.py,sha256=
|
3
|
+
auth/google_auth.py,sha256=JiGrHFpzhuxQgUNumZtAbyl8HTisDVdnvVFeSqpkCfg,32939
|
4
4
|
auth/oauth_callback_server.py,sha256=igrur3fkZSY0bawufrH4AN9fMNpobUdAUp1BG7AQC6w,9341
|
5
5
|
auth/oauth_responses.py,sha256=qbirSB4d7mBRKcJKqGLrJxRAPaLHqObf9t-VMAq6UKA,7020
|
6
6
|
auth/scopes.py,sha256=kMRdFN0wLyipFkp7IitTHs-M6zhZD-oieVd7fylueBc,3320
|
@@ -9,7 +9,7 @@ core/__init__.py,sha256=AHVKdPl6v4lUFm2R-KuGuAgEmCyfxseMeLGtntMcqCs,43
|
|
9
9
|
core/comments.py,sha256=n-S84v5N5x3LbL45vGUerERhNPYvuSlugpOboYtPGgw,11328
|
10
10
|
core/context.py,sha256=zNgPXf9EO2EMs9sQkfKiywoy6sEOksVNgOrJMA_c30Y,768
|
11
11
|
core/server.py,sha256=8A5_o6RCZ3hhsAiCszZhHiUJbVVrxJLspcvCiMmt27Q,9265
|
12
|
-
core/utils.py,sha256=
|
12
|
+
core/utils.py,sha256=sUNPhM0xh3tqgyCZxTcoje37Et-pbNJTksybTatDyho,10127
|
13
13
|
gcalendar/__init__.py,sha256=D5fSdAwbeomoaj7XAdxSnIy-NVKNkpExs67175bOtfc,46
|
14
14
|
gcalendar/calendar_tools.py,sha256=SIiSJRxG3G9KsScow0pYwew600_PdtFqlOo-y2vXQRo,22144
|
15
15
|
gchat/__init__.py,sha256=XBjH4SbtULfZHgFCxk3moel5XqG599HCgZWl_veIncg,88
|
@@ -26,9 +26,9 @@ gsheets/__init__.py,sha256=jFfhD52w_EOVw6N5guf_dIc9eP2khW_eS9UAPJg_K3k,446
|
|
26
26
|
gsheets/sheets_tools.py,sha256=TVlJ-jcIvJ_sJt8xO4-sBWIshb8rabJhjTmZfzHIJsU,11898
|
27
27
|
gslides/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
28
28
|
gslides/slides_tools.py,sha256=wil3XRyUMzUbpBUMqis0CW5eRuwOrP0Lp7-6WbF4QVU,10117
|
29
|
-
workspace_mcp-1.0.
|
30
|
-
workspace_mcp-1.0.
|
31
|
-
workspace_mcp-1.0.
|
32
|
-
workspace_mcp-1.0.
|
33
|
-
workspace_mcp-1.0.
|
34
|
-
workspace_mcp-1.0.
|
29
|
+
workspace_mcp-1.1.0.dist-info/licenses/LICENSE,sha256=bB8L7rIyRy5o-WHxGgvRuY8hUTzNu4h3DTkvyV8XFJo,1070
|
30
|
+
workspace_mcp-1.1.0.dist-info/METADATA,sha256=uyhkzbPiL3mgDBD2wK_CeEBVvkFaAY460yxf5wRtk-c,19406
|
31
|
+
workspace_mcp-1.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
32
|
+
workspace_mcp-1.1.0.dist-info/entry_points.txt,sha256=kPiEfOTuf-ptDM0Rf2OlyrFudGW7hCZGg4MCn2Foxs4,44
|
33
|
+
workspace_mcp-1.1.0.dist-info/top_level.txt,sha256=Y8mAkTitLNE2zZEJ-DbqR9R7Cs1V1MMf-UploVdOvlw,73
|
34
|
+
workspace_mcp-1.1.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|