gsuite-mcp 0.4.0__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.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: gsuite-mcp
3
+ Version: 0.4.0
4
+ Summary: MCP server for Google Workspace (Gmail, Calendar, Drive, Sheets, Tasks, Contacts), built on gsuite-sdk
5
+ Author-email: Pablo Alaniz <alanizpablo@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://pabloalaniz.github.io/google-suite/
8
+ Project-URL: Repository, https://github.com/PabloAlaniz/google-suite
9
+ Keywords: mcp,model-context-protocol,google-workspace,gmail,google-sheets,agents
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications :: Email
15
+ Classifier: Topic :: Office/Business
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: gsuite-sdk>=0.3.0
19
+ Requires-Dist: mcp<3,>=2.3
20
+
21
+ # gsuite-mcp
22
+
23
+ <!-- mcp-name: io.github.PabloAlaniz/gsuite-sdk -->
24
+
25
+ An [MCP](https://modelcontextprotocol.io) server for Google Workspace: Gmail,
26
+ Calendar, Drive, Sheets, Tasks and Contacts, built on
27
+ [gsuite-sdk](https://pypi.org/project/gsuite-sdk/). It runs locally over stdio
28
+ and uses your own Google login.
29
+
30
+ ## Setup
31
+
32
+ 1. Get an OAuth client file from Google Cloud
33
+ ([guide](https://pabloalaniz.github.io/google-suite/GETTING_CREDENTIALS/))
34
+ and log in once with the gsuite CLI:
35
+
36
+ ```bash
37
+ pip install "gsuite-sdk[cli]"
38
+ gsuite auth login # Gmail, Calendar, Drive, Sheets
39
+ gsuite auth login --force --scopes default,tasks,contacts # + Tasks and Contacts
40
+ ```
41
+
42
+ The token is saved to `tokens.db` in the current directory (or
43
+ `GSUITE_TOKEN_DB_PATH`).
44
+
45
+ 2. Add the server to your MCP client, pointing at that token with absolute
46
+ paths:
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "gsuite": {
52
+ "command": "uvx",
53
+ "args": ["gsuite-mcp"],
54
+ "env": {
55
+ "GSUITE_TOKEN_DB_PATH": "/Users/you/gsuite/tokens.db",
56
+ "GSUITE_CREDENTIALS_FILE": "/Users/you/gsuite/credentials.json",
57
+ "GSUITE_DEFAULT_TIMEZONE": "America/Argentina/Buenos_Aires"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ Claude Code: `claude mcp add gsuite -e GSUITE_TOKEN_DB_PATH=/Users/you/gsuite/tokens.db -- uvx gsuite-mcp`
65
+
66
+ ## Tools
67
+
68
+ | Area | Tools |
69
+ |---|---|
70
+ | Account | `auth_status` |
71
+ | Gmail | `gmail_search`, `gmail_read`, `gmail_send`, `gmail_reply`, `gmail_create_draft` |
72
+ | Calendar | `calendar_list_events`, `calendar_create_event`, `calendar_delete_event`, `calendar_free_busy` |
73
+ | Drive | `drive_search`, `drive_read_file`, `drive_upload`, `drive_share` |
74
+ | Sheets | `sheets_read`, `sheets_append`, `sheets_update`, `sheets_upsert` |
75
+ | Tasks | `tasks_list`, `tasks_add`, `tasks_complete` |
76
+ | Contacts | `contacts_search`, `contacts_get` |
77
+
78
+ Read tools are annotated read-only; tools that send, share, overwrite or
79
+ delete are marked so clients can ask before running them. Dates are ISO 8601;
80
+ times without an offset use `GSUITE_DEFAULT_TIMEZONE` (UTC by default).
81
+
82
+ The same capabilities are available as an [agent skill](https://github.com/PabloAlaniz/google-suite/tree/main/skills/gsuite-sdk),
83
+ a CLI and a REST API: see [gsuite-sdk](https://pabloalaniz.github.io/google-suite/).
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,67 @@
1
+ # gsuite-mcp
2
+
3
+ <!-- mcp-name: io.github.PabloAlaniz/gsuite-sdk -->
4
+
5
+ An [MCP](https://modelcontextprotocol.io) server for Google Workspace: Gmail,
6
+ Calendar, Drive, Sheets, Tasks and Contacts, built on
7
+ [gsuite-sdk](https://pypi.org/project/gsuite-sdk/). It runs locally over stdio
8
+ and uses your own Google login.
9
+
10
+ ## Setup
11
+
12
+ 1. Get an OAuth client file from Google Cloud
13
+ ([guide](https://pabloalaniz.github.io/google-suite/GETTING_CREDENTIALS/))
14
+ and log in once with the gsuite CLI:
15
+
16
+ ```bash
17
+ pip install "gsuite-sdk[cli]"
18
+ gsuite auth login # Gmail, Calendar, Drive, Sheets
19
+ gsuite auth login --force --scopes default,tasks,contacts # + Tasks and Contacts
20
+ ```
21
+
22
+ The token is saved to `tokens.db` in the current directory (or
23
+ `GSUITE_TOKEN_DB_PATH`).
24
+
25
+ 2. Add the server to your MCP client, pointing at that token with absolute
26
+ paths:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "gsuite": {
32
+ "command": "uvx",
33
+ "args": ["gsuite-mcp"],
34
+ "env": {
35
+ "GSUITE_TOKEN_DB_PATH": "/Users/you/gsuite/tokens.db",
36
+ "GSUITE_CREDENTIALS_FILE": "/Users/you/gsuite/credentials.json",
37
+ "GSUITE_DEFAULT_TIMEZONE": "America/Argentina/Buenos_Aires"
38
+ }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ Claude Code: `claude mcp add gsuite -e GSUITE_TOKEN_DB_PATH=/Users/you/gsuite/tokens.db -- uvx gsuite-mcp`
45
+
46
+ ## Tools
47
+
48
+ | Area | Tools |
49
+ |---|---|
50
+ | Account | `auth_status` |
51
+ | Gmail | `gmail_search`, `gmail_read`, `gmail_send`, `gmail_reply`, `gmail_create_draft` |
52
+ | Calendar | `calendar_list_events`, `calendar_create_event`, `calendar_delete_event`, `calendar_free_busy` |
53
+ | Drive | `drive_search`, `drive_read_file`, `drive_upload`, `drive_share` |
54
+ | Sheets | `sheets_read`, `sheets_append`, `sheets_update`, `sheets_upsert` |
55
+ | Tasks | `tasks_list`, `tasks_add`, `tasks_complete` |
56
+ | Contacts | `contacts_search`, `contacts_get` |
57
+
58
+ Read tools are annotated read-only; tools that send, share, overwrite or
59
+ delete are marked so clients can ask before running them. Dates are ISO 8601;
60
+ times without an offset use `GSUITE_DEFAULT_TIMEZONE` (UTC by default).
61
+
62
+ The same capabilities are available as an [agent skill](https://github.com/PabloAlaniz/google-suite/tree/main/skills/gsuite-sdk),
63
+ a CLI and a REST API: see [gsuite-sdk](https://pabloalaniz.github.io/google-suite/).
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gsuite-mcp"
7
+ version = "0.4.0"
8
+ description = "MCP server for Google Workspace (Gmail, Calendar, Drive, Sheets, Tasks, Contacts), built on gsuite-sdk"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ {name = "Pablo Alaniz", email = "alanizpablo@gmail.com"}
14
+ ]
15
+ keywords = ["mcp", "model-context-protocol", "google-workspace", "gmail", "google-sheets", "agents"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Communications :: Email",
22
+ "Topic :: Office/Business",
23
+ ]
24
+ dependencies = [
25
+ "gsuite-sdk>=0.3.0",
26
+ "mcp>=2.3,<3",
27
+ ]
28
+
29
+ [project.scripts]
30
+ gsuite-mcp = "gsuite_mcp.server:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://pabloalaniz.github.io/google-suite/"
34
+ Repository = "https://github.com/PabloAlaniz/google-suite"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+ include = ["gsuite_mcp*"]
39
+
40
+ [tool.setuptools.package-data]
41
+ gsuite_mcp = ["py.typed"]
42
+
43
+ [tool.uv.sources]
44
+ gsuite-sdk = { workspace = true }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ """Google Suite MCP - Model Context Protocol server for gsuite-sdk."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from gsuite_mcp.server import build_server, main
6
+ from gsuite_mcp.services import Services
7
+
8
+ __all__ = [
9
+ "build_server",
10
+ "main",
11
+ "Services",
12
+ ]
File without changes
@@ -0,0 +1,55 @@
1
+ """SDK entities as JSON-ready dicts for tool results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from datetime import date, datetime
7
+ from typing import Any
8
+
9
+ # Bulky or internal fields agents don't need
10
+ SKIP = {"raw", "html", "plain", "messages", "references"}
11
+
12
+
13
+ def to_json(value: Any, *, skip: set[str] = SKIP) -> Any:
14
+ """Dataclasses become dicts (minus raw/private fields), dates become ISO strings."""
15
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
16
+ return {
17
+ f.name: to_json(getattr(value, f.name), skip=skip)
18
+ for f in dataclasses.fields(value)
19
+ if not f.name.startswith("_") and f.name not in skip
20
+ }
21
+ if isinstance(value, datetime | date):
22
+ return value.isoformat()
23
+ if isinstance(value, dict):
24
+ return {str(k): to_json(v, skip=skip) for k, v in value.items()}
25
+ if isinstance(value, list | tuple | set):
26
+ return [to_json(v, skip=skip) for v in value]
27
+ return value
28
+
29
+
30
+ def message_summary(message: Any) -> dict[str, Any]:
31
+ """A Gmail message without its body: what a search result needs."""
32
+ return {
33
+ "id": message.id,
34
+ "thread_id": message.thread_id,
35
+ "subject": message.subject,
36
+ "sender": message.sender,
37
+ "date": to_json(message.date),
38
+ "snippet": message.snippet,
39
+ "labels": list(message.labels),
40
+ "attachments": [a.filename for a in message.attachments],
41
+ }
42
+
43
+
44
+ def message_full(message: Any) -> dict[str, Any]:
45
+ """A Gmail message with its text body and attachment metadata."""
46
+ return {
47
+ **message_summary(message),
48
+ "recipient": message.recipient,
49
+ "cc": message.cc,
50
+ "body": message.body,
51
+ "attachments": [
52
+ {"id": a.id, "filename": a.filename, "mime_type": a.mime_type, "size": a.size}
53
+ for a in message.attachments
54
+ ],
55
+ }
@@ -0,0 +1,412 @@
1
+ """MCP server for gsuite-sdk: Gmail, Calendar, Drive, Sheets, Tasks and Contacts tools.
2
+
3
+ Runs over stdio (``gsuite-mcp``) and reuses the login of ``gsuite auth login``
4
+ (GSUITE_TOKEN_DB_PATH / GSUITE_CREDENTIALS_FILE). Tools are annotated so
5
+ clients can tell read-only ones from those that send, share or delete.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ from collections.abc import Callable
12
+ from datetime import date, datetime
13
+ from typing import Any, Literal, TypeVar
14
+
15
+ from mcp.server.mcpserver import MCPServer
16
+ from mcp.server.mcpserver.exceptions import ToolError
17
+ from mcp.types import ToolAnnotations
18
+
19
+ from gsuite_core import __version__
20
+ from gsuite_core.exceptions import GSuiteError, PermissionDeniedError
21
+ from gsuite_mcp.serialize import message_full, message_summary, to_json
22
+ from gsuite_mcp.services import Services
23
+
24
+ INSTRUCTIONS = """\
25
+ Google Workspace for the signed-in user: Gmail, Calendar, Drive, Sheets, Tasks
26
+ and Contacts. Read tools are safe to call freely. Ask the user before tools
27
+ that send email, invite attendees, share files or delete anything. Dates are
28
+ ISO 8601 (2026-03-01 or 2026-03-01T10:00); times without an offset use the
29
+ server's GSUITE_DEFAULT_TIMEZONE (UTC by default). If a tool says there is no
30
+ login, tell the user to run `gsuite auth login`."""
31
+
32
+ READ = ToolAnnotations(read_only_hint=True, open_world_hint=True)
33
+ WRITE = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=True)
34
+ DESTRUCTIVE = ToolAnnotations(read_only_hint=False, destructive_hint=True, open_world_hint=True)
35
+
36
+ SendUpdates = Literal["all", "externalOnly", "none"]
37
+ F = TypeVar("F", bound=Callable[..., Any])
38
+
39
+
40
+ def sdk_errors(func: F) -> F:
41
+ """SDK errors become tool errors the agent can read."""
42
+
43
+ @functools.wraps(func)
44
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
45
+ try:
46
+ return func(*args, **kwargs)
47
+ except ToolError:
48
+ raise
49
+ except PermissionDeniedError as exc:
50
+ raise ToolError(
51
+ f"{exc.message}. Usually a scope missing from the login: run "
52
+ "`gsuite auth login --force --scopes default,tasks,contacts`."
53
+ ) from exc
54
+ except GSuiteError as exc:
55
+ raise ToolError(f"{type(exc).__name__}: {exc.message}") from exc
56
+ except ValueError as exc:
57
+ raise ToolError(str(exc)) from exc
58
+
59
+ return wrapper # type: ignore[return-value]
60
+
61
+
62
+ def results(items: list[Any]) -> dict[str, Any]:
63
+ """One object for list results (a bare list becomes one content block per item)."""
64
+ return {"count": len(items), "results": items}
65
+
66
+
67
+ def parse_when(value: str) -> datetime | date:
68
+ """ "2026-03-01" -> date; "2026-03-01T10:00" / "2026-03-01 10:00" -> datetime."""
69
+ try:
70
+ if len(value) == 10:
71
+ return date.fromisoformat(value)
72
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
73
+ except ValueError as exc:
74
+ raise ToolError(f"{value!r} is not an ISO date or datetime") from exc
75
+
76
+
77
+ def parse_day(value: str | None) -> date | None:
78
+ if value is None:
79
+ return None
80
+ when = parse_when(value)
81
+ return when.date() if isinstance(when, datetime) else when
82
+
83
+
84
+ def build_server(services: Services | None = None) -> MCPServer:
85
+ """The MCP server; tests pass Services built on mocked clients."""
86
+ svc = services or Services()
87
+ server = MCPServer(
88
+ "gsuite-sdk",
89
+ title="Google Workspace (gsuite-sdk)",
90
+ instructions=INSTRUCTIONS,
91
+ version=__version__,
92
+ website_url="https://pabloalaniz.github.io/google-suite/",
93
+ )
94
+
95
+ def tool(annotations: ToolAnnotations) -> Callable[[F], F]:
96
+ def register(func: F) -> F:
97
+ server.tool(annotations=annotations)(sdk_errors(func))
98
+ return func
99
+
100
+ return register
101
+
102
+ # ------------------------------------------------------------- auth
103
+
104
+ @tool(READ)
105
+ def auth_status() -> dict[str, Any]:
106
+ """Whether the server has a valid Google login, and for which account and scopes."""
107
+ auth = svc.auth()
108
+ credentials = auth.credentials
109
+ return {
110
+ "authenticated": True,
111
+ "email": auth.get_user_email(),
112
+ "scopes": sorted(credentials.scopes or []) if credentials else [],
113
+ }
114
+
115
+ # ------------------------------------------------------------ gmail
116
+
117
+ @tool(READ)
118
+ def gmail_search(query: str, max_results: int = 10) -> dict[str, Any]:
119
+ """Search Gmail with Gmail query syntax (from:, to:, subject:, is:unread,
120
+ has:attachment, newer_than:7d, label:...). Returns summaries without bodies."""
121
+ return results(
122
+ [message_summary(m) for m in svc.gmail.search(query, max_results=max_results)]
123
+ )
124
+
125
+ @tool(READ)
126
+ def gmail_read(message_id: str) -> dict[str, Any]:
127
+ """Read one email: headers, text body and attachment list."""
128
+ message = svc.gmail.get_message(message_id)
129
+ if message is None:
130
+ raise ToolError(f"No message {message_id}")
131
+ return message_full(message)
132
+
133
+ @tool(WRITE)
134
+ def gmail_send(
135
+ to: list[str],
136
+ subject: str,
137
+ body: str,
138
+ cc: list[str] | None = None,
139
+ bcc: list[str] | None = None,
140
+ html: bool = False,
141
+ ) -> dict[str, Any]:
142
+ """Send an email. Confirm recipients and content with the user first."""
143
+ sent = svc.gmail.send(to=to, subject=subject, body=body, cc=cc, bcc=bcc, html=html)
144
+ return {"id": sent.id, "thread_id": sent.thread_id}
145
+
146
+ @tool(WRITE)
147
+ def gmail_reply(message_id: str, body: str, reply_all: bool = False) -> dict[str, Any]:
148
+ """Reply in the thread of a message. Confirm the content with the user first."""
149
+ message = svc.gmail.get_message(message_id)
150
+ if message is None:
151
+ raise ToolError(f"No message {message_id}")
152
+ sent = svc.gmail.reply(message, body, reply_all=reply_all)
153
+ return {"id": sent.id, "thread_id": sent.thread_id}
154
+
155
+ @tool(WRITE)
156
+ def gmail_create_draft(
157
+ to: list[str], subject: str, body: str, cc: list[str] | None = None
158
+ ) -> dict[str, Any]:
159
+ """Save a draft without sending it (safe way to propose an email)."""
160
+ draft = svc.gmail.create_draft(to=to, subject=subject, body=body, cc=cc)
161
+ return {"draft_id": draft.id}
162
+
163
+ # --------------------------------------------------------- calendar
164
+
165
+ @tool(READ)
166
+ def calendar_list_events(
167
+ days: int = 7, calendar_id: str | None = None, max_results: int = 50
168
+ ) -> dict[str, Any]:
169
+ """Events from now until `days` ahead."""
170
+ events = svc.calendar.get_upcoming(
171
+ days=days, calendar_id=calendar_id, max_results=max_results
172
+ )
173
+ return results(to_json(events))
174
+
175
+ @tool(WRITE)
176
+ def calendar_create_event(
177
+ summary: str,
178
+ start: str,
179
+ end: str | None = None,
180
+ description: str | None = None,
181
+ location: str | None = None,
182
+ attendees: list[str] | None = None,
183
+ meet: bool = False,
184
+ send_updates: SendUpdates = "none",
185
+ calendar_id: str | None = None,
186
+ ) -> dict[str, Any]:
187
+ """Create an event. `start`/`end` are ISO; a date alone makes an all-day event.
188
+ `meet` adds a Google Meet link; `send_updates="all"` emails the attendees
189
+ (confirm with the user first)."""
190
+ start_at = parse_when(start)
191
+ event = svc.calendar.create_event(
192
+ summary=summary,
193
+ start=start_at,
194
+ end=parse_when(end) if end else None,
195
+ description=description,
196
+ location=location,
197
+ attendees=attendees,
198
+ all_day=not isinstance(start_at, datetime),
199
+ meet=meet,
200
+ send_updates=send_updates,
201
+ calendar_id=calendar_id,
202
+ )
203
+ return to_json(event) # type: ignore[no-any-return]
204
+
205
+ @tool(DESTRUCTIVE)
206
+ def calendar_delete_event(
207
+ event_id: str, send_updates: SendUpdates = "none", calendar_id: str | None = None
208
+ ) -> dict[str, Any]:
209
+ """Delete an event. Confirm with the user first."""
210
+ deleted = svc.calendar.delete_event(
211
+ event_id, calendar_id=calendar_id, send_updates=send_updates
212
+ )
213
+ if not deleted:
214
+ raise ToolError(f"No event {event_id}")
215
+ return {"deleted": event_id}
216
+
217
+ @tool(READ)
218
+ def calendar_free_busy(
219
+ time_min: str, time_max: str, calendars: list[str] | None = None
220
+ ) -> dict[str, Any]:
221
+ """Busy intervals per calendar (default: the user's) between two ISO datetimes."""
222
+ start, end = parse_when(time_min), parse_when(time_max)
223
+ if not (isinstance(start, datetime) and isinstance(end, datetime)):
224
+ raise ToolError("time_min and time_max need a time, e.g. 2026-03-02T09:00")
225
+ return to_json(svc.calendar.get_free_busy(start, end, calendars=calendars)) # type: ignore[no-any-return]
226
+
227
+ # ------------------------------------------------------------ drive
228
+
229
+ @tool(READ)
230
+ def drive_search(
231
+ name: str | None = None, query: str | None = None, max_results: int = 20
232
+ ) -> dict[str, Any]:
233
+ """Find files by name (contains) or with a raw Drive query
234
+ (e.g. "mimeType = 'application/pdf' and modifiedTime > '2026-01-01'")."""
235
+ if name and not query:
236
+ files = svc.drive.search(name)[:max_results]
237
+ else:
238
+ files = svc.drive.list_files(query=query, max_results=max_results)
239
+ return results(to_json(files))
240
+
241
+ @tool(READ)
242
+ def drive_read_file(file_id: str, max_chars: int = 20000) -> dict[str, Any]:
243
+ """Text of a file: Google Docs/Slides as text, Google Sheets as CSV, and
244
+ plain-text files as they are. Binary files (PDF, images) are refused."""
245
+ file = svc.drive.get(file_id)
246
+ if file is None:
247
+ raise ToolError(f"No file {file_id}")
248
+ mime = file.mime_type
249
+ if mime == "application/vnd.google-apps.spreadsheet":
250
+ text = svc.drive.export(file_id, "csv").decode("utf-8", "replace")
251
+ elif mime.startswith("application/vnd.google-apps."):
252
+ text = svc.drive.export(file_id, "txt").decode("utf-8", "replace")
253
+ elif mime.startswith("text/") or mime in ("application/json", "application/xml"):
254
+ text = svc.drive.get_content(file_id).decode("utf-8", "replace")
255
+ else:
256
+ raise ToolError(
257
+ f"{file.name} is {mime}, not text. Download it with "
258
+ f"`gsuite drive download {file_id}` instead."
259
+ )
260
+ return {
261
+ "id": file.id,
262
+ "name": file.name,
263
+ "mime_type": mime,
264
+ "truncated": len(text) > max_chars,
265
+ "text": text[:max_chars],
266
+ }
267
+
268
+ @tool(WRITE)
269
+ def drive_upload(
270
+ path: str, parent_id: str | None = None, name: str | None = None
271
+ ) -> dict[str, Any]:
272
+ """Upload a local file (path on the machine running this server)."""
273
+ return to_json(svc.drive.upload(path, name=name, parent_id=parent_id)) # type: ignore[no-any-return]
274
+
275
+ @tool(WRITE)
276
+ def drive_share(
277
+ file_id: str,
278
+ email: str | None = None,
279
+ role: Literal["reader", "commenter", "writer"] = "reader",
280
+ anyone: bool = False,
281
+ notify: bool = True,
282
+ ) -> dict[str, Any]:
283
+ """Share a file with a person (email) or with anyone who has the link
284
+ (anyone=true). Confirm with the user first."""
285
+ if not email and not anyone:
286
+ raise ToolError("Give an email, or anyone=true for a link anyone can open")
287
+ permission = svc.drive.add_permission(
288
+ file_id,
289
+ role=role,
290
+ type="anyone" if anyone else "user",
291
+ email=email,
292
+ notify=notify,
293
+ )
294
+ return to_json(permission) # type: ignore[no-any-return]
295
+
296
+ # ----------------------------------------------------------- sheets
297
+
298
+ def open_spreadsheet(spreadsheet: str) -> Any:
299
+ """By URL, ID or exact title."""
300
+ if spreadsheet.startswith("http"):
301
+ return svc.sheets.open_by_url(spreadsheet)
302
+ if len(spreadsheet) > 30:
303
+ return svc.sheets.open_by_key(spreadsheet)
304
+ return svc.sheets.open(spreadsheet)
305
+
306
+ def worksheet(spreadsheet: str, sheet: str | None) -> Any:
307
+ doc = open_spreadsheet(spreadsheet)
308
+ ws = doc.worksheet(sheet) if sheet else doc.sheet1
309
+ if ws is None:
310
+ raise ToolError(f"No tab {sheet!r} in {doc.title}")
311
+ return ws
312
+
313
+ @tool(READ)
314
+ def sheets_read(
315
+ spreadsheet: str, range: str = "A1:Z200", sheet: str | None = None
316
+ ) -> dict[str, Any]:
317
+ """Read a range. `spreadsheet` is a URL, an ID or an exact title; `sheet`
318
+ is the tab (default: the first one). Values come back as displayed."""
319
+ ws = worksheet(spreadsheet, sheet)
320
+ return {"sheet": ws.title, "range": range, "values": ws.get(range)}
321
+
322
+ @tool(WRITE)
323
+ def sheets_append(
324
+ spreadsheet: str, rows: list[list[Any]], sheet: str | None = None
325
+ ) -> dict[str, Any]:
326
+ """Append rows after the last one with data."""
327
+ ws = worksheet(spreadsheet, sheet)
328
+ ws.append_rows(rows)
329
+ return {"sheet": ws.title, "appended": len(rows)}
330
+
331
+ @tool(DESTRUCTIVE)
332
+ def sheets_update(
333
+ spreadsheet: str, range: str, values: list[list[Any]], sheet: str | None = None
334
+ ) -> dict[str, Any]:
335
+ """Overwrite a range starting at `range` (e.g. "B2") with a 2D list of values."""
336
+ ws = worksheet(spreadsheet, sheet)
337
+ ws.update(range, values)
338
+ return {"sheet": ws.title, "range": range, "rows": len(values)}
339
+
340
+ @tool(WRITE)
341
+ def sheets_upsert(
342
+ spreadsheet: str, rows: list[dict[str, Any]], key: str, sheet: str | None = None
343
+ ) -> dict[str, Any]:
344
+ """Update the rows whose `key` column matches and append the rest.
345
+ The first row of the sheet is the header; rows are {column: value}."""
346
+ ws = worksheet(spreadsheet, sheet)
347
+ return dict(ws.upsert(rows, key=key))
348
+
349
+ # ------------------------------------------------------------ tasks
350
+
351
+ @tool(READ)
352
+ def tasks_list(
353
+ tasklist_id: str = "@default",
354
+ show_completed: bool = False,
355
+ due_before: str | None = None,
356
+ max_results: int = 100,
357
+ ) -> dict[str, Any]:
358
+ """Tasks of a list (default: the user's main list), pending only unless
359
+ show_completed. Needs the Tasks scope."""
360
+ tasks = svc.tasks.list_tasks(
361
+ tasklist_id,
362
+ show_completed=show_completed,
363
+ due_max=parse_day(due_before),
364
+ max_results=max_results,
365
+ )
366
+ return results(to_json(tasks))
367
+
368
+ @tool(WRITE)
369
+ def tasks_add(
370
+ title: str,
371
+ notes: str | None = None,
372
+ due: str | None = None,
373
+ tasklist_id: str = "@default",
374
+ parent: str | None = None,
375
+ ) -> dict[str, Any]:
376
+ """Create a task (a subtask when `parent` is a task ID). `due` is a day."""
377
+ task = svc.tasks.create_task(
378
+ title, notes=notes, due=parse_day(due), tasklist_id=tasklist_id, parent=parent
379
+ )
380
+ return to_json(task) # type: ignore[no-any-return]
381
+
382
+ @tool(WRITE)
383
+ def tasks_complete(task_id: str, tasklist_id: str = "@default") -> dict[str, Any]:
384
+ """Mark a task as completed."""
385
+ return to_json(svc.tasks.complete_task(task_id, tasklist_id=tasklist_id)) # type: ignore[no-any-return]
386
+
387
+ # --------------------------------------------------------- contacts
388
+
389
+ @tool(READ)
390
+ def contacts_search(query: str, max_results: int = 10) -> dict[str, Any]:
391
+ """Contacts whose name, email, phone or company starts with `query`
392
+ (max 30). Needs the Contacts scope."""
393
+ return results(to_json(svc.contacts.search(query, max_results=max_results)))
394
+
395
+ @tool(READ)
396
+ def contacts_get(contact_id: str) -> dict[str, Any]:
397
+ """One contact by ID ("c123" or "people/c123")."""
398
+ contact = svc.contacts.get(contact_id)
399
+ if contact is None:
400
+ raise ToolError(f"No contact {contact_id}")
401
+ return to_json(contact) # type: ignore[no-any-return]
402
+
403
+ return server
404
+
405
+
406
+ def main() -> None:
407
+ """Console entry point: `gsuite-mcp` serves over stdio."""
408
+ build_server().run("stdio")
409
+
410
+
411
+ if __name__ == "__main__":
412
+ main()
@@ -0,0 +1,84 @@
1
+ """Lazily built SDK clients sharing one GoogleAuth, and the login check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cached_property
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from mcp.server.mcpserver.exceptions import ToolError
10
+
11
+ from gsuite_core import GoogleAuth, get_settings
12
+
13
+ if TYPE_CHECKING:
14
+ from gsuite_calendar import Calendar
15
+ from gsuite_contacts import Contacts
16
+ from gsuite_drive import Drive
17
+ from gsuite_gmail import Gmail
18
+ from gsuite_sheets import Sheets
19
+ from gsuite_tasks import Tasks
20
+
21
+
22
+ def login_hint() -> str:
23
+ settings = get_settings()
24
+ token_db = Path(settings.token_db_path).resolve()
25
+ return (
26
+ f"No valid Google login in {token_db}. Run `gsuite auth login` (add "
27
+ "`--scopes default,tasks,contacts` for Tasks and Contacts) in the directory "
28
+ "this server runs from, or set GSUITE_TOKEN_DB_PATH and GSUITE_CREDENTIALS_FILE "
29
+ "to absolute paths in the server's configuration."
30
+ )
31
+
32
+
33
+ class Services:
34
+ """One GoogleAuth for every client; clients are created on first use."""
35
+
36
+ def __init__(self, auth: Any = None) -> None:
37
+ self._auth = auth
38
+
39
+ def auth(self) -> Any:
40
+ if self._auth is None:
41
+ self._auth = GoogleAuth()
42
+ auth = self._auth
43
+ if auth.is_authenticated():
44
+ return auth
45
+ if auth.needs_refresh():
46
+ auth.refresh()
47
+ return auth
48
+ raise ToolError(login_hint())
49
+
50
+ @cached_property
51
+ def gmail(self) -> Gmail:
52
+ from gsuite_gmail import Gmail
53
+
54
+ return Gmail(self.auth())
55
+
56
+ @cached_property
57
+ def calendar(self) -> Calendar:
58
+ from gsuite_calendar import Calendar
59
+
60
+ return Calendar(self.auth())
61
+
62
+ @cached_property
63
+ def drive(self) -> Drive:
64
+ from gsuite_drive import Drive
65
+
66
+ return Drive(self.auth())
67
+
68
+ @cached_property
69
+ def sheets(self) -> Sheets:
70
+ from gsuite_sheets import Sheets
71
+
72
+ return Sheets(self.auth())
73
+
74
+ @cached_property
75
+ def tasks(self) -> Tasks:
76
+ from gsuite_tasks import Tasks
77
+
78
+ return Tasks(self.auth())
79
+
80
+ @cached_property
81
+ def contacts(self) -> Contacts:
82
+ from gsuite_contacts import Contacts
83
+
84
+ return Contacts(self.auth())
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: gsuite-mcp
3
+ Version: 0.4.0
4
+ Summary: MCP server for Google Workspace (Gmail, Calendar, Drive, Sheets, Tasks, Contacts), built on gsuite-sdk
5
+ Author-email: Pablo Alaniz <alanizpablo@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://pabloalaniz.github.io/google-suite/
8
+ Project-URL: Repository, https://github.com/PabloAlaniz/google-suite
9
+ Keywords: mcp,model-context-protocol,google-workspace,gmail,google-sheets,agents
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications :: Email
15
+ Classifier: Topic :: Office/Business
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: gsuite-sdk>=0.3.0
19
+ Requires-Dist: mcp<3,>=2.3
20
+
21
+ # gsuite-mcp
22
+
23
+ <!-- mcp-name: io.github.PabloAlaniz/gsuite-sdk -->
24
+
25
+ An [MCP](https://modelcontextprotocol.io) server for Google Workspace: Gmail,
26
+ Calendar, Drive, Sheets, Tasks and Contacts, built on
27
+ [gsuite-sdk](https://pypi.org/project/gsuite-sdk/). It runs locally over stdio
28
+ and uses your own Google login.
29
+
30
+ ## Setup
31
+
32
+ 1. Get an OAuth client file from Google Cloud
33
+ ([guide](https://pabloalaniz.github.io/google-suite/GETTING_CREDENTIALS/))
34
+ and log in once with the gsuite CLI:
35
+
36
+ ```bash
37
+ pip install "gsuite-sdk[cli]"
38
+ gsuite auth login # Gmail, Calendar, Drive, Sheets
39
+ gsuite auth login --force --scopes default,tasks,contacts # + Tasks and Contacts
40
+ ```
41
+
42
+ The token is saved to `tokens.db` in the current directory (or
43
+ `GSUITE_TOKEN_DB_PATH`).
44
+
45
+ 2. Add the server to your MCP client, pointing at that token with absolute
46
+ paths:
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "gsuite": {
52
+ "command": "uvx",
53
+ "args": ["gsuite-mcp"],
54
+ "env": {
55
+ "GSUITE_TOKEN_DB_PATH": "/Users/you/gsuite/tokens.db",
56
+ "GSUITE_CREDENTIALS_FILE": "/Users/you/gsuite/credentials.json",
57
+ "GSUITE_DEFAULT_TIMEZONE": "America/Argentina/Buenos_Aires"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ Claude Code: `claude mcp add gsuite -e GSUITE_TOKEN_DB_PATH=/Users/you/gsuite/tokens.db -- uvx gsuite-mcp`
65
+
66
+ ## Tools
67
+
68
+ | Area | Tools |
69
+ |---|---|
70
+ | Account | `auth_status` |
71
+ | Gmail | `gmail_search`, `gmail_read`, `gmail_send`, `gmail_reply`, `gmail_create_draft` |
72
+ | Calendar | `calendar_list_events`, `calendar_create_event`, `calendar_delete_event`, `calendar_free_busy` |
73
+ | Drive | `drive_search`, `drive_read_file`, `drive_upload`, `drive_share` |
74
+ | Sheets | `sheets_read`, `sheets_append`, `sheets_update`, `sheets_upsert` |
75
+ | Tasks | `tasks_list`, `tasks_add`, `tasks_complete` |
76
+ | Contacts | `contacts_search`, `contacts_get` |
77
+
78
+ Read tools are annotated read-only; tools that send, share, overwrite or
79
+ delete are marked so clients can ask before running them. Dates are ISO 8601;
80
+ times without an offset use `GSUITE_DEFAULT_TIMEZONE` (UTC by default).
81
+
82
+ The same capabilities are available as an [agent skill](https://github.com/PabloAlaniz/google-suite/tree/main/skills/gsuite-sdk),
83
+ a CLI and a REST API: see [gsuite-sdk](https://pabloalaniz.github.io/google-suite/).
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/gsuite_mcp/__init__.py
4
+ src/gsuite_mcp/py.typed
5
+ src/gsuite_mcp/serialize.py
6
+ src/gsuite_mcp/server.py
7
+ src/gsuite_mcp/services.py
8
+ src/gsuite_mcp.egg-info/PKG-INFO
9
+ src/gsuite_mcp.egg-info/SOURCES.txt
10
+ src/gsuite_mcp.egg-info/dependency_links.txt
11
+ src/gsuite_mcp.egg-info/entry_points.txt
12
+ src/gsuite_mcp.egg-info/requires.txt
13
+ src/gsuite_mcp.egg-info/top_level.txt
14
+ tests/test_mcp_server.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gsuite-mcp = gsuite_mcp.server:main
@@ -0,0 +1,2 @@
1
+ gsuite-sdk>=0.3.0
2
+ mcp<3,>=2.3
@@ -0,0 +1 @@
1
+ gsuite_mcp
@@ -0,0 +1,346 @@
1
+ """The MCP server, through an in-memory MCP client, on mocked SDK clients."""
2
+
3
+ import json
4
+ from datetime import UTC, date, datetime
5
+ from unittest.mock import MagicMock
6
+
7
+ import pytest
8
+ from mcp.client import Client
9
+
10
+ from gsuite_calendar import Event
11
+ from gsuite_contacts import Contact
12
+ from gsuite_core.exceptions import NotFoundError, PermissionDeniedError
13
+ from gsuite_drive import File, Permission
14
+ from gsuite_gmail import Message
15
+ from gsuite_mcp import Services, build_server
16
+ from gsuite_mcp.serialize import to_json
17
+ from gsuite_tasks import Task
18
+
19
+ DOC = "application/vnd.google-apps.document"
20
+ SHEET = "application/vnd.google-apps.spreadsheet"
21
+
22
+
23
+ @pytest.fixture
24
+ def auth():
25
+ mock = MagicMock(name="auth")
26
+ mock.is_authenticated.return_value = True
27
+ mock.get_user_email.return_value = "me@example.com"
28
+ mock.credentials.scopes = ["https://www.googleapis.com/auth/gmail.modify"]
29
+ return mock
30
+
31
+
32
+ @pytest.fixture
33
+ def services(auth):
34
+ svc = Services(auth)
35
+ for name in ("gmail", "calendar", "drive", "sheets", "tasks", "contacts"):
36
+ svc.__dict__[name] = MagicMock(name=name) # replaces the cached_property
37
+ return svc
38
+
39
+
40
+ @pytest.fixture
41
+ def server(services):
42
+ return build_server(services)
43
+
44
+
45
+ async def call(server, tool, /, **arguments):
46
+ async with Client(server) as client:
47
+ result = await client.call_tool(tool, arguments)
48
+ text = result.content[0].text if result.content else ""
49
+ if result.is_error:
50
+ return "error", text
51
+ return "ok", json.loads(text)
52
+
53
+
54
+ def _message(**overrides):
55
+ fields = {
56
+ "id": "m1",
57
+ "thread_id": "t1",
58
+ "subject": "Invoice",
59
+ "sender": "billing@example.com",
60
+ "recipient": "me@example.com",
61
+ "date": datetime(2026, 3, 1, 10, tzinfo=UTC),
62
+ "snippet": "Your invoice",
63
+ "plain": "Body text",
64
+ "labels": ["INBOX"],
65
+ }
66
+ fields.update(overrides)
67
+ return Message(**fields)
68
+
69
+
70
+ class TestTools:
71
+ async def test_tools_are_annotated(self, server):
72
+ async with Client(server) as client:
73
+ tools = {t.name: t for t in (await client.list_tools()).tools}
74
+
75
+ assert len(tools) == 23
76
+ assert tools["gmail_search"].annotations.read_only_hint is True
77
+ assert tools["gmail_send"].annotations.read_only_hint is False
78
+ assert tools["gmail_send"].annotations.destructive_hint is False
79
+ assert tools["calendar_delete_event"].annotations.destructive_hint is True
80
+ assert tools["sheets_update"].annotations.destructive_hint is True
81
+ assert all(t.description for t in tools.values())
82
+
83
+ async def test_auth_status(self, server):
84
+ status, body = await call(server, "auth_status")
85
+ assert status == "ok"
86
+ assert body["email"] == "me@example.com"
87
+
88
+
89
+ class TestAuthErrors:
90
+ async def test_no_login_explains_how_to_log_in(self, auth):
91
+ auth.is_authenticated.return_value = False
92
+ auth.needs_refresh.return_value = False
93
+ status, text = await call(build_server(Services(auth)), "gmail_search", query="x")
94
+ assert status == "error"
95
+ assert "gsuite auth login" in text
96
+
97
+ async def test_expired_token_is_refreshed(self, auth, services, server):
98
+ auth.is_authenticated.return_value = False
99
+ auth.needs_refresh.return_value = True
100
+ services.gmail.search.return_value = []
101
+ assert await call(server, "auth_status") == (
102
+ "ok",
103
+ {"authenticated": True, "email": "me@example.com", "scopes": auth.credentials.scopes},
104
+ )
105
+ auth.refresh.assert_called_once()
106
+
107
+ async def test_missing_scope_hint(self, services, server):
108
+ services.tasks.list_tasks.side_effect = PermissionDeniedError("tasks", "list")
109
+ status, text = await call(server, "tasks_list")
110
+ assert status == "error"
111
+ assert "--scopes default,tasks,contacts" in text
112
+
113
+ async def test_other_sdk_errors(self, services, server):
114
+ services.contacts.get.side_effect = NotFoundError("contacts", "contact", "c9")
115
+ status, text = await call(server, "contacts_get", contact_id="c9")
116
+ assert status == "error"
117
+ assert "NotFoundError" in text
118
+
119
+
120
+ class TestGmail:
121
+ async def test_search_returns_summaries(self, services, server):
122
+ services.gmail.search.return_value = [_message()]
123
+
124
+ status, body = await call(server, "gmail_search", query="is:unread", max_results=5)
125
+
126
+ assert status == "ok"
127
+ assert body["count"] == 1
128
+ assert body["results"] == [
129
+ {
130
+ "id": "m1",
131
+ "thread_id": "t1",
132
+ "subject": "Invoice",
133
+ "sender": "billing@example.com",
134
+ "date": "2026-03-01T10:00:00+00:00",
135
+ "snippet": "Your invoice",
136
+ "labels": ["INBOX"],
137
+ "attachments": [],
138
+ }
139
+ ]
140
+ services.gmail.search.assert_called_once_with("is:unread", max_results=5)
141
+
142
+ async def test_read(self, services, server):
143
+ services.gmail.get_message.return_value = _message()
144
+ status, body = await call(server, "gmail_read", message_id="m1")
145
+ assert body["body"] == "Body text"
146
+
147
+ async def test_read_missing(self, services, server):
148
+ services.gmail.get_message.return_value = None
149
+ assert (await call(server, "gmail_read", message_id="x"))[0] == "error"
150
+
151
+ async def test_send_and_reply(self, services, server):
152
+ services.gmail.send.return_value = _message(id="s1")
153
+ services.gmail.get_message.return_value = _message()
154
+ services.gmail.reply.return_value = _message(id="r1")
155
+
156
+ assert (await call(server, "gmail_send", to=["a@b.c"], subject="Hi", body="x"))[1] == {
157
+ "id": "s1",
158
+ "thread_id": "t1",
159
+ }
160
+ assert services.gmail.send.call_args.kwargs["to"] == ["a@b.c"]
161
+ await call(server, "gmail_reply", message_id="m1", body="Thanks", reply_all=True)
162
+ services.gmail.reply.assert_called_once_with(
163
+ services.gmail.get_message.return_value, "Thanks", reply_all=True
164
+ )
165
+
166
+ async def test_draft(self, services, server):
167
+ services.gmail.create_draft.return_value = MagicMock(id="d1")
168
+ status, body = await call(server, "gmail_create_draft", to=["a@b.c"], subject="S", body="B")
169
+ assert body == {"draft_id": "d1"}
170
+
171
+
172
+ class TestCalendar:
173
+ async def test_list(self, services, server):
174
+ services.calendar.get_upcoming.return_value = [
175
+ Event(id="e1", summary="Sync", start=datetime(2026, 3, 2, 10, tzinfo=UTC))
176
+ ]
177
+ status, body = await call(server, "calendar_list_events", days=2)
178
+ (event,) = body["results"]
179
+ assert event["summary"] == "Sync"
180
+ assert event["start"] == "2026-03-02T10:00:00+00:00"
181
+ assert "raw" not in event
182
+
183
+ async def test_create_timed_and_all_day(self, services, server):
184
+ services.calendar.create_event.return_value = Event(id="e1", summary="x")
185
+
186
+ await call(
187
+ server,
188
+ "calendar_create_event",
189
+ summary="Sync",
190
+ start="2026-03-02T10:00",
191
+ meet=True,
192
+ send_updates="all",
193
+ )
194
+ kwargs = services.calendar.create_event.call_args.kwargs
195
+ assert kwargs["start"] == datetime(2026, 3, 2, 10)
196
+ assert kwargs["all_day"] is False
197
+ assert (kwargs["meet"], kwargs["send_updates"]) == (True, "all")
198
+
199
+ await call(server, "calendar_create_event", summary="Holiday", start="2026-03-24")
200
+ kwargs = services.calendar.create_event.call_args.kwargs
201
+ assert kwargs["start"] == date(2026, 3, 24)
202
+ assert kwargs["all_day"] is True
203
+
204
+ async def test_bad_date(self, server):
205
+ status, text = await call(server, "calendar_create_event", summary="x", start="tomorrow")
206
+ assert status == "error"
207
+ assert "ISO" in text
208
+
209
+ async def test_delete_missing(self, services, server):
210
+ services.calendar.delete_event.return_value = False
211
+ assert (await call(server, "calendar_delete_event", event_id="e9"))[0] == "error"
212
+
213
+ async def test_free_busy_needs_times(self, services, server):
214
+ status, _ = await call(
215
+ server, "calendar_free_busy", time_min="2026-03-02", time_max="2026-03-03"
216
+ )
217
+ assert status == "error"
218
+ services.calendar.get_free_busy.return_value = {"primary": []}
219
+ status, body = await call(
220
+ server, "calendar_free_busy", time_min="2026-03-02T09:00", time_max="2026-03-02T18:00"
221
+ )
222
+ assert body == {"primary": []}
223
+
224
+
225
+ class TestDrive:
226
+ async def test_search_by_name_or_query(self, services, server):
227
+ services.drive.search.return_value = [
228
+ File(id="f1", name="a.pdf", mime_type="application/pdf")
229
+ ]
230
+ status, body = await call(server, "drive_search", name="a")
231
+ assert body["results"][0]["id"] == "f1"
232
+ services.drive.list_files.return_value = []
233
+ await call(server, "drive_search", query="trashed = false", max_results=5)
234
+ services.drive.list_files.assert_called_once_with(query="trashed = false", max_results=5)
235
+
236
+ @pytest.mark.parametrize(
237
+ ("mime", "export"),
238
+ [(DOC, "txt"), (SHEET, "csv"), ("application/vnd.google-apps.presentation", "txt")],
239
+ )
240
+ async def test_read_google_files_as_text(self, services, server, mime, export):
241
+ services.drive.get.return_value = File(id="f1", name="Doc", mime_type=mime)
242
+ services.drive.export.return_value = b"hello"
243
+ status, body = await call(server, "drive_read_file", file_id="f1")
244
+ assert body["text"] == "hello"
245
+ services.drive.export.assert_called_once_with("f1", export)
246
+
247
+ async def test_read_text_file_and_truncate(self, services, server):
248
+ services.drive.get.return_value = File(id="f1", name="a.txt", mime_type="text/plain")
249
+ services.drive.get_content.return_value = b"0123456789"
250
+ status, body = await call(server, "drive_read_file", file_id="f1", max_chars=4)
251
+ assert (body["text"], body["truncated"]) == ("0123", True)
252
+
253
+ async def test_binary_refused(self, services, server):
254
+ services.drive.get.return_value = File(id="f1", name="a.pdf", mime_type="application/pdf")
255
+ status, text = await call(server, "drive_read_file", file_id="f1")
256
+ assert status == "error"
257
+ assert "gsuite drive download f1" in text
258
+
259
+ async def test_share(self, services, server):
260
+ assert (await call(server, "drive_share", file_id="f1"))[0] == "error"
261
+ services.drive.add_permission.return_value = Permission(
262
+ id="p1", type="anyone", role="reader"
263
+ )
264
+ status, body = await call(server, "drive_share", file_id="f1", anyone=True)
265
+ assert body["type"] == "anyone"
266
+ assert services.drive.add_permission.call_args.kwargs["type"] == "anyone"
267
+
268
+
269
+ class TestSheets:
270
+ @pytest.mark.parametrize(
271
+ ("spreadsheet", "method"),
272
+ [
273
+ ("https://docs.google.com/spreadsheets/d/abc/edit", "open_by_url"),
274
+ ("1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789", "open_by_key"),
275
+ ("Budget", "open"),
276
+ ],
277
+ )
278
+ async def test_resolves_url_id_or_title(self, services, server, spreadsheet, method):
279
+ ws = getattr(services.sheets, method).return_value.sheet1
280
+ ws.title = "Data"
281
+ ws.get.return_value = [["a", "b"]]
282
+
283
+ status, body = await call(server, "sheets_read", spreadsheet=spreadsheet, range="A1:B1")
284
+
285
+ assert body == {"sheet": "Data", "range": "A1:B1", "values": [["a", "b"]]}
286
+
287
+ async def test_missing_tab(self, services, server):
288
+ services.sheets.open.return_value.worksheet.return_value = None
289
+ status, text = await call(server, "sheets_read", spreadsheet="Budget", sheet="Nope")
290
+ assert status == "error"
291
+
292
+ async def test_title_not_found(self, services, server):
293
+ services.sheets.open.side_effect = ValueError("Spreadsheet not found: Budget")
294
+ status, text = await call(server, "sheets_read", spreadsheet="Budget")
295
+ assert (status, text.endswith("Spreadsheet not found: Budget")) == ("error", True)
296
+
297
+ async def test_writes(self, services, server):
298
+ ws = services.sheets.open.return_value.sheet1
299
+ ws.title = "Data"
300
+ ws.upsert.return_value = {"updated": 1, "appended": 0}
301
+
302
+ assert (await call(server, "sheets_append", spreadsheet="B", rows=[["x", 1]]))[1][
303
+ "appended"
304
+ ] == 1
305
+ ws.append_rows.assert_called_once_with([["x", 1]])
306
+ await call(server, "sheets_update", spreadsheet="B", range="B2", values=[[5]])
307
+ ws.update.assert_called_once_with("B2", [[5]])
308
+ status, body = await call(
309
+ server, "sheets_upsert", spreadsheet="B", rows=[{"name": "Ana"}], key="name"
310
+ )
311
+ assert body == {"updated": 1, "appended": 0}
312
+
313
+
314
+ class TestTasksAndContacts:
315
+ async def test_tasks(self, services, server):
316
+ services.tasks.list_tasks.return_value = [
317
+ Task(id="t1", title="Pay", tasklist_id="@default", due=date(2026, 3, 1))
318
+ ]
319
+ status, body = await call(server, "tasks_list", due_before="2026-03-31")
320
+ assert body["results"][0]["due"] == "2026-03-01"
321
+ assert services.tasks.list_tasks.call_args.kwargs["due_max"] == date(2026, 3, 31)
322
+
323
+ services.tasks.create_task.return_value = Task(id="t2", title="New", tasklist_id="@default")
324
+ await call(server, "tasks_add", title="New", due="2026-04-01T09:00")
325
+ assert services.tasks.create_task.call_args.kwargs["due"] == date(2026, 4, 1)
326
+
327
+ services.tasks.complete_task.return_value = Task(
328
+ id="t2", title="New", tasklist_id="@default", status="completed"
329
+ )
330
+ assert (await call(server, "tasks_complete", task_id="t2"))[1]["status"] == "completed"
331
+
332
+ async def test_contacts(self, services, server):
333
+ services.contacts.search.return_value = [
334
+ Contact(resource_name="people/c1", display_name="Ana", emails=["a@x.com"])
335
+ ]
336
+ status, body = await call(server, "contacts_search", query="ana")
337
+ assert body["results"][0]["emails"] == ["a@x.com"]
338
+ services.contacts.get.return_value = None
339
+ assert (await call(server, "contacts_get", contact_id="c9"))[0] == "error"
340
+
341
+
342
+ def test_to_json_skips_raw_and_private_fields():
343
+ message = _message()
344
+ data = to_json(message)
345
+ assert "raw" not in data and "_gmail" not in data and "plain" not in data
346
+ assert data["date"] == "2026-03-01T10:00:00+00:00"