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.
- gsuite_mcp-0.4.0/PKG-INFO +87 -0
- gsuite_mcp-0.4.0/README.md +67 -0
- gsuite_mcp-0.4.0/pyproject.toml +44 -0
- gsuite_mcp-0.4.0/setup.cfg +4 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp/__init__.py +12 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp/py.typed +0 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp/serialize.py +55 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp/server.py +412 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp/services.py +84 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp.egg-info/PKG-INFO +87 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp.egg-info/SOURCES.txt +14 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp.egg-info/dependency_links.txt +1 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp.egg-info/entry_points.txt +2 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp.egg-info/requires.txt +2 -0
- gsuite_mcp-0.4.0/src/gsuite_mcp.egg-info/top_level.txt +1 -0
- gsuite_mcp-0.4.0/tests/test_mcp_server.py +346 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"
|