workspace-mcp 1.0.2__tar.gz → 1.0.3__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.
Files changed (39) hide show
  1. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/PKG-INFO +57 -8
  2. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/README.md +56 -7
  3. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/auth/google_auth.py +120 -12
  4. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/auth/oauth_callback_server.py +7 -3
  5. workspace_mcp-1.0.3/core/context.py +22 -0
  6. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/core/server.py +5 -7
  7. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gdocs/docs_tools.py +178 -0
  8. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/pyproject.toml +1 -1
  9. workspace_mcp-1.0.3/tests/test_auth.py +115 -0
  10. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/PKG-INFO +57 -8
  11. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/SOURCES.txt +2 -0
  12. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/LICENSE +0 -0
  13. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/auth/__init__.py +0 -0
  14. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/auth/oauth_responses.py +0 -0
  15. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/auth/scopes.py +0 -0
  16. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/auth/service_decorator.py +0 -0
  17. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/core/__init__.py +0 -0
  18. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/core/utils.py +0 -0
  19. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gcalendar/__init__.py +0 -0
  20. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gcalendar/calendar_tools.py +0 -0
  21. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gchat/__init__.py +0 -0
  22. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gchat/chat_tools.py +0 -0
  23. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gdocs/__init__.py +0 -0
  24. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gdrive/__init__.py +0 -0
  25. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gdrive/drive_tools.py +0 -0
  26. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gforms/__init__.py +0 -0
  27. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gforms/forms_tools.py +0 -0
  28. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gmail/__init__.py +0 -0
  29. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gmail/gmail_tools.py +0 -0
  30. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gsheets/__init__.py +0 -0
  31. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gsheets/sheets_tools.py +0 -0
  32. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gslides/__init__.py +0 -0
  33. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/gslides/slides_tools.py +0 -0
  34. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/main.py +0 -0
  35. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/setup.cfg +0 -0
  36. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/dependency_links.txt +0 -0
  37. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/entry_points.txt +0 -0
  38. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/requires.txt +0 -0
  39. {workspace_mcp-1.0.2 → workspace_mcp-1.0.3}/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.0.2
3
+ Version: 1.0.3
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
@@ -76,6 +76,20 @@ Dynamic: license-file
76
76
 
77
77
  ---
78
78
 
79
+ ## AI-Enhanced Documentation
80
+
81
+ > **This README was crafted with AI assistance, and here's why that matters**
82
+ >
83
+ > When people dismiss documentation as "AI-generated," they're missing the bigger picture
84
+
85
+ As a solo developer building open source tools that may only ever serve my own needs, comprehensive documentation often wouldn't happen without AI help. When done right—using agents like **Roo** or **Claude Code** that understand the entire codebase, AI doesn't just regurgitate generic content - it extracts real implementation details and creates accurate, specific documentation.
86
+
87
+ **The alternative? No docs at all.**
88
+
89
+ I hope the community can appreciate these tools for what they enable: solo developers maintaining professional documentation standards while focusing on building great software.
90
+
91
+ ---
92
+ *This documentation was enhanced by AI with full codebase context. The result? You're reading docs that otherwise might not exist.*
79
93
 
80
94
  ## 🌐 Overview
81
95
 
@@ -102,9 +116,13 @@ A production-ready MCP server that integrates all major Google Workspace service
102
116
 
103
117
  ### Simplest Start (uvx - Recommended)
104
118
 
105
- Run instantly without installation:
119
+ > Run instantly without manual installation - you must configure OAuth credentials when using uvx. You can use either environment variables (recommended for production) or set the `GOOGLE_CLIENT_SECRET_PATH` (or legacy `GOOGLE_CLIENT_SECRETS`) environment variable to point to your `client_secret.json` file.
106
120
 
107
121
  ```bash
122
+ # Set OAuth credentials via environment variables (recommended)
123
+ export GOOGLE_OAUTH_CLIENT_ID="your-client-id.apps.googleusercontent.com"
124
+ export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
125
+
108
126
  # Start the server with all Google Workspace tools
109
127
  uvx workspace-mcp
110
128
 
@@ -138,9 +156,31 @@ uv run main.py
138
156
  1. **Google Cloud Setup**:
139
157
  - Create OAuth 2.0 credentials (web application) in [Google Cloud Console](https://console.cloud.google.com/)
140
158
  - Enable APIs: Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Chat
141
- - Download credentials as `client_secret.json` in project root
142
- - To use a different location for `client_secret.json`, you can set the `GOOGLE_CLIENT_SECRETS` environment variable with that path
143
159
  - Add redirect URI: `http://localhost:8000/oauth2callback`
