workspace-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ """
2
+ Transport-aware OAuth callback handling.
3
+
4
+ In streamable-http mode: Uses the existing FastAPI server
5
+ In stdio mode: Starts a minimal HTTP server just for OAuth callbacks
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import threading
11
+ import time
12
+ from typing import Optional, Dict, Any
13
+ import socket
14
+
15
+ from fastapi import FastAPI, Request
16
+ import uvicorn
17
+
18
+ from auth.google_auth import handle_auth_callback, CONFIG_CLIENT_SECRETS_PATH
19
+ from auth.scopes import OAUTH_STATE_TO_SESSION_ID_MAP, SCOPES
20
+ from auth.oauth_responses import create_error_response, create_success_response, create_server_error_response
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ class MinimalOAuthServer:
25
+ """
26
+ Minimal HTTP server for OAuth callbacks in stdio mode.
27
+ Only starts when needed and uses the same port (8000) as streamable-http mode.
28
+ """
29
+
30
+ def __init__(self, port: int = 8000, base_uri: str = "http://localhost"):
31
+ self.port = port
32
+ self.base_uri = base_uri
33
+ self.app = FastAPI()
34
+ self.server = None
35
+ self.server_thread = None
36
+ self.is_running = False
37
+
38
+ # Setup the callback route
39
+ self._setup_callback_route()
40
+
41
+ def _setup_callback_route(self):
42
+ """Setup the OAuth callback route."""
43
+
44
+ @self.app.get("/oauth2callback")
45
+ async def oauth_callback(request: Request):
46
+ """Handle OAuth callback - same logic as in core/server.py"""
47
+ state = request.query_params.get("state")
48
+ code = request.query_params.get("code")
49
+ error = request.query_params.get("error")
50
+
51
+ if error:
52
+ error_message = f"Authentication failed: Google returned an error: {error}. State: {state}."
53
+ logger.error(error_message)
54
+ return create_error_response(error_message)
55
+
56
+ if not code:
57
+ error_message = "Authentication failed: No authorization code received from Google."
58
+ logger.error(error_message)
59
+ return create_error_response(error_message)
60
+
61
+ try:
62
+ logger.info(f"OAuth callback: Received code (state: {state}). Attempting to exchange for tokens.")
63
+
64
+ mcp_session_id: Optional[str] = OAUTH_STATE_TO_SESSION_ID_MAP.pop(state, None)
65
+ if mcp_session_id:
66
+ logger.info(f"OAuth callback: Retrieved MCP session ID '{mcp_session_id}' for state '{state}'.")
67
+ else:
68
+ logger.warning(f"OAuth callback: No MCP session ID found for state '{state}'. Auth will not be tied to a specific session.")
69
+
70
+ # Exchange code for credentials
71
+ verified_user_id, credentials = handle_auth_callback(
72
+ client_secrets_path=CONFIG_CLIENT_SECRETS_PATH,
73
+ scopes=SCOPES,
74
+ authorization_response=str(request.url),
75
+ redirect_uri=f"{self.base_uri}:{self.port}/oauth2callback",
76
+ session_id=mcp_session_id
77
+ )
78
+
79
+ log_session_part = f" (linked to session: {mcp_session_id})" if mcp_session_id else ""
80
+ logger.info(f"OAuth callback: Successfully authenticated user: {verified_user_id} (state: {state}){log_session_part}.")
81
+
82
+ # Return success page using shared template
83
+ return create_success_response(verified_user_id)
84
+
85
+ except Exception as e:
86
+ error_message_detail = f"Error processing OAuth callback (state: {state}): {str(e)}"
87
+ logger.error(error_message_detail, exc_info=True)
88
+ return create_server_error_response(str(e))
89
+
90
+ def start(self) -> bool:
91
+ """
92
+ Start the minimal OAuth server.
93
+
94
+ Returns:
95
+ True if started successfully, False otherwise
96
+ """
97
+ if self.is_running:
98
+ logger.info("Minimal OAuth server is already running")
99
+ return True
100
+
101
+ # Check if port is available
102
+ # Extract hostname from base_uri (e.g., "http://localhost" -> "localhost")
103
+ try:
104
+ from urllib.parse import urlparse
105
+ parsed_uri = urlparse(self.base_uri)
106
+ hostname = parsed_uri.hostname or 'localhost'
107
+ except Exception:
108
+ hostname = 'localhost'
109
+
110
+ try:
111
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
112
+ s.bind((hostname, self.port))
113
+ except OSError:
114
+ logger.error(f"Port {self.port} is already in use on {hostname}. Cannot start minimal OAuth server.")
115
+ return False
116
+
117
+ def run_server():
118
+ """Run the server in a separate thread."""
119
+ try:
120
+ config = uvicorn.Config(
121
+ self.app,
122
+ host=hostname,
123
+ port=self.port,
124
+ log_level="warning",
125
+ access_log=False
126
+ )
127
+ self.server = uvicorn.Server(config)
128
+ asyncio.run(self.server.serve())
129
+
130
+ except Exception as e:
131
+ logger.error(f"Minimal OAuth server error: {e}", exc_info=True)
132
+
133
+ # Start server in background thread
134
+ self.server_thread = threading.Thread(target=run_server, daemon=True)
135
+ self.server_thread.start()
136
+
137
+ # Wait for server to start
138
+ max_wait = 3.0
139
+ start_time = time.time()
140
+ while time.time() - start_time < max_wait:
141
+ try:
142
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
143
+ result = s.connect_ex((hostname, self.port))
144
+ if result == 0:
145
+ self.is_running = True
146
+ logger.info(f"Minimal OAuth server started on {hostname}:{self.port}")
147
+ return True
148
+ except Exception:
149
+ pass
150
+ time.sleep(0.1)
151
+
152
+ logger.error(f"Failed to start minimal OAuth server on {hostname}:{self.port}")
153
+ return False
154
+
155
+ def stop(self):
156
+ """Stop the minimal OAuth server."""
157
+ if not self.is_running:
158
+ return
159
+
160
+ try:
161
+ if self.server:
162
+ if hasattr(self.server, 'should_exit'):
163
+ self.server.should_exit = True
164
+
165
+ if self.server_thread and self.server_thread.is_alive():
166
+ self.server_thread.join(timeout=3.0)
167
+
168
+ self.is_running = False
169
+ logger.info(f"Minimal OAuth server stopped")
170
+
171
+ except Exception as e:
172
+ logger.error(f"Error stopping minimal OAuth server: {e}", exc_info=True)
173
+
174
+
175
+ # Global instance for stdio mode
176
+ _minimal_oauth_server: Optional[MinimalOAuthServer] = None
177
+
178
+ def get_oauth_redirect_uri(transport_mode: str = "stdio", port: int = 8000, base_uri: str = "http://localhost") -> str:
179
+ """
180
+ Get the appropriate OAuth redirect URI based on transport mode.
181
+
182
+ Args:
183
+ transport_mode: "stdio" or "streamable-http"
184
+ port: Port number (default 8000)
185
+ base_uri: Base URI (default "http://localhost")
186
+
187
+ Returns:
188
+ OAuth redirect URI
189
+ """
190
+ return f"{base_uri}:{port}/oauth2callback"
191
+
192
+ def ensure_oauth_callback_available(transport_mode: str = "stdio", port: int = 8000, base_uri: str = "http://localhost") -> bool:
193
+ """
194
+ Ensure OAuth callback endpoint is available for the given transport mode.
195
+
196
+ For streamable-http: Assumes the main server is already running
197
+ For stdio: Starts a minimal server if needed
198
+
199
+ Args:
200
+ transport_mode: "stdio" or "streamable-http"
201
+ port: Port number (default 8000)
202
+ base_uri: Base URI (default "http://localhost")
203
+
204
+ Returns:
205
+ True if callback endpoint is available, False otherwise
206
+ """
207
+ global _minimal_oauth_server
208
+
209
+ if transport_mode == "streamable-http":
210
+ # In streamable-http mode, the main FastAPI server should handle callbacks
211
+ logger.debug("Using existing FastAPI server for OAuth callbacks (streamable-http mode)")
212
+ return True
213
+
214
+ elif transport_mode == "stdio":
215
+ # In stdio mode, start minimal server if not already running
216
+ if _minimal_oauth_server is None:
217
+ logger.info(f"Creating minimal OAuth server instance for {base_uri}:{port}")
218
+ _minimal_oauth_server = MinimalOAuthServer(port, base_uri)
219
+
220
+ if not _minimal_oauth_server.is_running:
221
+ logger.info("Starting minimal OAuth server for stdio mode")
222
+ result = _minimal_oauth_server.start()
223
+ if result:
224
+ logger.info(f"Minimal OAuth server successfully started on {base_uri}:{port}")
225
+ else:
226
+ logger.error(f"Failed to start minimal OAuth server on {base_uri}:{port}")
227
+ return result
228
+ else:
229
+ logger.info("Minimal OAuth server is already running")
230
+ return True
231
+
232
+ else:
233
+ logger.error(f"Unknown transport mode: {transport_mode}")
234
+ return False
235
+
236
+ def cleanup_oauth_callback_server():
237
+ """Clean up the minimal OAuth server if it was started."""
238
+ global _minimal_oauth_server
239
+ if _minimal_oauth_server:
240
+ _minimal_oauth_server.stop()
241
+ _minimal_oauth_server = None
@@ -0,0 +1,223 @@
1
+ """
2
+ Shared OAuth callback response templates.
3
+
4
+ Provides reusable HTML response templates for OAuth authentication flows
5
+ to eliminate duplication between server.py and oauth_callback_server.py.
6
+ """
7
+
8
+ from fastapi.responses import HTMLResponse
9
+ from typing import Optional
10
+
11
+
12
+ def create_error_response(error_message: str, status_code: int = 400) -> HTMLResponse:
13
+ """
14
+ Create a standardized error response for OAuth failures.
15
+
16
+ Args:
17
+ error_message: The error message to display
18
+ status_code: HTTP status code (default 400)
19
+
20
+ Returns:
21
+ HTMLResponse with error page
22
+ """
23
+ content = f"""
24
+ <html>
25
+ <head><title>Authentication Error</title></head>
26
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;">
27
+ <h2 style="color: #d32f2f;">Authentication Error</h2>
28
+ <p>{error_message}</p>
29
+ <p>Please ensure you grant the requested permissions. You can close this window and try again.</p>
30
+ <script>setTimeout(function() {{ window.close(); }}, 10000);</script>
31
+ </body>
32
+ </html>
33
+ """
34
+ return HTMLResponse(content=content, status_code=status_code)
35
+
36
+
37
+ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLResponse:
38
+ """
39
+ Create a standardized success response for OAuth authentication.
40
+
41
+ Args:
42
+ verified_user_id: The authenticated user's email (optional)
43
+
44
+ Returns:
45
+ HTMLResponse with success page
46
+ """
47
+ # Handle the case where no user ID is provided
48
+ user_display = verified_user_id if verified_user_id else "Google User"
49
+
50
+ content = f"""<html>
51
+ <head>
52
+ <title>Authentication Successful</title>
53
+ <style>
54
+ * {{
55
+ margin: 0;
56
+ padding: 0;
57
+ box-sizing: border-box;
58
+ }}
59
+
60
+ body {{
61
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
62
+ background: linear-gradient(135deg,#0f172a,#1e293b,#334155);
63
+ min-height: 100vh;
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ color: #1a1a1a;
68
+ -webkit-font-smoothing: antialiased;
69
+ -moz-osx-font-smoothing: grayscale;
70
+ }}
71
+
72
+ .container {{
73
+ background: rgba(255, 255, 255, 0.95);
74
+ backdrop-filter: blur(10px);
75
+ padding: 60px;
76
+ border-radius: 20px;
77
+ box-shadow: 0 30px 60px rgba(0, 0, 0, 0.12);
78
+ text-align: center;
79
+ max-width: 480px;
80
+ width: 90%;
81
+ transform: translateY(-20px);
82
+ animation: slideUp 0.6s ease-out;
83
+ }}
84
+
85
+ @keyframes slideUp {{
86
+ from {{
87
+ opacity: 0;
88
+ transform: translateY(0);
89
+ }}
90
+ to {{
91
+ opacity: 1;
92
+ transform: translateY(-20px);
93
+ }}
94
+ }}
95
+
96
+ .icon {{
97
+ width: 80px;
98
+ height: 80px;
99
+ margin: 0 auto 30px;
100
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
101
+ border-radius: 50%;
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ font-size: 40px;
106
+ color: white;
107
+ animation: pulse 2s ease-in-out infinite;
108
+ }}
109
+
110
+ @keyframes pulse {{
111
+ 0%, 100% {{
112
+ transform: scale(1);
113
+ }}
114
+ 50% {{
115
+ transform: scale(1.05);
116
+ }}
117
+ }}
118
+
119
+ h1 {{
120
+ font-size: 28px;
121
+ font-weight: 600;
122
+ margin-bottom: 20px;
123
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
124
+ -webkit-background-clip: text;
125
+ -webkit-text-fill-color: transparent;
126
+ background-clip: text;
127
+ }}
128
+
129
+ .message {{
130
+ font-size: 16px;
131
+ line-height: 1.6;
132
+ color: #4a5568;
133
+ margin-bottom: 20px;
134
+ }}
135
+
136
+ .user-id {{
137
+ font-weight: 600;
138
+ color: #667eea;
139
+ padding: 4px 12px;
140
+ background: rgba(102, 126, 234, 0.1);
141
+ border-radius: 6px;
142
+ display: inline-block;
143
+ margin: 0 4px;
144
+ }}
145
+
146
+ .button {{
147
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
148
+ color: white;
149
+ padding: 16px 40px;
150
+ border: none;
151
+ border-radius: 30px;
152
+ font-size: 16px;
153
+ font-weight: 500;
154
+ cursor: pointer;
155
+ transition: all 0.3s ease;
156
+ margin-top: 30px;
157
+ display: inline-block;
158
+ text-decoration: none;
159
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
160
+ }}
161
+
162
+ .button:hover {{
163
+ transform: translateY(-2px);
164
+ box-shadow: 0 7px 20px rgba(102, 126, 234, 0.4);
165
+ }}
166
+
167
+ .button:active {{
168
+ transform: translateY(0);
169
+ }}
170
+
171
+ .auto-close {{
172
+ font-size: 13px;
173
+ color: #a0aec0;
174
+ margin-top: 30px;
175
+ opacity: 0.8;
176
+ }}
177
+ </style>
178
+ <script>
179
+ setTimeout(function() {{
180
+ window.close();
181
+ }}, 10000);
182
+ </script>
183
+ </head>
184
+ <body>
185
+ <div class="container">
186
+ <div class="icon">✓</div>
187
+ <h1>Authentication Successful</h1>
188
+ <div class="message">
189
+ You've been authenticated as <span class="user-id">{user_display}</span>
190
+ </div>
191
+ <div class="message">
192
+ Your credentials have been securely saved. You can now close this window and retry your original command.
193
+ </div>
194
+ <button class="button" onclick="window.close()">Close Window</button>
195
+ <div class="auto-close">This window will close automatically in 10 seconds</div>
196
+ </div>
197
+ </body>
198
+ </html>"""
199
+ return HTMLResponse(content=content)
200
+
201
+
202
+ def create_server_error_response(error_detail: str) -> HTMLResponse:
203
+ """
204
+ Create a standardized server error response for OAuth processing failures.
205
+
206
+ Args:
207
+ error_detail: The detailed error message
208
+
209
+ Returns:
210
+ HTMLResponse with server error page
211
+ """
212
+ content = f"""
213
+ <html>
214
+ <head><title>Authentication Processing Error</title></head>
215
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;">
216
+ <h2 style="color: #d32f2f;">Authentication Processing Error</h2>
217
+ <p>An unexpected error occurred while processing your authentication: {error_detail}</p>
218
+ <p>Please try again. You can close this window.</p>
219
+ <script>setTimeout(function() {{ window.close(); }}, 10000);</script>
220
+ </body>
221
+ </html>
222
+ """
223
+ return HTMLResponse(content=content, status_code=500)
auth/scopes.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ Google Workspace OAuth Scopes
3
+
4
+ This module centralizes OAuth scope definitions for Google Workspace integration.
5
+ Separated from service_decorator.py to avoid circular imports.
6
+ """
7
+ import logging
8
+ from typing import Dict
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Temporary map to associate OAuth state with MCP session ID
13
+ # This should ideally be a more robust cache in a production system (e.g., Redis)
14
+ OAUTH_STATE_TO_SESSION_ID_MAP: Dict[str, str] = {}
15
+
16
+ # Individual OAuth Scope Constants
17
+ USERINFO_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
18
+ OPENID_SCOPE = 'openid'
19
+ CALENDAR_READONLY_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly'
20
+ CALENDAR_EVENTS_SCOPE = 'https://www.googleapis.com/auth/calendar.events'
21
+
22
+ # Google Drive scopes
23
+ DRIVE_READONLY_SCOPE = 'https://www.googleapis.com/auth/drive.readonly'
24
+ DRIVE_FILE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
25
+
26
+ # Google Docs scopes
27
+ DOCS_READONLY_SCOPE = 'https://www.googleapis.com/auth/documents.readonly'
28
+ DOCS_WRITE_SCOPE = 'https://www.googleapis.com/auth/documents'
29
+
30
+ # Gmail API scopes
31
+ GMAIL_READONLY_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
32
+ GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send'
33
+ GMAIL_COMPOSE_SCOPE = 'https://www.googleapis.com/auth/gmail.compose'
34
+ GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'
35
+ GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels'
36
+
37
+ # Google Chat API scopes
38
+ CHAT_READONLY_SCOPE = 'https://www.googleapis.com/auth/chat.messages.readonly'
39
+ CHAT_WRITE_SCOPE = 'https://www.googleapis.com/auth/chat.messages'
40
+ CHAT_SPACES_SCOPE = 'https://www.googleapis.com/auth/chat.spaces'
41
+
42
+ # Google Sheets API scopes
43
+ SHEETS_READONLY_SCOPE = 'https://www.googleapis.com/auth/spreadsheets.readonly'
44
+ SHEETS_WRITE_SCOPE = 'https://www.googleapis.com/auth/spreadsheets'
45
+
46
+ # Google Forms API scopes
47
+ FORMS_BODY_SCOPE = 'https://www.googleapis.com/auth/forms.body'
48
+ FORMS_BODY_READONLY_SCOPE = 'https://www.googleapis.com/auth/forms.body.readonly'
49
+ FORMS_RESPONSES_READONLY_SCOPE = 'https://www.googleapis.com/auth/forms.responses.readonly'
50
+
51
+ # Google Slides API scopes
52
+ SLIDES_SCOPE = 'https://www.googleapis.com/auth/presentations'
53
+ SLIDES_READONLY_SCOPE = 'https://www.googleapis.com/auth/presentations.readonly'
54
+
55
+ # Base OAuth scopes required for user identification
56
+ BASE_SCOPES = [
57
+ USERINFO_EMAIL_SCOPE,
58
+ OPENID_SCOPE
59
+ ]
60
+
61
+ # Service-specific scope groups
62
+ DOCS_SCOPES = [
63
+ DOCS_READONLY_SCOPE,
64
+ DOCS_WRITE_SCOPE
65
+ ]
66
+
67
+ CALENDAR_SCOPES = [
68
+ CALENDAR_READONLY_SCOPE,
69
+ CALENDAR_EVENTS_SCOPE
70
+ ]
71
+
72
+ DRIVE_SCOPES = [
73
+ DRIVE_READONLY_SCOPE,
74
+ DRIVE_FILE_SCOPE
75
+ ]
76
+
77
+ GMAIL_SCOPES = [
78
+ GMAIL_READONLY_SCOPE,
79
+ GMAIL_SEND_SCOPE,
80
+ GMAIL_COMPOSE_SCOPE,
81
+ GMAIL_MODIFY_SCOPE,
82
+ GMAIL_LABELS_SCOPE
83
+ ]
84
+
85
+ CHAT_SCOPES = [
86
+ CHAT_READONLY_SCOPE,
87
+ CHAT_WRITE_SCOPE,
88
+ CHAT_SPACES_SCOPE
89
+ ]
90
+
91
+ SHEETS_SCOPES = [
92
+ SHEETS_READONLY_SCOPE,
93
+ SHEETS_WRITE_SCOPE
94
+ ]
95
+
96
+ FORMS_SCOPES = [
97
+ FORMS_BODY_SCOPE,
98
+ FORMS_BODY_READONLY_SCOPE,
99
+ FORMS_RESPONSES_READONLY_SCOPE
100
+ ]
101
+
102
+ SLIDES_SCOPES = [
103
+ SLIDES_SCOPE,
104
+ SLIDES_READONLY_SCOPE
105
+ ]
106
+
107
+ # Combined scopes for all supported Google Workspace operations
108
+ SCOPES = list(set(BASE_SCOPES + CALENDAR_SCOPES + DRIVE_SCOPES + GMAIL_SCOPES + DOCS_SCOPES + CHAT_SCOPES + SHEETS_SCOPES + FORMS_SCOPES + SLIDES_SCOPES))