Skip to content

Commit 5b0b4c1

Browse files
authored
Merge pull request #2 from cfdude/dev
Sync fork with upstream main
2 parents 2a14b50 + cf03ef4 commit 5b0b4c1

File tree

12 files changed

+685
-208
lines changed

12 files changed

+685
-208
lines changed

README.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ A production-ready MCP server that integrates all major Google Workspace service
6262

6363
**<span style="color:#72898f">@</span> Gmail****<span style="color:#72898f">≡</span> Drive****<span style="color:#72898f">⧖</span> Calendar** **<span style="color:#72898f">≡</span> Docs**
6464
- Complete Gmail management, end to end coverage
65-
- Full calendar management with advanced capabilities
65+
- Full calendar management with advanced features
6666
- File operations with Office format support
67-
- Document creation, editing & comment management
67+
- Document creation, editing & comments
6868
- Deep, exhaustive support for fine grained editing
6969

7070
---
@@ -1107,6 +1107,47 @@ async def your_new_tool(service, param1: str, param2: int = 10):
11071107
- **Error Handling**: Native exceptions instead of manual error construction
11081108
- **Multi-Service Support**: `@require_multiple_services()` for complex tools
11091109

1110+
### Credential Store System
1111+
1112+
The server includes an abstract credential store API and a default backend for managing Google OAuth
1113+
credentials with support for multiple storage backends:
1114+
1115+
**Features:**
1116+
- **Abstract Interface**: `CredentialStore` base class defines standard operations (get, store, delete, list users)
1117+
- **Local File Storage**: `LocalDirectoryCredentialStore` implementation stores credentials as JSON files
1118+
- **Configurable Storage**: Environment variable `GOOGLE_MCP_CREDENTIALS_DIR` sets storage location
1119+
- **Multi-User Support**: Store and manage credentials for multiple Google accounts
1120+
- **Automatic Directory Creation**: Storage directory is created automatically if it doesn't exist
1121+
1122+
**Configuration:**
1123+
```bash
1124+
# Optional: Set custom credentials directory
1125+
export GOOGLE_MCP_CREDENTIALS_DIR="/path/to/credentials"
1126+
1127+
# Default locations (if GOOGLE_MCP_CREDENTIALS_DIR not set):
1128+
# - ~/.google_workspace_mcp/credentials (if home directory accessible)
1129+
# - ./.credentials (fallback)
1130+
```
1131+
1132+
**Usage Example:**
1133+
```python
1134+
from auth.credential_store import get_credential_store
1135+
1136+
# Get the global credential store instance
1137+
store = get_credential_store()
1138+
1139+
# Store credentials for a user
1140+
store.store_credential("[email protected]", credentials)
1141+
1142+
# Retrieve credentials
1143+
creds = store.get_credential("[email protected]")
1144+
1145+
# List all users with stored credentials
1146+
users = store.list_users()
1147+
```
1148+
1149+
The credential store automatically handles credential serialization, expiry parsing, and provides error handling for storage operations.
1150+
11101151
---
11111152

11121153
## <span style="color:#adbcbc">⊠ Security</span>