160
+ - Configure credentials using one of these methods:
161
+
162
+ **Option A: Environment Variables (Recommended for Production)**
163
+ ```bash
164
+ export GOOGLE_OAUTH_CLIENT_ID="your-client-id.apps.googleusercontent.com"
165
+ export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
166
+ export GOOGLE_OAUTH_REDIRECT_URI="http://localhost:8000/oauth2callback" # Optional
167
+ ```
168
+
169
+ **Option B: File-based (Traditional)**
170
+ - Download credentials as `client_secret.json` in project root
171
+ - To use a different location, set `GOOGLE_CLIENT_SECRET_PATH` (or legacy `GOOGLE_CLIENT_SECRETS`) environment variable with the file path
172
+
173
+ **Credential Loading Priority**:
174
+ 1. Environment variables (`GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`)
175
+ 2. File specified by `GOOGLE_CLIENT_SECRET_PATH` or `GOOGLE_CLIENT_SECRETS` environment variable
176
+ 3. Default file (`client_secret.json` in project root)
177
+
178
+ **Why Environment Variables?**
179
+ - ✅ Containerized deployments (Docker, Kubernetes)
180
+ - ✅ Cloud platforms (Heroku, Railway, etc.)
181
+ - ✅ CI/CD pipelines
182
+ - ✅ No secrets in version control
183
+ - ✅ Easy credential rotation
144
184
 
145
185
  2. **Environment**:
146
186
  ```bash
@@ -198,7 +238,11 @@ python install_claude.py
198
238
  "mcpServers": {
199
239
  "google_workspace": {
200
240
  "command": "uvx",
201
- "args": ["workspace-mcp"]
241
+ "args": ["workspace-mcp"],
242
+ "env": {
243
+ "GOOGLE_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
244
+ "GOOGLE_OAUTH_CLIENT_SECRET": "your-client-secret"
245
+ }
202
246
  }
203
247
  }
204
248
  }
@@ -211,7 +255,11 @@ python install_claude.py
211
255
  "google_workspace": {
212
256
  "command": "uv",
213
257
  "args": ["run", "main.py"],
214
- "cwd": "/path/to/google_workspace_mcp"
258
+ "cwd": "/path/to/google_workspace_mcp",
259
+ "env": {
260
+ "GOOGLE_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
261
+ "GOOGLE_OAUTH_CLIENT_SECRET": "your-client-secret"
262
+ }
215
263
  }
216
264
  }
217
265
  }
@@ -259,7 +307,8 @@ When calling a tool:
259
307
  |------|-------------|
260
308
  | `list_calendars` | List accessible calendars |
261
309
  | `get_events` | Retrieve events with time range filtering |
262
- | `create_event` | Create events (all-day or timed) |
310
+ | `get_event` | Fetch detailed information of a single event by ID |
311
+ | `create_event` | Create events (all-day or timed) with optional Drive file attachments |
263
312
  | `modify_event` | Update existing events |
264
313
  | `delete_event` | Remove events |
265
314
 
@@ -270,7 +319,7 @@ When calling a tool:
270
319
  | `search_drive_files` | Search files with query syntax |
271
320
  | `get_drive_file_content` | Read file content (supports Office formats) |
272
321
  | `list_drive_items` | List folder contents |
273
- | `create_drive_file` | Create new files |
322
+ | `create_drive_file` | Create new files or fetch content from public URLs |
274
323
 
275
324
  ### 📧 Gmail ([`gmail_tools.py`](gmail/gmail_tools.py))
276
325
 
@@ -34,6 +34,20 @@
34
34
 
35
35
  ---
36
36
 
37
+ ## AI-Enhanced Documentation
38
+
39
+ > **This README was crafted with AI assistance, and here's why that matters**
40
+ >
41
+ > When people dismiss documentation as "AI-generated," they're missing the bigger picture
42
+
43
+ As a solo developer building open source tools that may only ever serve my own needs, comprehensive documentation often wouldn't happen without AI help. When done right—using agents like **Roo** or **Claude Code** that understand the entire codebase, AI doesn't just regurgitate generic content - it extracts real implementation details and creates accurate, specific documentation.
44
+
45
+ **The alternative? No docs at all.**
46
+
47
+ I hope the community can appreciate these tools for what they enable: solo developers maintaining professional documentation standards while focusing on building great software.
48
+
49
+ ---
50
+ *This documentation was enhanced by AI with full codebase context. The result? You're reading docs that otherwise might not exist.*
37
51
 
38
52
  ## 🌐 Overview
39
53
 
@@ -60,9 +74,13 @@ A production-ready MCP server that integrates all major Google Workspace service
60
74
 
61
75
  ### Simplest Start (uvx - Recommended)
62
76
 
63
- Run instantly without installation:
77
+ > Run instantly without manual installation - you must configure OAuth credentials when using uvx. You can use either environment variables (recommended for production) or set the `GOOGLE_CLIENT_SECRET_PATH` (or legacy `GOOGLE_CLIENT_SECRETS`) environment variable to point to your `client_secret.json` file.
64
78
 
65
79
  ```bash
