workspace-mcp 1.0.1__py3-none-any.whl → 1.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auth/google_auth.py CHANGED
@@ -27,7 +27,7 @@ DEFAULT_CREDENTIALS_DIR = ".credentials"
27
27
  # This should be more robust in a production system once OAuth2.1 is implemented in client.
28
28
  _SESSION_CREDENTIALS_CACHE: Dict[str, Credentials] = {}
29
29
  # Centralized Client Secrets Path Logic
30
- _client_secrets_env = os.getenv("GOOGLE_CLIENT_SECRETS")
30
+ _client_secrets_env = os.getenv("GOOGLE_CLIENT_SECRET_PATH") or os.getenv("GOOGLE_CLIENT_SECRETS")
31
31
  if _client_secrets_env:
32
32
  CONFIG_CLIENT_SECRETS_PATH = _client_secrets_env
33
33
  else:
@@ -151,22 +151,128 @@ def load_credentials_from_session(session_id: str) -> Optional[Credentials]:
151
151
  logger.debug(f"No credentials found in session cache for session_id: {session_id}")
152
152
  return credentials
153
153
 
154
+ def load_client_secrets_from_env() -> Optional[Dict[str, Any]]:
155
+ """
156
+ Loads the client secrets from environment variables.
157
+
158
+ Environment variables used:
159
+ - GOOGLE_OAUTH_CLIENT_ID: OAuth 2.0 client ID
160
+ - GOOGLE_OAUTH_CLIENT_SECRET: OAuth 2.0 client secret
161
+ - GOOGLE_OAUTH_REDIRECT_URI: (optional) OAuth redirect URI
162
+
163
+ Returns:
164
+ Client secrets configuration dict compatible with Google OAuth library,
165
+ or None if required environment variables are not set.
166
+ """
167
+ client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
168
+ client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
169
+ redirect_uri = os.getenv("GOOGLE_OAUTH_REDIRECT_URI")
170
+
171
+ if client_id and client_secret:
172
+ # Create config structure that matches Google client secrets format
173
+ web_config = {
174
+ "client_id": client_id,
175
+ "client_secret": client_secret,
176
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
177
+ "token_uri": "https://oauth2.googleapis.com/token",
178
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs"
179
+ }
180
+
181
+ # Add redirect_uri if provided via environment variable
182
+ if redirect_uri:
183
+ web_config["redirect_uris"] = [redirect_uri]
184
+
185
+ # Return the full config structure expected by Google OAuth library
186
+ config = {"web": web_config}
187
+
188
+ logger.info("Loaded OAuth client credentials from environment variables")
189
+ return config
190
+
191
+ logger.debug("OAuth client credentials not found in environment variables")
192
+ return None
193
+
154
194
  def load_client_secrets(client_secrets_path: str) -> Dict[str, Any]:
155
- """Loads the client secrets file."""
195
+ """
196
+ Loads the client secrets from environment variables (preferred) or from the client secrets file.
197
+
198
+ Priority order:
199
+ 1. Environment variables (GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET)
200
+ 2. File-based credentials at the specified path
201
+
202
+ Args:
203
+ client_secrets_path: Path to the client secrets JSON file (used as fallback)
204
+
205
+ Returns:
206
+ Client secrets configuration dict
207
+
208
+ Raises:
209
+ ValueError: If client secrets file has invalid format
210
+ IOError: If file cannot be read and no environment variables are set
211
+ """
212
+ # First, try to load from environment variables
213
+ env_config = load_client_secrets_from_env()
214
+ if env_config:
215
+ # Extract the "web" config from the environment structure
216
+ return env_config["web"]
217
+
218
+ # Fall back to loading from file
156
219
  try:
157
220
  with open(client_secrets_path, 'r') as f:
158
221
  client_config = json.load(f)
159
222
  # The file usually contains a top-level key like "web" or "installed"
160
223
  if "web" in client_config:
224
+ logger.info(f"Loaded OAuth client credentials from file: {client_secrets_path}")
161
225
  return client_config["web"]
162
226
  elif "installed" in client_config:
163
- return client_config["installed"]
227
+ logger.info(f"Loaded OAuth client credentials from file: {client_secrets_path}")
228
+ return client_config["installed"]
164
229
  else:
165
230
  logger.error(f"Client secrets file {client_secrets_path} has unexpected format.")
166
231
  raise ValueError("Invalid client secrets file format")
167
232
  except (IOError, json.JSONDecodeError) as e:
168
233
  logger.error(f"Error loading client secrets file {client_secrets_path}: {e}")
169
234
  raise
235
+ def check_client_secrets() -> Optional[str]:
236
+ """
237
+ Checks for the presence of OAuth client secrets, either as environment
238
+ variables or as a file.
239
+
240
+ Returns:
241
+ An error message string if secrets are not found, otherwise None.
242
+ """
243
+ env_config = load_client_secrets_from_env()
244
+ if not env_config and not os.path.exists(CONFIG_CLIENT_SECRETS_PATH):
245
+ logger.error(f"OAuth client credentials not found. No environment variables set and no file at {CONFIG_CLIENT_SECRETS_PATH}")
246
+ 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
+ return None
248
+
249
+ def create_oauth_flow(scopes: List[str], redirect_uri: str, state: Optional[str] = None) -> Flow:
250
+ """Creates an OAuth flow using environment variables or client secrets file."""
251
+ # Try environment variables first
252
+ env_config = load_client_secrets_from_env()
253
+ if env_config:
254
+ # Use client config directly
255
+ flow = Flow.from_client_config(
256
+ env_config,
257
+ scopes=scopes,
258
+ redirect_uri=redirect_uri,
259
+ state=state
260
+ )
261
+ logger.debug("Created OAuth flow from environment variables")
262
+ return flow
263
+
264
+ # Fall back to file-based config
265
+ if not os.path.exists(CONFIG_CLIENT_SECRETS_PATH):
266
+ raise FileNotFoundError(f"OAuth client secrets file not found at {CONFIG_CLIENT_SECRETS_PATH} and no environment variables set")
267
+
268
+ flow = Flow.from_client_secrets_file(
269
+ CONFIG_CLIENT_SECRETS_PATH,
270
+ scopes=scopes,
271
+ redirect_uri=redirect_uri,
272
+ state=state
273
+ )
274
+ logger.debug(f"Created OAuth flow from client secrets file: {CONFIG_CLIENT_SECRETS_PATH}")
275
+ return flow
170
276
 
171
277
  # --- Core OAuth Logic ---
172
278
 