auth/credential_store.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"""
2+
Credential Store API for Google Workspace MCP
3+
4+
This module provides a standardized interface for credential storage and retrieval,
5+
supporting multiple backends configurable via environment variables.
6+
"""
7+
8+
import os
9+
import json
10+
import logging
11+
from abc import ABC, abstractmethod
12+
from typing import Optional, List
13+
from datetime import datetime
14+
from google.oauth2.credentials import Credentials
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class CredentialStore(ABC):
20+
"""Abstract base class for credential storage."""
21+
22+
@abstractmethod
23+
def get_credential(self, user_email: str) -> Optional[Credentials]:
24+
"""
25+
Get credentials for a user by email.
26+
27+
Args:
28+
user_email: User's email address
29+
30+
Returns:
31+
Google Credentials object or None if not found
32+
"""
33+
pass
34+
35+
@abstractmethod
36+
def store_credential(self, user_email: str, credentials: Credentials) -> bool:
37+
"""
38+
Store credentials for a user.
39+
40+
Args:
41+
user_email: User's email address
42+
credentials: Google Credentials object to store
43+
44+
Returns:
45+
True if successfully stored, False otherwise
46+
"""
47+
pass
48+
49+
@abstractmethod
50+
def delete_credential(self, user_email: str) -> bool:
51+
"""
52+
Delete credentials for a user.
53+
54+
Args:
55+
user_email: User's email address
56+
57+
Returns:
58+
True if successfully deleted, False otherwise
59+
"""
60+
pass
61+
62+
@abstractmethod
63+
def list_users(self) -> List[str]:
64+
"""
65+
List all users with stored credentials.
66+
67+
Returns:
68+
List of user email addresses
69+
"""
70+
pass
71+
72+
73+
class LocalDirectoryCredentialStore(CredentialStore):
74+
"""Credential store that uses local JSON files for storage."""
75+
76+
def __init__(self, base_dir: Optional[str] = None):
77+
"""
78+
Initialize the local JSON credential store.
79+
80+
Args:
81+
base_dir: Base directory for credential files. If None, uses the directory
82+
configured by the GOOGLE_MCP_CREDENTIALS_DIR environment variable,
83+
or defaults to ~/.google_workspace_mcp/credentials if the environment
84+
variable is not set.
85+
"""
86+
if base_dir is None:
87+
if os.getenv("GOOGLE_MCP_CREDENTIALS_DIR"):
88+
base_dir = os.getenv("GOOGLE_MCP_CREDENTIALS_DIR")
89+
else:
90+
home_dir = os.path.expanduser("~")
91+
if home_dir and home_dir != "~":
92+
base_dir = os.path.join(
93+
home_dir, ".google_workspace_mcp", "credentials"
94+
)
95+
else:
96+
base_dir = os.path.join(os.getcwd(), ".credentials")
97+
98+
self.base_dir = base_dir
99+
logger.info(f"LocalJsonCredentialStore initialized with base_dir: {base_dir}")
100+
101+
def _get_credential_path(self, user_email: str) -> str:
102+
"""Get the file path for a user's credentials."""
103+
if not os.path.exists(self.base_dir):
104+
os.makedirs(self.base_dir)
105+
logger.info(f"Created credentials directory: {self.base_dir}")
106+
return os.path.join(self.base_dir, f"{user_email}.json")
107+
108+
def get_credential(self, user_email: str) -> Optional[Credentials]:
109+
"""Get credentials from local JSON file."""
110+
creds_path = self._get_credential_path(user_email)
111+
112+
if not os.path.exists(creds_path):
113+
logger.debug(f"No credential file found for {user_email} at {creds_path}")
114+
return None
115+
116+
try:
117+
with open(creds_path, "r") as f:
118+
creds_data = json.load(f)
119+
120+
# Parse expiry if present
121+
expiry = None
122+
if creds_data.get("expiry"):
123+
try:
124+
expiry = datetime.fromisoformat(creds_data["expiry"])
125+
# Ensure timezone-naive datetime for Google auth library compatibility
126+
if expiry.tzinfo is not None:
127+
expiry = expiry.replace(tzinfo=None)
128+
except (ValueError, TypeError) as e:
129+
logger.warning(f"Could not parse expiry time for {user_email}: {e}")
130+
131+
credentials = Credentials(
132+
token=creds_data.get("token"),
133+
refresh_token=creds_data.get("refresh_token"),
134+
token_uri=creds_data.get("token_uri"),
135+
client_id=creds_data.get("client_id"),
136+
client_secret=creds_data.get("client_secret"),
137+
scopes=creds_data.get("scopes"),
138+
expiry=expiry,
139+
)
140+
141+
logger.debug(f"Loaded credentials for {user_email} from {creds_path}")
142+
return credentials
143+
144+
except (IOError, json.JSONDecodeError, KeyError) as e:
145+
logger.error(
146+
f"Error loading credentials for {user_email} from {creds_path}: {e}"
147+
)
148+
return None
149+
150+
def store_credential(self, user_email: str, credentials: Credentials) -> bool:
151+
"""Store credentials to local JSON file."""
152+
creds_path = self._get_credential_path(user_email)
153+
154+
creds_data = {
155+
"token": credentials.token,
156+
"refresh_token": credentials.refresh_token,
157+
"token_uri": credentials.token_uri,
158+
"client_id": credentials.client_id,
159+
"client_secret": credentials.client_secret,
160+
"scopes": credentials.scopes,
161+
"expiry": credentials.expiry.isoformat() if credentials.expiry else None,
162+
}
163+
164+
try:
165+
with open(creds_path, "w") as f:
166+
json.dump(creds_data, f, indent=2)
167+
logger.info(f"Stored credentials for {user_email} to {creds_path}")
168+
return True
169+
except IOError as e:
170+
logger.error(
171+
f"Error storing credentials for {user_email} to {creds_path}: {e}"
172+
)
173+
return False
174+
175+
def delete_credential(self, user_email: str) -> bool:
176+
"""Delete credential file for a user."""
177+
creds_path = self._get_credential_path(user_email)
178+
179+
try:
180+
if os.path.exists(creds_path):
181+
os.remove(creds_path)
182+
logger.info(f"Deleted credentials for {user_email} from {creds_path}")
183+
return True
184+
else:
185+
logger.debug(
186+
f"No credential file to delete for {user_email} at {creds_path}"
187+
)
188+
return True # Consider it a success if file doesn't exist
189+
except IOError as e:
190+
logger.error(
191+
f"Error deleting credentials for {user_email} from {creds_path}: {e}"
192+
)
193+
return False
194+
195+
def list_users(self) -> List[str]:
196+
"""List all users with credential files."""
197+
if not os.path.exists(self.base_dir):
198+
return []
199+
200+
users = []
201+
try:
202+
for filename in os.listdir(self.base_dir):
203+
if filename.endswith(".json"):
204+
user_email = filename[:-5] # Remove .json extension
205+
users.append(user_email)
206+
logger.debug(
207+
f"Found {len(users)} users with credentials in {self.base_dir}"
208+
)
209+
except OSError as e:
210+
logger.error(f"Error listing credential files in {self.base_dir}: {e}")
211+
212+
return sorted(users)
213+
214+
215+
# Global credential store instance
216+
_credential_store: Optional[CredentialStore] = None
217+
218+
219+
def get_credential_store() -> CredentialStore:
220+
"""
221+
Get the global credential store instance.
222+
223+
Returns:
224+
Configured credential store instance
225+
"""
226+
global _credential_store
227+
228+
if _credential_store is None:
229+
# always use LocalJsonCredentialStore as the default
230+
# Future enhancement: support other backends via environment variables
231+
_credential_store = LocalDirectoryCredentialStore()
232+
logger.info(f"Initialized credential store: {type(_credential_store).__name__}")
233+
234+
return _credential_store
235+
236+
237+
def set_credential_store(store: CredentialStore):
238+
"""
239+
Set the global credential store instance.
240+
241+
Args:
242+
store: Credential store instance to use
243+
"""
244+
global _credential_store
245+
_credential_store = store
246+
logger.info(f"Set credential store: {type(store).__name__}")

0 commit comments

Comments
 (0)