workspace-mcp 1.0.1__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.
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/PKG-INFO +61 -9
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/README.md +59 -8
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/auth/google_auth.py +120 -12
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/auth/oauth_callback_server.py +7 -3
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/auth/service_decorator.py +31 -32
- workspace_mcp-1.0.3/core/context.py +22 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/core/server.py +5 -7
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/core/utils.py +36 -0
- workspace_mcp-1.0.3/gcalendar/calendar_tools.py +546 -0
- workspace_mcp-1.0.3/gchat/chat_tools.py +227 -0
- workspace_mcp-1.0.3/gdocs/docs_tools.py +394 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gdrive/drive_tools.py +168 -171
- workspace_mcp-1.0.3/gforms/forms_tools.py +279 -0
- workspace_mcp-1.0.3/gmail/gmail_tools.py +726 -0
- workspace_mcp-1.0.3/gsheets/sheets_tools.py +340 -0
- workspace_mcp-1.0.3/gslides/slides_tools.py +272 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/main.py +30 -24
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/pyproject.toml +24 -15
- workspace_mcp-1.0.3/tests/test_auth.py +115 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/PKG-INFO +61 -9
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/SOURCES.txt +2 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/requires.txt +1 -0
- workspace_mcp-1.0.1/gcalendar/calendar_tools.py +0 -496
- workspace_mcp-1.0.1/gchat/chat_tools.py +0 -254
- workspace_mcp-1.0.1/gdocs/docs_tools.py +0 -244
- workspace_mcp-1.0.1/gforms/forms_tools.py +0 -318
- workspace_mcp-1.0.1/gmail/gmail_tools.py +0 -807
- workspace_mcp-1.0.1/gsheets/sheets_tools.py +0 -393
- workspace_mcp-1.0.1/gslides/slides_tools.py +0 -316
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/LICENSE +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/auth/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/auth/oauth_responses.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/auth/scopes.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/core/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gcalendar/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gchat/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gdocs/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gdrive/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gforms/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gmail/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gsheets/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/gslides/__init__.py +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/setup.cfg +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/dependency_links.txt +0 -0
- {workspace_mcp-1.0.1 → workspace_mcp-1.0.3}/workspace_mcp.egg-info/entry_points.txt +0 -0
- {workspace_mcp-1.0.1 → 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.
|
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
|
@@ -36,6 +36,7 @@ Requires-Dist: google-api-python-client>=2.168.0
|
|
36
36
|
Requires-Dist: google-auth-httplib2>=0.2.0
|
37
37
|
Requires-Dist: google-auth-oauthlib>=1.2.2
|
38
38
|
Requires-Dist: httpx>=0.28.1
|
39
|
+
Requires-Dist: pyjwt>=2.10.1
|
39
40
|
Requires-Dist: tomlkit
|
40
41
|
Dynamic: license-file
|
41
42
|
|
@@ -48,10 +49,11 @@ Dynamic: license-file
|
|
48
49
|
[](https://pypi.org/project/workspace-mcp/)
|
49
50
|
[](https://github.com/astral-sh/uv)
|
50
51
|
[](https://workspacemcp.com)
|
52
|
+
[](https://mseep.ai/app/eebbc4a6-0f8c-41b2-ace8-038e5516dba0)
|
51
53
|
|
52
|
-
**
|
54
|
+
**This is the single most feature-complete Google Workspace MCP server**
|
53
55
|
|
54
|
-
*
|
56
|
+
*Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, and Chat through all MCP clients, AI assistants and developer tools*
|
55
57
|
|
56
58
|
</div>
|
57
59
|
|
@@ -74,6 +76,20 @@ Dynamic: license-file
|
|
74
76
|
|
75
77
|
---
|
76
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.*
|
77
93
|
|
78
94
|
## 🌐 Overview
|
79
95
|
|
@@ -100,9 +116,13 @@ A production-ready MCP server that integrates all major Google Workspace service
|
|
100
116
|
|
101
117
|
### Simplest Start (uvx - Recommended)
|
102
118
|
|
103
|
-
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.
|
104
120
|
|
105
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
|
+
|
106
126
|
# Start the server with all Google Workspace tools
|
107
127
|
uvx workspace-mcp
|
108
128
|
|
@@ -136,8 +156,31 @@ uv run main.py
|
|
136
156
|
1. **Google Cloud Setup**:
|
137
157
|
- Create OAuth 2.0 credentials (web application) in [Google Cloud Console](https://console.cloud.google.com/)
|
138
158
|
- Enable APIs: Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Chat
|
139
|
-
- Download credentials as `client_secret.json` in project root
|
140
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
|
141
184
|
|
142
185
|
2. **Environment**:
|
143
186
|
```bash
|
@@ -195,7 +238,11 @@ python install_claude.py
|
|
195
238
|
"mcpServers": {
|
196
239
|
"google_workspace": {
|
197
240
|
"command": "uvx",
|
198
|
-
"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
|
+
}
|
199
246
|
}
|
200
247
|
}
|
201
248
|
}
|
@@ -208,7 +255,11 @@ python install_claude.py
|
|
208
255
|
"google_workspace": {
|
209
256
|
"command": "uv",
|
210
257
|
"args": ["run", "main.py"],
|
211
|
-
"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
|
+
}
|
212
263
|
}
|
213
264
|
}
|
214
265
|
}
|
@@ -256,7 +307,8 @@ When calling a tool:
|
|
256
307
|
|------|-------------|
|
257
308
|
| `list_calendars` | List accessible calendars |
|
258
309
|
| `get_events` | Retrieve events with time range filtering |
|
259
|
-
| `
|
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 |
|
260
312
|
| `modify_event` | Update existing events |
|
261
313
|
| `delete_event` | Remove events |
|
262
314
|
|
@@ -267,7 +319,7 @@ When calling a tool:
|
|
267
319
|
| `search_drive_files` | Search files with query syntax |
|
268
320
|
| `get_drive_file_content` | Read file content (supports Office formats) |
|
269
321
|
| `list_drive_items` | List folder contents |
|
270
|
-
| `create_drive_file` | Create new files |
|
322
|
+
| `create_drive_file` | Create new files or fetch content from public URLs |
|
271
323
|
|
272
324
|
### 📧 Gmail ([`gmail_tools.py`](gmail/gmail_tools.py))
|
273
325
|
|
@@ -7,10 +7,11 @@
|
|
7
7
|
[](https://pypi.org/project/workspace-mcp/)
|
8
8
|
[](https://github.com/astral-sh/uv)
|
9
9
|
[](https://workspacemcp.com)
|
10
|
+
[](https://mseep.ai/app/eebbc4a6-0f8c-41b2-ace8-038e5516dba0)
|
10
11
|
|
11
|
-
**
|
12
|
+
**This is the single most feature-complete Google Workspace MCP server**
|
12
13
|
|
13
|
-
*
|
14
|
+
*Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, and Chat through all MCP clients, AI assistants and developer tools*
|
14
15
|
|
15
16
|
</div>
|
16
17
|
|
@@ -33,6 +34,20 @@
|
|
33
34
|
|
34
35
|
---
|
35
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.*
|
36
51
|
|
37
52
|
## 🌐 Overview
|
38
53
|
|
@@ -59,9 +74,13 @@ A production-ready MCP server that integrates all major Google Workspace service
|
|
59
74
|
|
60
75
|
### Simplest Start (uvx - Recommended)
|
61
76
|
|
62
|
-
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.
|
63
78
|
|
64
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
|
+
|
65
84
|
# Start the server with all Google Workspace tools
|
66
85
|
uvx workspace-mcp
|
67
86
|
|
@@ -95,8 +114,31 @@ uv run main.py
|
|
95
114
|
1. **Google Cloud Setup**:
|
96
115
|
- Create OAuth 2.0 credentials (web application) in [Google Cloud Console](https://console.cloud.google.com/)
|
97
116
|
- Enable APIs: Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Chat
|
98
|
-
- Download credentials as `client_secret.json` in project root
|
99
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
|
100
142
|
|
101
143
|
2. **Environment**:
|
102
144
|
```bash
|
@@ -154,7 +196,11 @@ python install_claude.py
|
|
154
196
|
"mcpServers": {
|
155
197
|
"google_workspace": {
|
156
198
|
"command": "uvx",
|
157
|
-
"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
|
+
}
|
158
204
|
}
|
159
205
|
}
|
160
206
|
}
|
@@ -167,7 +213,11 @@ python install_claude.py
|
|
167
213
|
"google_workspace": {
|
168
214
|
"command": "uv",
|
169
215
|
"args": ["run", "main.py"],
|
170
|
-
"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
|
+
}
|
171
221
|
}
|
172
222
|
}
|
173
223
|
}
|
@@ -215,7 +265,8 @@ When calling a tool:
|
|
215
265
|
|------|-------------|
|
216
266
|
| `list_calendars` | List accessible calendars |
|
217
267
|
| `get_events` | Retrieve events with time range filtering |
|
218
|
-
| `
|
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 |
|
219
270
|
| `modify_event` | Update existing events |
|
220
271
|
| `delete_event` | Remove events |
|
221
272
|
|
@@ -226,7 +277,7 @@ When calling a tool:
|
|
226
277
|
| `search_drive_files` | Search files with query syntax |
|
227
278
|
| `get_drive_file_content` | Read file content (supports Office formats) |
|
228
279
|
| `list_drive_items` | List folder contents |
|
229
|
-
| `create_drive_file` | Create new files |
|
280
|
+
| `create_drive_file` | Create new files or fetch content from public URLs |
|
230
281
|
|
231
282
|
### 📧 Gmail ([`gmail_tools.py`](gmail/gmail_tools.py))
|
232
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
|
-
"""
|
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
|
-
|
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 =
|
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
|
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,
|
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 =
|
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,
|
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))
|
@@ -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
|
-
#
|
199
|
-
|
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
|
-
#
|
203
|
-
|
204
|
-
|
205
|
-
|
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
|
-
|
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
|
-
#
|
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
|
-
#
|
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
|
-
|
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
|
|
@@ -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)
|