@@ -206,8 +312,7 @@ async def start_auth_flow(
206
312
  OAUTH_STATE_TO_SESSION_ID_MAP[oauth_state] = mcp_session_id
207
313
  logger.info(f"[start_auth_flow] Stored mcp_session_id '{mcp_session_id}' for oauth_state '{oauth_state}'.")
208
314
 
209
- flow = Flow.from_client_secrets_file(
210
- CONFIG_CLIENT_SECRETS_PATH, # Use module constant
315
+ flow = create_oauth_flow(
211
316
  scopes=SCOPES, # Use global SCOPES
212
317
  redirect_uri=redirect_uri, # Use passed redirect_uri
213
318
  state=oauth_state
@@ -240,7 +345,7 @@ async def start_auth_flow(
240
345
  return "\n".join(message_lines)
241
346
 
242
347
  except FileNotFoundError as e:
243
- error_text = f"OAuth client secrets file not found: {e}. Please ensure '{CONFIG_CLIENT_SECRETS_PATH}' is correctly configured."
348
+ error_text = f"OAuth client credentials not found: {e}. Please either:\n1. Set environment variables: GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET\n2. Ensure '{CONFIG_CLIENT_SECRETS_PATH}' file exists"
244
349
  logger.error(error_text, exc_info=True)
245
350
  raise Exception(error_text)
246
351
  except Exception as e:
@@ -249,12 +354,12 @@ async def start_auth_flow(
249
354
  raise Exception(error_text)
250
355
 
251
356
  def handle_auth_callback(
252
- client_secrets_path: str,
253
357
  scopes: List[str],
254
358
  authorization_response: str,
255
- redirect_uri: str, # Made redirect_uri a required parameter
359
+ redirect_uri: str,
256
360
  credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR,
257
- session_id: Optional[str] = None
361
+ session_id: Optional[str] = None,
362
+ client_secrets_path: Optional[str] = None # Deprecated: kept for backward compatibility
258
363
  ) -> Tuple[str, Credentials]:
259
364
  """
260
365
  Handles the callback from Google, exchanges the code for credentials,
@@ -262,12 +367,12 @@ def handle_auth_callback(
262
367
  and returns them.
263
368
 
264
369
  Args:
265
- client_secrets_path: Path to the Google client secrets JSON file.
266
370
  scopes: List of OAuth scopes requested.
267
371
  authorization_response: The full callback URL from Google.
268
372
  redirect_uri: The redirect URI.
269
373
  credentials_base_dir: Base directory for credential files.
270
374
  session_id: Optional MCP session ID to associate with the credentials.
375
+ client_secrets_path: (Deprecated) Path to client secrets file. Ignored if environment variables are set.
271
376
 
272
377
  Returns:
273
378
  A tuple containing the user_google_email and the obtained Credentials object.
@@ -278,13 +383,16 @@ def handle_auth_callback(
278
383
  HttpError: If fetching user info fails.
279
384
  """
280
385
  try:
386
+ # Log deprecation warning if old parameter is used
387
+ if client_secrets_path:
388
+ logger.warning("The 'client_secrets_path' parameter is deprecated. Use GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET environment variables instead.")
389
+
281
390
  # Allow HTTP for localhost in development
282
391
  if 'OAUTHLIB_INSECURE_TRANSPORT' not in os.environ:
283
392
  logger.warning("OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost development.")
284
393
  os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'
285
394
 
286
- flow = Flow.from_client_secrets_file(
287
- client_secrets_path,
395
+ flow = create_oauth_flow(
288
396
  scopes=scopes,
289
397
  redirect_uri=redirect_uri
290
398
  )
@@ -15,7 +15,7 @@ import socket
15
15
  from fastapi import FastAPI, Request
16
16
  import uvicorn
17
17
 
18
- from auth.google_auth import handle_auth_callback, CONFIG_CLIENT_SECRETS_PATH
18
+ from auth.google_auth import handle_auth_callback, check_client_secrets
19
19
  from auth.scopes import OAUTH_STATE_TO_SESSION_ID_MAP, SCOPES
20
20
  from auth.oauth_responses import create_error_response, create_success_response, create_server_error_response
21
21
 
@@ -59,6 +59,11 @@ class MinimalOAuthServer:
59
59
  return create_error_response(error_message)
60
60
 
61
61
  try:
62
+ # Check if we have credentials available (environment variables or file)
63
+ error_message = check_client_secrets()
64
+ if error_message:
65
+ return create_server_error_response(error_message)
66
+
62
67
  logger.info(f"OAuth callback: Received code (state: {state}). Attempting to exchange for tokens.")
63
68
 
64
69
  mcp_session_id: Optional[str] = OAUTH_STATE_TO_SESSION_ID_MAP.pop(state, None)
@@ -69,7 +74,6 @@ class MinimalOAuthServer:
69
74
 
70
75
  # Exchange code for credentials
71
76
  verified_user_id, credentials = handle_auth_callback(
72
- client_secrets_path=CONFIG_CLIENT_SECRETS_PATH,
73
77
  scopes=SCOPES,
74
78
  authorization_response=str(request.url),
75
79
  redirect_uri=f"{self.base_uri}:{self.port}/oauth2callback",
@@ -106,7 +110,7 @@ class MinimalOAuthServer:
106
110
  hostname = parsed_uri.hostname or 'localhost'
107
111
  except Exception:
108
112
  hostname = 'localhost'
109
-
113
+
110
114
  try:
111
115
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
112
116
  s.bind((hostname, self.port))
auth/service_decorator.py CHANGED
@@ -193,29 +193,37 @@ def require_google_service(
193
193
  # Original authentication logic is handled automatically
194
194
  """
195
195
  def decorator(func: Callable) -> Callable:
196
+ # Inspect the original function signature
197
+ original_sig = inspect.signature(func)
198
+ params = list(original_sig.parameters.values())
199
+
200
+ # The decorated function must have 'service' as its first parameter.
201
+ if not params or params[0].name != 'service':
202
+ raise TypeError(
203
+ f"Function '{func.__name__}' decorated with @require_google_service "
204
+ "must have 'service' as its first parameter."
205
+ )
206
+
207
+ # Create a new signature for the wrapper that excludes the 'service' parameter.
208
+ # This is the signature that FastMCP will see.
209
+ wrapper_sig = original_sig.replace(parameters=params[1:])
210
+
196
211
  @wraps(func)
197
212
  async def wrapper(*args, **kwargs):
198
- # Extract user_google_email from function parameters
199
- sig = inspect.signature(func)
200
- param_names = list(sig.parameters.keys())
213
+ # Note: `args` and `kwargs` are now the arguments for the *wrapper*,
214
+ # which does not include 'service'.
201
215
 
202
- # Find user_google_email parameter
203
- user_google_email = None
204
- if 'user_google_email' in kwargs:
205
- user_google_email = kwargs['user_google_email']
206
- else:
207
- # Look for user_google_email in positional args
208
- try:
209
- user_email_index = param_names.index('user_google_email')
210
- if user_email_index < len(args):
211
- user_google_email = args[user_email_index]
212
- except ValueError:
213
- pass
216
+ # Extract user_google_email from the arguments passed to the wrapper
217
+ bound_args = wrapper_sig.bind(*args, **kwargs)
218
+ bound_args.apply_defaults()
219
+ user_google_email = bound_args.arguments.get('user_google_email')
214
220
 
215
221
  if not user_google_email:
216
- raise Exception("user_google_email parameter is required but not found")
222
+ # This should ideally not be reached if 'user_google_email' is a required parameter
223
+ # in the function signature, but it's a good safeguard.
224
+ raise Exception("'user_google_email' parameter is required but was not found.")
217
225
 
218
- # Get service configuration
226
+ # Get service configuration from the decorator's arguments
219
227
  if service_type not in SERVICE_CONFIGS:
220
228
  raise Exception(f"Unknown service type: {service_type}")
221
229
 
@@ -226,7 +234,7 @@ def require_google_service(
226
234
  # Resolve scopes
227
235
  resolved_scopes = _resolve_scopes(scopes)
228
236
 
229
- # Check cache first if enabled
237
+ # --- Service Caching and Authentication Logic (largely unchanged) ---
230
238
  service = None
231
239
  actual_user_email = user_google_email
232
240
 
@@ -236,7 +244,6 @@ def require_google_service(
236
244
  if cached_result:
237
245
  service, actual_user_email = cached_result
238
246
 
239
- # If not cached, authenticate
240
247
  if service is None:
241
248
  try:
242
249
  tool_name = func.__name__
@@ -247,30 +254,22 @@ def require_google_service(
247
254
  user_google_email=user_google_email,
248
255
  required_scopes=resolved_scopes,
249
256
  )
250
-
251
- # Cache the service if caching is enabled
252
257
  if cache_enabled:
253
258
  cache_key = _get_cache_key(user_google_email, service_name, service_version, resolved_scopes)
254
259
  _cache_service(cache_key, service, actual_user_email)
255
-
256
260
  except GoogleAuthenticationError as e:
257
261
  raise Exception(str(e))
258
262
 
259
- # Inject service as first parameter
260
- if 'service' in param_names:
261
- kwargs['service'] = service
262
- else:
263
- # Insert service as first positional argument
264
- args = (service,) + args
265
-
266
- # Call the original function with refresh error handling
263
+ # --- Call the original function with the service object injected ---
267
264
  try:
268
- return await func(*args, **kwargs)
265
+ # Prepend the fetched service object to the original arguments
266
+ return await func(service, *args, **kwargs)
269
267
  except RefreshError as e:
270
- # Handle token refresh errors gracefully
271
268
  error_message = _handle_token_refresh_error(e, actual_user_email, service_name)
272
269
  raise Exception(error_message)
273
270
 
271
+ # Set the wrapper's signature to the one without 'service'
272
+ wrapper.__signature__ = wrapper_sig
274
273
  return wrapper
275
274
  return decorator
276
275
 
core/context.py ADDED
@@ -0,0 +1,22 @@
1
+ # core/context.py
2
+ import contextvars
3
+ from typing import Optional
4
+
5
+ # Context variable to hold injected credentials for the life of a single request.
6
+ _injected_oauth_credentials = contextvars.ContextVar(
7
+ "injected_oauth_credentials", default=None
8
+ )
9
+
10
+ def get_injected_oauth_credentials():
11
+ """
12
+ Retrieve injected OAuth credentials for the current request context.
13
+ This is called by the authentication layer to check for request-scoped credentials.
14
+ """
15
+ return _injected_oauth_credentials.get()
16
+
17
+ def set_injected_oauth_credentials(credentials: Optional[dict]):
18
+ """
19
+ Set or clear the injected OAuth credentials for the current request context.
20
+ This is called by the service decorator.
21
+ """
22
+ _injected_oauth_credentials.set(credentials)
core/server.py CHANGED
@@ -11,7 +11,7 @@ from mcp import types
11
11
  from mcp.server.fastmcp import FastMCP
12
12
  from starlette.requests import Request
13
13
 
14
- from auth.google_auth import handle_auth_callback, start_auth_flow, CONFIG_CLIENT_SECRETS_PATH
14
+ from auth.google_auth import handle_auth_callback, start_auth_flow, check_client_secrets
15
15
  from auth.oauth_callback_server import get_oauth_redirect_uri, ensure_oauth_callback_available
16
16
  from auth.oauth_responses import create_error_response, create_success_response, create_server_error_response
17
17
 
@@ -119,11 +119,10 @@ async def oauth2_callback(request: Request) -> HTMLResponse:
119
119
  return create_error_response(error_message)
120
120
 
121
121
  try:
122
- client_secrets_path = CONFIG_CLIENT_SECRETS_PATH
123
- if not os.path.exists(client_secrets_path):
124
- logger.error(f"OAuth client secrets file not found at {client_secrets_path}")
125
- # This is a server configuration error, should not happen in a deployed environment.
126
- return HTMLResponse(content="Server Configuration Error: Client secrets not found.", status_code=500)
122
+ # Check if we have credentials available (environment variables or file)
123
+ error_message = check_client_secrets()
124
+ if error_message:
125
+ return create_server_error_response(error_message)
127
126
 
128
127
  logger.info(f"OAuth callback: Received code (state: {state}). Attempting to exchange for tokens.")
129
128
 
@@ -136,7 +135,6 @@ async def oauth2_callback(request: Request) -> HTMLResponse:
136
135
  # Exchange code for credentials. handle_auth_callback will save them.
137
136
  # The user_id returned here is the Google-verified email.
138
137
  verified_user_id, credentials = handle_auth_callback(
139
- client_secrets_path=client_secrets_path,
140
138
  scopes=SCOPES, # Ensure all necessary scopes are requested
141
139
  authorization_response=str(request.url),
142
140
  redirect_uri=get_oauth_redirect_uri_for_current_mode(),
core/utils.py CHANGED
@@ -160,3 +160,39 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]:
160
160
  except Exception as e:
161
161
  logger.error(f"Failed to extract office XML text for {mime_type}: {e}", exc_info=True)
162
162
  return None
163
+
164
+ import functools
165
+ from googleapiclient.errors import HttpError
166
+
167
+ def handle_http_errors(tool_name: str):
168
+ """
169
+ A decorator to handle Google API HttpErrors in a standardized way.
170
+
171
+ It wraps a tool function, catches HttpError, logs a detailed error message,
172
+ and raises a generic Exception with a user-friendly message.
173
+
174
+ Args:
175
+ tool_name (str): The name of the tool being decorated (e.g., 'list_calendars').
176
+ This is used for logging purposes.
177
+ """
178
+ def decorator(func):
179
+ @functools.wraps(func)
180
+ async def wrapper(*args, **kwargs):
181
+ try:
182
+ return await func(*args, **kwargs)
183
+ except HttpError as error:
184
+ user_google_email = kwargs.get('user_google_email', 'N/A')
185
+ message = (
186
+ f"API error in {tool_name}: {error}. "
187
+ f"You might need to re-authenticate for user '{user_google_email}'. "
188
+ f"LLM: Try 'start_google_auth' with the user's email and the appropriate service_name."
189
+ )
190
+ logger.error(message, exc_info=True)
191
+ raise Exception(message)
192
+ except Exception as e:
193
+ # Catch any other unexpected errors
194
+ message = f"An unexpected error occurred in {tool_name}: {e}"
195
+ logger.exception(message)
196
+ raise Exception(message)
197
+ return wrapper
198
+ return decorator