80
+ # Set OAuth credentials via environment variables (recommended)
81
+ export GOOGLE_OAUTH_CLIENT_ID="your-client-id.apps.googleusercontent.com"
82
+ export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
83
+
66
84
  # Start the server with all Google Workspace tools
67
85
  uvx workspace-mcp
68
86
 
@@ -96,9 +114,31 @@ uv run main.py
96
114
  1. **Google Cloud Setup**:
97
115
  - Create OAuth 2.0 credentials (web application) in [Google Cloud Console](https://console.cloud.google.com/)
98
116
  - Enable APIs: Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Chat
99
- - Download credentials as `client_secret.json` in project root
100
- - To use a different location for `client_secret.json`, you can set the `GOOGLE_CLIENT_SECRETS` environment variable with that path
101
117
  - Add redirect URI: `http://localhost:8000/oauth2callback`
118
+ - Configure credentials using one of these methods:
119
+
120
+ **Option A: Environment Variables (Recommended for Production)**
121
+ ```bash
122
+ export GOOGLE_OAUTH_CLIENT_ID="your-client-id.apps.googleusercontent.com"
123
+ export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
124
+ export GOOGLE_OAUTH_REDIRECT_URI="http://localhost:8000/oauth2callback" # Optional
125
+ ```
126
+
127
+ **Option B: File-based (Traditional)**
128
+ - Download credentials as `client_secret.json` in project root
129
+ - To use a different location, set `GOOGLE_CLIENT_SECRET_PATH` (or legacy `GOOGLE_CLIENT_SECRETS`) environment variable with the file path
130
+
131
+ **Credential Loading Priority**:
132
+ 1. Environment variables (`GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`)
133
+ 2. File specified by `GOOGLE_CLIENT_SECRET_PATH` or `GOOGLE_CLIENT_SECRETS` environment variable
134
+ 3. Default file (`client_secret.json` in project root)
135
+
136
+ **Why Environment Variables?**
137
+ - ✅ Containerized deployments (Docker, Kubernetes)
138
+ - ✅ Cloud platforms (Heroku, Railway, etc.)
139
+ - ✅ CI/CD pipelines
140
+ - ✅ No secrets in version control
141
+ - ✅ Easy credential rotation
102
142
 
103
143
  2. **Environment**:
104
144
  ```bash
@@ -156,7 +196,11 @@ python install_claude.py
156
196
  "mcpServers": {
157
197
  "google_workspace": {
158
198
  "command": "uvx",
159
- "args": ["workspace-mcp"]
199
+ "args": ["workspace-mcp"],
200
+ "env": {
201
+ "GOOGLE_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
202
+ "GOOGLE_OAUTH_CLIENT_SECRET": "your-client-secret"
203
+ }
160
204
  }
161
205
  }
162
206
  }
@@ -169,7 +213,11 @@ python install_claude.py
169
213
  "google_workspace": {
170
214
  "command": "uv",
171
215
  "args": ["run", "main.py"],
172
- "cwd": "/path/to/google_workspace_mcp"
216
+ "cwd": "/path/to/google_workspace_mcp",
217
+ "env": {
218
+ "GOOGLE_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
219
+ "GOOGLE_OAUTH_CLIENT_SECRET": "your-client-secret"
220
+ }
173
221
  }
174
222
  }
175
223
  }
@@ -217,7 +265,8 @@ When calling a tool:
217
265
  |------|-------------|
218
266
  | `list_calendars` | List accessible calendars |
219
267
  | `get_events` | Retrieve events with time range filtering |
220
- | `create_event` | Create events (all-day or timed) |
268
+ | `get_event` | Fetch detailed information of a single event by ID |
269
+ | `create_event` | Create events (all-day or timed) with optional Drive file attachments |
221
270
  | `modify_event` | Update existing events |
222
271
  | `delete_event` | Remove events |
223
272
 
@@ -228,7 +277,7 @@ When calling a tool:
228
277
  | `search_drive_files` | Search files with query syntax |
229
278
  | `get_drive_file_content` | Read file content (supports Office formats) |
230
279
  | `list_drive_items` | List folder contents |
231
- | `create_drive_file` | Create new files |
280
+ | `create_drive_file` | Create new files or fetch content from public URLs |
232
281
 
233
282
  ### 📧 Gmail ([`gmail_tools.py`](gmail/gmail_tools.py))
234
283
 
@@ -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))
@@ -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)
@@ -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(),
@@ -214,3 +214,181 @@ async def create_doc(
214
214
  msg = f"Created Google Doc '{title}' (ID: {doc_id}) for {user_google_email}. Link: {link}"
215
215
  logger.info(f"Successfully created Google Doc '{title}' (ID: {doc_id}) for {user_google_email}. Link: {link}")
216
216
  return msg
217
+
218
+
219
+ @server.tool()
220
+ @require_google_service("drive", "drive_read")
221
+ @handle_http_errors("read_doc_comments")
222
+ async def read_doc_comments(
223
+ service,
224
+ user_google_email: str,
225
+ document_id: str,
226
+ ) -> str:
227
+ """
228
+ Read all comments from a Google Doc.
229
+
230
+ Args:
231
+ document_id: The ID of the Google Document
232
+
233
+ Returns:
234
+ str: A formatted list of all comments and replies in the document.
235
+ """
236
+ logger.info(f"[read_doc_comments] Reading comments for document {document_id}")
237
+
238
+ response = await asyncio.to_thread(
239
+ service.comments().list(
240
+ fileId=document_id,
241
+ fields="comments(id,content,author,createdTime,modifiedTime,resolved,replies(content,author,id,createdTime,modifiedTime))"
242
+ ).execute
243
+ )
244
+
245
+ comments = response.get('comments', [])
246
+
247
+ if not comments:
248
+ return f"No comments found in document {document_id}"
249
+
250
+ output = [f"Found {len(comments)} comments in document {document_id}:\n"]
251
+
252
+ for comment in comments:
253
+ author = comment.get('author', {}).get('displayName', 'Unknown')
254
+ content = comment.get('content', '')
255
+ created = comment.get('createdTime', '')
256
+ resolved = comment.get('resolved', False)
257
+ comment_id = comment.get('id', '')
258
+ status = " [RESOLVED]" if resolved else ""
259
+
260
+ output.append(f"Comment ID: {comment_id}")
261
+ output.append(f"Author: {author}")
262
+ output.append(f"Created: {created}{status}")
263
+ output.append(f"Content: {content}")
264
+
265
+ # Add replies if any
266
+ replies = comment.get('replies', [])
267
+ if replies:
268
+ output.append(f" Replies ({len(replies)}):")
269
+ for reply in replies:
270
+ reply_author = reply.get('author', {}).get('displayName', 'Unknown')
271
+ reply_content = reply.get('content', '')
272
+ reply_created = reply.get('createdTime', '')
273
+ reply_id = reply.get('id', '')
274
+ output.append(f" Reply ID: {reply_id}")
275
+ output.append(f" Author: {reply_author}")
276
+ output.append(f" Created: {reply_created}")
277
+ output.append(f" Content: {reply_content}")
278
+
279
+ output.append("") # Empty line between comments
280
+
281
+ return "\n".join(output)
282
+
283
+
284
+ @server.tool()
285
+ @require_google_service("drive", "drive_file")
286
+ @handle_http_errors("reply_to_comment")
287
+ async def reply_to_comment(
288
+ service,
289
+ user_google_email: str,
290
+ document_id: str,
291
+ comment_id: str,
292
+ reply_content: str,
293
+ ) -> str:
294
+ """
295
+ Reply to a specific comment in a Google Doc.
296
+
297
+ Args:
298
+ document_id: The ID of the Google Document
299
+ comment_id: The ID of the comment to reply to
300
+ reply_content: The content of the reply
301
+
302
+ Returns:
303
+ str: Confirmation message with reply details.
304
+ """
305
+ logger.info(f"[reply_to_comment] Replying to comment {comment_id} in document {document_id}")
306
+
307
+ body = {'content': reply_content}
308
+
309
+ reply = await asyncio.to_thread(
310
+ service.replies().create(
311
+ fileId=document_id,
312
+ commentId=comment_id,
313
+ body=body,
314
+ fields="id,content,author,createdTime,modifiedTime"
315
+ ).execute
316
+ )
317
+
318
+ reply_id = reply.get('id', '')
319
+ author = reply.get('author', {}).get('displayName', 'Unknown')
320
+ created = reply.get('createdTime', '')
321
+
322
+ return f"Reply posted successfully!\nReply ID: {reply_id}\nAuthor: {author}\nCreated: {created}\nContent: {reply_content}"
323
+
324
+
325
+ @server.tool()
326
+ @require_google_service("drive", "drive_file")
327
+ @handle_http_errors("create_doc_comment")
328
+ async def create_doc_comment(
329
+ service,
330
+ user_google_email: str,
331
+ document_id: str,
332
+ comment_content: str,
333
+ ) -> str:
334
+ """
335
+ Create a new comment on a Google Doc.
336
+
337
+ Args:
338
+ document_id: The ID of the Google Document
339
+ comment_content: The content of the comment
340
+
341
+ Returns:
342
+ str: Confirmation message with comment details.
343
+ """
344
+ logger.info(f"[create_doc_comment] Creating comment in document {document_id}")
345
+
346
+ body = {"content": comment_content}
347
+
348
+ comment = await asyncio.to_thread(
349
+ service.comments().create(
350
+ fileId=document_id,
351
+ body=body,
352
+ fields="id,content,author,createdTime,modifiedTime"
353
+ ).execute
354
+ )
355
+
356
+ comment_id = comment.get('id', '')
357
+ author = comment.get('author', {}).get('displayName', 'Unknown')
358
+ created = comment.get('createdTime', '')
359
+
360
+ return f"Comment created successfully!\nComment ID: {comment_id}\nAuthor: {author}\nCreated: {created}\nContent: {comment_content}"
361
+
362
+
363
+ @server.tool()
364
+ @require_google_service("drive", "drive_file")
365
+ @handle_http_errors("resolve_comment")
366
+ async def resolve_comment(
367
+ service,
368
+ user_google_email: str,
369
+ document_id: str,
370
+ comment_id: str,
371
+ ) -> str:
372
+ """
373
+ Resolve a comment in a Google Doc.
374
+
375
+ Args:
376
+ document_id: The ID of the Google Document
377
+ comment_id: The ID of the comment to resolve
378
+
379
+ Returns:
380
+ str: Confirmation message.
381
+ """
382
+ logger.info(f"[resolve_comment] Resolving comment {comment_id} in document {document_id}")
383
+
384
+ body = {"resolved": True}
385
+
386
+ await asyncio.to_thread(
387
+ service.comments().update(
388
+ fileId=document_id,
389
+ commentId=comment_id,
390
+ body=body
391
+ ).execute
392
+ )
393
+
394
+ return f"Comment {comment_id} has been resolved successfully."
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "workspace-mcp"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "Comprehensive, highly performant Google Workspace Streamable HTTP & SSE MCP Server for Calendar, Gmail, Docs, Sheets, Slides & Drive"
9
9
  readme = "README.md"
10
10
  keywords = [ "mcp", "google", "workspace", "llm", "ai", "claude", "model", "context", "protocol", "server"]
@@ -0,0 +1,115 @@
1
+ import os
2
+ import json
3
+ from unittest.mock import patch, mock_open
4
+ import pytest
5
+ from auth.google_auth import load_client_secrets_from_env, create_oauth_flow, load_client_secrets
6
+ from google_auth_oauthlib.flow import Flow
7
+
8
+ # -- Mocks and Fixtures --
9
+
10
+ @pytest.fixture
11
+ def mock_env_vars(monkeypatch):
12
+ """Fixture to mock environment variables for OAuth."""
13
+ monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "test_client_id_from_env")
14
+ monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_SECRET", "test_client_secret_from_env")
15
+ monkeypatch.setenv("GOOGLE_OAUTH_REDIRECT_URI", "http://localhost:8000/callback")
16
+ return monkeypatch
17
+
18
+ @pytest.fixture
19
+ def mock_client_secrets_file(tmp_path):
20
+ """Fixture to create a mock client_secret.json file."""
21
+ secrets_content = {
22
+ "web": {
23
+ "client_id": "test_client_id_from_file",
24
+ "client_secret": "test_client_secret_from_file",
25
+ "redirect_uris": ["http://localhost:8000/oauth2callback"],
26
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
27
+ "token_uri": "https://oauth2.googleapis.com/token"
28
+ }
29
+ }
30
+ secrets_file = tmp_path / "client_secret.json"
31
+ secrets_file.write_text(json.dumps(secrets_content))
32
+ return str(secrets_file)
33
+
34
+ # -- Tests for load_client_secrets_from_env --
35
+
36
+ def test_load_client_secrets_from_env_success(mock_env_vars):
37
+ """Test loading client secrets from environment variables successfully."""
38
+ config = load_client_secrets_from_env()
39
+ assert config is not None
40
+ assert config["web"]["client_id"] == "test_client_id_from_env"
41
+ assert config["web"]["client_secret"] == "test_client_secret_from_env"
42
+ assert config["web"]["redirect_uris"] == ["http://localhost:8000/callback"]
43
+
44
+ def test_load_client_secrets_from_env_missing_vars():
45
+ """Test that None is returned when environment variables are missing."""
46
+ # Ensure variables are not set
47
+ if "GOOGLE_OAUTH_CLIENT_ID" in os.environ:
48
+ del os.environ["GOOGLE_OAUTH_CLIENT_ID"]
49
+ config = load_client_secrets_from_env()
50
+ assert config is None
51
+
52
+ # -- Tests for load_client_secrets (the combined function) --
53
+
54
+ def test_load_client_secrets_prioritizes_env_vars(mock_env_vars, mock_client_secrets_file):
55
+ """Test that environment variables are prioritized over the file."""
56
+ # Pass a file path, but env vars should be used
57
+ config = load_client_secrets(mock_client_secrets_file)
58
+ assert config["client_id"] == "test_client_id_from_env"
59
+ assert config["client_secret"] == "test_client_secret_from_env"
60
+
61
+ def test_load_client_secrets_falls_back_to_file(mock_client_secrets_file):
62
+ """Test that the file is used when no environment variables are set."""
63
+ # Ensure env vars are not present
64
+ if "GOOGLE_OAUTH_CLIENT_ID" in os.environ:
65
+ del os.environ["GOOGLE_OAUTH_CLIENT_ID"]
66
+
67
+ config = load_client_secrets(mock_client_secrets_file)
68
+ assert config["client_id"] == "test_client_id_from_file"
69
+ assert config["client_secret"] == "test_client_secret_from_file"
70
+
71
+ def test_load_client_secrets_file_not_found():
72
+ """Test that an error is raised if no file is found and no env vars are set."""
73
+ with pytest.raises(IOError):
74
+ load_client_secrets("non_existent_file.json")
75
+
76
+ # -- Tests for create_oauth_flow --
77
+
78
+ @patch('auth.google_auth.Flow')
79
+ def test_create_oauth_flow_from_env(mock_flow, mock_env_vars):
80
+ """Test creating an OAuth flow using environment variables."""
81
+ create_oauth_flow(scopes=["test_scope"], redirect_uri="http://localhost:8000/callback")
82
+
83
+ # Check that Flow.from_client_config was called with the correct config
84
+ mock_flow.from_client_config.assert_called_once()
85
+ call_args = mock_flow.from_client_config.call_args[0][0]
86
+ assert call_args["web"]["client_id"] == "test_client_id_from_env"
87
+
88
+ @patch('auth.google_auth.Flow')
89
+ @patch('os.path.exists', return_value=True)
90
+ def test_create_oauth_flow_from_file(mock_path_exists, mock_flow, mock_client_secrets_file):
91
+ """Test creating an OAuth flow using a client secrets file."""
92
+ # Ensure env vars are not present
93
+ if "GOOGLE_OAUTH_CLIENT_ID" in os.environ:
94
+ del os.environ["GOOGLE_OAUTH_CLIENT_ID"]
95
+
96
+ with patch('auth.google_auth.CONFIG_CLIENT_SECRETS_PATH', mock_client_secrets_file):
97
+ create_oauth_flow(scopes=["test_scope"], redirect_uri="http://localhost:8000/callback")
98
+
99
+ # Check that Flow.from_client_secrets_file was called
100
+ mock_flow.from_client_secrets_file.assert_called_once_with(
101
+ mock_client_secrets_file,
102
+ scopes=["test_scope"],
103
+ redirect_uri="http://localhost:8000/callback",
104
+ state=None
105
+ )
106
+
107
+ @patch('os.path.exists', return_value=False)
108
+ def test_create_oauth_flow_no_config_found(mock_path_exists):
109
+ """Test that an error is raised if no configuration is found."""
110
+ # Ensure env vars are not present
111
+ if "GOOGLE_OAUTH_CLIENT_ID" in os.environ:
112
+ del os.environ["GOOGLE_OAUTH_CLIENT_ID"]
113
+
114
+ with pytest.raises(FileNotFoundError):
115
+ create_oauth_flow(scopes=["test_scope"], redirect_uri="http://localhost:8000/callback")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workspace-mcp
3
- Version: 1.0.2
3
+ Version: 1.0.3
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
@@ -76,6 +76,20 @@ Dynamic: license-file
76
76
 
77
77
  ---
78
78
 
79
+ ## AI-Enhanced Documentation
80
+
81
+ > **This README was crafted with AI assistance, and here's why that matters**
82
+ >
83
+ > When people dismiss documentation as "AI-generated," they're missing the bigger picture
84
+
85
+ As a solo developer building open source tools that may only ever serve my own needs, comprehensive documentation often wouldn't happen without AI help. When done right—using agents like **Roo** or **Claude Code** that understand the entire codebase, AI doesn't just regurgitate generic content - it extracts real implementation details and creates accurate, specific documentation.
86
+
87
+ **The alternative? No docs at all.**
88
+
89
+ I hope the community can appreciate these tools for what they enable: solo developers maintaining professional documentation standards while focusing on building great software.
90
+
91
+ ---
92
+ *This documentation was enhanced by AI with full codebase context. The result? You're reading docs that otherwise might not exist.*
79
93
 
80
94
  ## 🌐 Overview
81
95
 
@@ -102,9 +116,13 @@ A production-ready MCP server that integrates all major Google Workspace service
102
116
 
103
117
  ### Simplest Start (uvx - Recommended)
104
118
 
105
- Run instantly without installation:
119
+ > Run instantly without manual installation - you must configure OAuth credentials when using uvx. You can use either environment variables (recommended for production) or set the `GOOGLE_CLIENT_SECRET_PATH` (or legacy `GOOGLE_CLIENT_SECRETS`) environment variable to point to your `client_secret.json` file.
106
120
 
107
121
  ```bash
122
+ # Set OAuth credentials via environment variables (recommended)
123
+ export GOOGLE_OAUTH_CLIENT_ID="your-client-id.apps.googleusercontent.com"
124
+ export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
125
+
108
126
  # Start the server with all Google Workspace tools
109
127
  uvx workspace-mcp
110
128
 
@@ -138,9 +156,31 @@ uv run main.py
138
156
  1. **Google Cloud Setup**:
139
157
  - Create OAuth 2.0 credentials (web application) in [Google Cloud Console](https://console.cloud.google.com/)
140
158
  - Enable APIs: Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Chat
141
- - Download credentials as `client_secret.json` in project root
142
- - To use a different location for `client_secret.json`, you can set the `GOOGLE_CLIENT_SECRETS` environment variable with that path
143
159
  - Add redirect URI: `http://localhost:8000/oauth2callback`
160
+ - Configure credentials using one of these methods:
161
+
162
+ **Option A: Environment Variables (Recommended for Production)**
163
+ ```bash
164
+ export GOOGLE_OAUTH_CLIENT_ID="your-client-id.apps.googleusercontent.com"
165
+ export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"
166
+ export GOOGLE_OAUTH_REDIRECT_URI="http://localhost:8000/oauth2callback" # Optional
167
+ ```
168
+
169
+ **Option B: File-based (Traditional)**
170
+ - Download credentials as `client_secret.json` in project root
171
+ - To use a different location, set `GOOGLE_CLIENT_SECRET_PATH` (or legacy `GOOGLE_CLIENT_SECRETS`) environment variable with the file path
172
+
173
+ **Credential Loading Priority**:
174
+ 1. Environment variables (`GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`)
175
+ 2. File specified by `GOOGLE_CLIENT_SECRET_PATH` or `GOOGLE_CLIENT_SECRETS` environment variable
176
+ 3. Default file (`client_secret.json` in project root)
177
+
178
+ **Why Environment Variables?**
179
+ - ✅ Containerized deployments (Docker, Kubernetes)
180
+ - ✅ Cloud platforms (Heroku, Railway, etc.)
181
+ - ✅ CI/CD pipelines
182
+ - ✅ No secrets in version control
183
+ - ✅ Easy credential rotation
144
184
 
145
185
  2. **Environment**:
146
186
  ```bash
@@ -198,7 +238,11 @@ python install_claude.py
198
238
  "mcpServers": {
199
239
  "google_workspace": {
200
240
  "command": "uvx",
201
- "args": ["workspace-mcp"]
241
+ "args": ["workspace-mcp"],
242
+ "env": {
243
+ "GOOGLE_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
244
+ "GOOGLE_OAUTH_CLIENT_SECRET": "your-client-secret"
245
+ }
202
246
  }
203
247
  }
204
248
  }
@@ -211,7 +255,11 @@ python install_claude.py
211
255
  "google_workspace": {
212
256
  "command": "uv",
213
257
  "args": ["run", "main.py"],
214
- "cwd": "/path/to/google_workspace_mcp"
258
+ "cwd": "/path/to/google_workspace_mcp",
259
+ "env": {
260
+ "GOOGLE_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
261
+ "GOOGLE_OAUTH_CLIENT_SECRET": "your-client-secret"
262
+ }
215
263
  }
216
264
  }
217
265
  }
@@ -259,7 +307,8 @@ When calling a tool:
259
307
  |------|-------------|
260
308
  | `list_calendars` | List accessible calendars |
261
309
  | `get_events` | Retrieve events with time range filtering |
262
- | `create_event` | Create events (all-day or timed) |
310
+ | `get_event` | Fetch detailed information of a single event by ID |
311
+ | `create_event` | Create events (all-day or timed) with optional Drive file attachments |
263
312
  | `modify_event` | Update existing events |
264
313
  | `delete_event` | Remove events |
265
314
 
@@ -270,7 +319,7 @@ When calling a tool:
270
319
  | `search_drive_files` | Search files with query syntax |
271
320
  | `get_drive_file_content` | Read file content (supports Office formats) |
272
321
  | `list_drive_items` | List folder contents |
273
- | `create_drive_file` | Create new files |
322
+ | `create_drive_file` | Create new files or fetch content from public URLs |
274
323
 
275
324
  ### 📧 Gmail ([`gmail_tools.py`](gmail/gmail_tools.py))
276
325
 
@@ -9,6 +9,7 @@ auth/oauth_responses.py
9
9
  auth/scopes.py
10
10
  auth/service_decorator.py
11
11
  core/__init__.py
12
+ core/context.py
12
13
  core/server.py
13
14
  core/utils.py
14
15
  gcalendar/__init__.py
@@ -27,6 +28,7 @@ gsheets/__init__.py
27
28
  gsheets/sheets_tools.py
28
29
  gslides/__init__.py
29
30
  gslides/slides_tools.py
31
+ tests/test_auth.py
30
32
  workspace_mcp.egg-info/PKG-INFO
31
33
  workspace_mcp.egg-info/SOURCES.txt
32
34
  workspace_mcp.egg-info/dependency_links.txt
File without changes
File without changes
File without changes