mcp-google-multi 5.2.0-alpha.3 → 5.2.0-alpha.5
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.
- package/README.md +34 -141
- package/dist/index.js +1 -53
- package/dist/services.d.ts +11 -0
- package/dist/services.js +53 -0
- package/dist/tools/docs.d.ts +22 -0
- package/dist/tools/docs.js +187 -9
- package/dist/tools/generated/admin.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,168 +1,61 @@
|
|
|
1
1
|
# mcp-google-multi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar, Sheets, Docs, Slides, Forms, Contacts, Tasks, Chat, Meet, Classroom, Vault, Admin and more — **every OAuth-reachable Workspace API method** as a tool, across **multiple Google accounts** at once, from Claude Code or any MCP client.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- 🧰 **Exhaustive** — 871 tools across 28 services + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
|
|
8
|
+
- 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
|
|
9
|
+
- 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
- 🔒 **Secure by default** — refresh tokens **encrypted at rest** (AES-256-GCM); **writes are deny-by-default**; no telemetry — it talks only to Google.
|
|
11
|
-
- 📦 **npm-first** — install and run with `npx`; everything configured through env vars.
|
|
12
|
-
- 🧰 **~175 tools** across 13 services → full list in **[COVERAGE.md](./COVERAGE.md)**.
|
|
11
|
+
## Quick setup
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
You don't need to know anything about MCP or OAuth — five steps, all copy-paste:
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
1. **Install [Node.js](https://nodejs.org) 20 or newer**, then install the server:
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g mcp-google-multi
|
|
19
|
+
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
# 1) install
|
|
22
|
-
npm i -g mcp-google-multi
|
|
21
|
+
2. **Create your (free) Google app** so the server can sign in as you — one-time, ~2 minutes: follow [Google Cloud setup](./docs/google-cloud-setup.md). You come back with a **Client ID** and **Client Secret**.
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
# Easiest for a quick try — export them, or drop a .env in your working dir:
|
|
26
|
-
# GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_ACCOUNTS, MASTER_KEY, GOOGLE_PROFILE
|
|
23
|
+
3. **Create a file named `.env`** in the folder you'll run from, and fill in your values:
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
```bash
|
|
26
|
+
GOOGLE_CLIENT_ID=paste-your-client-id
|
|
27
|
+
GOOGLE_CLIENT_SECRET=paste-your-client-secret
|
|
28
|
+
# name each Google account with a short alias:
|
|
29
|
+
GOOGLE_ACCOUNTS=work:you@company.com,personal:you@gmail.com
|
|
30
|
+
# encryption key for stored tokens — generate one with: openssl rand -base64 32
|
|
31
|
+
MASTER_KEY=paste-the-generated-key
|
|
32
|
+
```
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
claude mcp add google-multi -s user -- npx -y mcp-google-multi
|
|
34
|
-
```
|
|
34
|
+
4. **Sign in each account** (a browser window opens; approve the permissions):
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
```bash
|
|
37
|
+
mcp-google-multi auth --account work
|
|
38
|
+
mcp-google-multi auth --account personal
|
|
39
|
+
```
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
5. **Connect it to Claude Code** (any MCP client works the same way):
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
```bash
|
|
44
|
+
claude mcp add google-multi -s user -- npx -y mcp-google-multi
|
|
45
|
+
```
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
Restart your client and the tools appear. Check everything with `mcp-google-multi config check`.
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
#!/usr/bin/env bash
|
|
46
|
-
# ~/.local/bin/mcp-google-multi-run — chmod +x, then register this as the MCP command
|
|
47
|
-
set -euo pipefail
|
|
48
|
-
export INFISICAL_TOKEN="$(infisical login --method=universal-auth \
|
|
49
|
-
--client-id "$YOUR_CLIENT_ID" --client-secret "$YOUR_CLIENT_SECRET" --plain --silent)"
|
|
50
|
-
exec infisical run --projectId <project> --env prod --path /mcp-google-multi \
|
|
51
|
-
-- npx -y mcp-google-multi
|
|
52
|
-
```
|
|
49
|
+
**Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Secrets in a vault](./docs/secrets.md) · [Upgrading from v4](./docs/upgrading-v4.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
|
|
53
50
|
|
|
54
|
-
|
|
55
|
-
claude mcp add google-multi -s user -- ~/.local/bin/mcp-google-multi-run
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Now the only thing on disk is the **encrypted** token store. Pass the token via the `INFISICAL_TOKEN` env var (as above), **not** a `--token` flag, so it never shows up in `ps`. (Any secrets manager works — Doppler, Vault, 1Password CLI, etc. — the pattern is the same.)
|
|
59
|
-
|
|
60
|
-
## Configuration
|
|
61
|
-
|
|
62
|
-
| Env var | Required | Description |
|
|
63
|
-
|---|---|---|
|
|
64
|
-
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | ✓ | OAuth **Desktop** client from Google Cloud |
|
|
65
|
-
| `GOOGLE_ACCOUNTS` | ✓ | `alias:email,…` — e.g. `work:you@co.com,personal:you@gmail.com` |
|
|
66
|
-
| `MASTER_KEY` | ✓ | base64 32-byte key that encrypts the token store (`openssl rand -base64 32`) |
|
|
67
|
-
| `GOOGLE_PROFILE` | — | write policy: `read-only` (default) · `safe-writes` · `full-writes` |
|
|
68
|
-
| `GOOGLE_READ_ONLY` | — | `true` = hard kill-switch for all writes |
|
|
69
|
-
| `GOOGLE_WRITE_ALLOW` / `GOOGLE_WRITE_DENY` | — | glob overrides, e.g. `calendar:*`, `*:delete*` |
|
|
70
|
-
| `GOOGLE_OPTIONAL_SCOPES` | — | extra bundles: `slides`, `forms`, `chat` |
|
|
71
|
-
| `GOOGLE_ADMIN_ACCOUNTS` | — | aliases granted Workspace-admin scopes (the account's own super-admin OAuth) |
|
|
72
|
-
| `GOOGLE_TOOLSETS` | — | `all` (default) or a CSV filter of: `gmail`, `drive`, `calendar`, `sheets`, `docs`, `contacts`, `searchconsole`, `tasks`, `meet`, `slides`, `forms`, `chat`, `admin` |
|
|
73
|
-
| `TOKEN_STORE_PATH` | — | override the encrypted token dir (default: `~/.config/mcp-google-multi/tokens`) |
|
|
74
|
-
| `DISCOVERY_CACHE_PATH` | — | override the Discovery-doc cache dir (default: `~/.config/mcp-google-multi/discovery`) |
|
|
75
|
-
| `GOOGLE_TRIM` | — | `off` (or `0`/`false`/`no`) disables compact JSON serialization of tool responses |
|
|
76
|
-
|
|
77
|
-
Inspect the resolved setup any time: `mcp-google-multi config check`.
|
|
78
|
-
|
|
79
|
-
## Discover-first tools (tiny idle context)
|
|
80
|
-
|
|
81
|
-
The server does **not** dump ~170 tool schemas into your model's context. At boot, `tools/list` exposes only one small `{service}_discover` tool per service (plus nothing else). Calling e.g. `drive_discover` returns that service's operation catalog (name, one-line summary, arguments, read/write class), reveals the operational tools, and emits `notifications/tools/list_changed` so the client re-fetches the list. An optional `query` argument filters the catalog.
|
|
82
|
-
|
|
83
|
-
Hidden is a listing concept, not a security boundary: operational tools stay callable at all times (existing prompts that call tools directly keep working), and write-control + OAuth scopes remain the real enforcement. Use `GOOGLE_TOOLSETS` to switch entire services off — it is a filter only: listing `slides`/`forms`/`chat`/`admin` does not enable them without their `GOOGLE_OPTIONAL_SCOPES` / `GOOGLE_ADMIN_ACCOUNTS` gates.
|
|
84
|
-
|
|
85
|
-
## Multi-account: fan out one call across accounts
|
|
86
|
-
|
|
87
|
-
Every read tool's `account` argument also accepts `"*"` (all configured accounts) or a CSV subset (`"work,personal"`). The server runs the call once per account (bounded concurrency) and returns per-account results — one account failing never hides the others:
|
|
88
|
-
|
|
89
|
-
```jsonc
|
|
90
|
-
{ "results": [
|
|
91
|
-
{ "account": "work", "ok": true, "data": { /* … */ } },
|
|
92
|
-
{ "account": "personal", "ok": false, "error": { "error": "auth_required", /* … */ } }
|
|
93
|
-
],
|
|
94
|
-
"partial": true }
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Fan-out is **read-only by design** (write tools take exactly one account), and the three read tools that save files to disk (`drive_download`, `drive_export`, `gmail_download_attachment`) are excluded so parallel accounts can't clobber the same path. `account_list` shows what's configured: alias, email, token health (`ok` / `expired_refreshable` / `needs_reauth` / `missing` / `decrypt_error`), and granted-vs-configured scopes — without ever touching token values.
|
|
98
|
-
|
|
99
|
-
`drive_transfer` copies or moves a file between two of your accounts: server-side share+copy when possible (the temporary read grant on the source is revoked right after; the copy gets a clean name, never "Copy of"), download+upload as the fallback. `move: true` trashes the source after a successful copy — that part is **delete-gated by write-control**, so `safe-writes` can copy but never move. Comments, revision history, and permissions don't transfer; if the fallback has to change the format (Drawings export as PNG), the result is flagged `lossy` and a requested move keeps the source intact. Native files over Google's 10MB export cap and binaries over 1GB can't take the fallback path.
|
|
100
|
-
|
|
101
|
-
## Escape hatch: any Workspace REST method
|
|
102
|
-
|
|
103
|
-
Two eager tools cover everything the curated set doesn't: `google_api_search` finds any method in Google's API Discovery index (including APIs with no dedicated tools here, like Drive Activity), and `google_api_call` invokes a method by its Discovery id (`drive.revisions.list`, `slides.presentations.create`, …) with path/query params and a JSON body. Calls run through your account's OAuth client and the **same write-control policy** as named tools: the read/create/update/delete class is derived from the method's HTTP verb and name (POST deletes like `batchDelete`/`clear` count as deletes), and policy globs/`GOOGLE_TOOLSETS` match the same service names as named tools (`people` counts as `contacts`, `admin_*` as `admin`; `driveactivity`/`drivelabels`/`groupssettings` have no named service and are always available).
|
|
104
|
-
|
|
105
|
-
Discovery documents are fetched from Google on first use and cached on disk for 7 days (`DISCOVERY_CACHE_PATH`, default `~/.config/mcp-google-multi/discovery`); a stale cache is used when offline.
|
|
106
|
-
|
|
107
|
-
## Lean responses by default
|
|
108
|
-
|
|
109
|
-
Tool responses are serialized compactly (no pretty-print token tax; set `GOOGLE_TRIM=off` to restore pretty JSON), and the fat readers ship sensible caps with per-call escape valves. The caps are per-call controls (`full` / `maxChars`) and are NOT affected by `GOOGLE_TRIM`:
|
|
110
|
-
|
|
111
|
-
- `drive_read` returns up to `maxChars` characters (default 100k) with `truncated`/`totalChars`/`offset` for paging — this also bounds Google Doc exports, which can reach 10MB. (Non-Google-native files over 2MB are still rejected with `too_large`, not paged.)
|
|
112
|
-
- `gmail_read` / `gmail_read_thread` cap each message body at 50k chars (`bodyTruncated` + `bodyTotalChars` flags); pass `full: true` for the whole body.
|
|
113
|
-
- `calendar_list_events` / `calendar_list_instances` trim descriptions to ~300 chars and drop empty/audit fields (`created`/`updated`) in list view; `calendar_get_event` always returns the full event.
|
|
114
|
-
|
|
115
|
-
## Write-control (deny-by-default)
|
|
116
|
-
|
|
117
|
-
Reads are never gated. **Every create/update/delete is off until you opt in** — pick a profile:
|
|
118
|
-
|
|
119
|
-
| `GOOGLE_PROFILE` | Allows |
|
|
120
|
-
|---|---|
|
|
121
|
-
| `read-only` (default) | reads only |
|
|
122
|
-
| `safe-writes` | create + update (deletes still blocked) |
|
|
123
|
-
| `full-writes` | everything |
|
|
124
|
-
|
|
125
|
-
`GOOGLE_READ_ONLY=true` overrides all. For fine control: `GOOGLE_WRITE_ALLOW="calendar:*, sheets:update*"` and `GOOGLE_WRITE_DENY="*:delete*"` (deny wins). `mcp-google-multi config check` prints the resolved policy and exactly which tools are enabled.
|
|
126
|
-
|
|
127
|
-
## What's covered
|
|
128
|
-
|
|
129
|
-
~175 tools across Gmail, Drive, Calendar, Sheets, Docs, Contacts, Search Console, Tasks, Meet, and (optional) Slides, Forms, Chat, Workspace Admin. **Full per-tool list → [COVERAGE.md](./COVERAGE.md).** Every tool takes an `account` argument matching one of your aliases.
|
|
130
|
-
|
|
131
|
-
## Google Cloud setup
|
|
132
|
-
|
|
133
|
-
1. [Google Cloud Console](https://console.cloud.google.com) → create or select a project.
|
|
134
|
-
2. Enable the APIs you'll use: Gmail, Drive, Calendar, Sheets, Docs, People, Search Console, Tasks, Meet (+ Slides / Forms / Chat / Admin SDK if you enable those bundles).
|
|
135
|
-
3. **APIs & Services → Credentials → Create Credentials → OAuth client ID → Desktop app**.
|
|
136
|
-
4. Add the redirect URI `http://localhost:4242/oauth2callback`.
|
|
137
|
-
5. Copy the **Client ID** + **Client Secret** into your environment.
|
|
138
|
-
|
|
139
|
-
## Upgrading from v4
|
|
140
|
-
|
|
141
|
-
v5 is a breaking change, but the migration is a one-time, ~2-minute step:
|
|
142
|
-
|
|
143
|
-
1. Update: `npm i -g mcp-google-multi@latest` (or update your client config).
|
|
144
|
-
2. Add **`MASTER_KEY`** to your environment (`openssl rand -base64 32`) — now required.
|
|
145
|
-
3. Encrypt existing tokens: `mcp-google-multi migrate-tokens` (reads your old `tokens/<alias>/token.json` and encrypts them) — or just re-auth each account.
|
|
146
|
-
4. Writes are now **deny-by-default** — set `GOOGLE_PROFILE=safe-writes` (or `full-writes`) to keep writing. (`GOOGLE_ALLOW_ADMIN_WRITES` is gone — replaced by write-control profiles.)
|
|
147
|
-
|
|
148
|
-
## Security
|
|
149
|
-
|
|
150
|
-
Your OAuth, your machine. Refresh tokens are **AES-256-GCM encrypted at rest** (decryptable only with your `MASTER_KEY`), writes are deny-by-default, and the server has **no telemetry** — it connects only to Google's APIs. Found a vulnerability? Report it privately — see [SECURITY.md](./SECURITY.md), never a public issue.
|
|
151
|
-
|
|
152
|
-
## Roadmap
|
|
153
|
-
|
|
154
|
-
Maintainer-led. Direction is tracked publicly as **[GitHub Milestones](https://github.com/bakissation/mcp-google-multi/milestones)** (exhaustive API coverage → service accounts + hosting in v6). Not accepting unsolicited feature PRs; bug reports are welcome.
|
|
155
|
-
|
|
156
|
-
## Contributing
|
|
157
|
-
|
|
158
|
-
See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Code of Conduct](./CODE_OF_CONDUCT.md). Security issues go to [SECURITY.md](./SECURITY.md), never a public issue.
|
|
159
|
-
|
|
160
|
-
## Credits
|
|
51
|
+
## Maintainer & credits
|
|
161
52
|
|
|
162
53
|
Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.xyz) · [@bakissation](https://github.com/bakissation). [Read the case study →](https://berkati.xyz/case-studies/mcp-google-multi/)
|
|
163
54
|
|
|
164
55
|
Development is **funded by [IdeaCrafters](https://ideacrafters.com)** ([@IdeaCraftersHQ](https://github.com/IdeaCraftersHQ)) — the studio that pays for this OSS to exist.
|
|
165
56
|
|
|
57
|
+
Thanks to contributors [@obatried](https://github.com/obatried), [@trevor-commits](https://github.com/trevor-commits), and [@mjreddy](https://github.com/mjreddy). The project is maintainer-led (roadmap on [Milestones](https://github.com/bakissation/mcp-google-multi/milestones); bug reports welcome, feature PRs by prior agreement — see [CONTRIBUTING.md](./CONTRIBUTING.md)). Security reports go to [SECURITY.md](./SECURITY.md), never a public issue.
|
|
58
|
+
|
|
166
59
|
## License
|
|
167
60
|
|
|
168
61
|
[MIT](./LICENSE)
|
package/dist/index.js
CHANGED
|
@@ -5,21 +5,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
-
import { registerGmailTools } from './tools/gmail.js';
|
|
9
|
-
import { registerDriveTools } from './tools/drive.js';
|
|
10
|
-
import { registerCalendarTools } from './tools/calendar.js';
|
|
11
|
-
import { registerSheetsTools } from './tools/sheets.js';
|
|
12
|
-
import { registerDocsTools } from './tools/docs.js';
|
|
13
|
-
import { registerContactsTools } from './tools/contacts.js';
|
|
14
|
-
import { registerSearchConsoleTools } from './tools/searchconsole.js';
|
|
15
|
-
import { registerTasksTools } from './tools/tasks.js';
|
|
16
|
-
import { registerMeetTools } from './tools/meet.js';
|
|
17
|
-
import { registerSlidesTools } from './tools/slides.js';
|
|
18
|
-
import { registerFormsTools } from './tools/forms.js';
|
|
19
|
-
import { registerChatTools } from './tools/chat.js';
|
|
20
|
-
import { registerAdminTools } from './tools/admin.js';
|
|
21
|
-
import { getOptionalBundles, getAdminAccounts } from './auth.js';
|
|
22
8
|
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
9
|
+
import { GENERATED_GATES, SERVICES } from './services.js';
|
|
23
10
|
import { ToolRegistry } from './registry.js';
|
|
24
11
|
import { registerDiscoverTools } from './discover.js';
|
|
25
12
|
import { registerEscapeTools } from './tools/google-api.js';
|
|
@@ -28,45 +15,6 @@ import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
|
28
15
|
import { resolvePolicy, isAllowed, describePolicy } from './write-control.js';
|
|
29
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
17
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
31
|
-
const SERVICES = [
|
|
32
|
-
{ name: 'gmail', register: registerGmailTools },
|
|
33
|
-
{ name: 'drive', register: registerDriveTools },
|
|
34
|
-
{ name: 'calendar', register: registerCalendarTools },
|
|
35
|
-
{ name: 'sheets', register: registerSheetsTools },
|
|
36
|
-
{ name: 'docs', register: registerDocsTools },
|
|
37
|
-
{ name: 'contacts', register: registerContactsTools },
|
|
38
|
-
{ name: 'searchconsole', register: registerSearchConsoleTools },
|
|
39
|
-
{ name: 'tasks', register: registerTasksTools },
|
|
40
|
-
{ name: 'meet', register: registerMeetTools },
|
|
41
|
-
{ name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
|
|
42
|
-
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
43
|
-
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
44
|
-
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
45
|
-
];
|
|
46
|
-
// Opt-in gates for generated-only services whose scopes are not granted by
|
|
47
|
-
// default; shared services (admin, forms, chat) reuse their curated gate below.
|
|
48
|
-
// workspaceevents has no dedicated scope (subscriptions use the underlying
|
|
49
|
-
// resource scopes), so it registers ungated.
|
|
50
|
-
const bundleGate = (name) => ({
|
|
51
|
-
enabled: () => new Set(getOptionalBundles()).has(name),
|
|
52
|
-
hint: `add "${name}" to GOOGLE_OPTIONAL_SCOPES`,
|
|
53
|
-
});
|
|
54
|
-
const GENERATED_GATES = {
|
|
55
|
-
appsmarket: bundleGate('appsmarket'),
|
|
56
|
-
classroom: bundleGate('classroom'),
|
|
57
|
-
cloudidentity: bundleGate('cloudidentity'),
|
|
58
|
-
cloudsearch: bundleGate('cloudsearch'),
|
|
59
|
-
driveactivity: bundleGate('driveactivity'),
|
|
60
|
-
drivelabels: bundleGate('drivelabels'),
|
|
61
|
-
groupsmigration: bundleGate('groupsmigration'),
|
|
62
|
-
groupssettings: bundleGate('groupssettings'),
|
|
63
|
-
keep: bundleGate('keep'),
|
|
64
|
-
licensing: bundleGate('licensing'),
|
|
65
|
-
postmaster: bundleGate('postmaster'),
|
|
66
|
-
reseller: bundleGate('reseller'),
|
|
67
|
-
script: bundleGate('script'),
|
|
68
|
-
vault: bundleGate('vault'),
|
|
69
|
-
};
|
|
70
18
|
function buildRegistry(server, policy) {
|
|
71
19
|
const registry = new ToolRegistry(server, policy);
|
|
72
20
|
const toolsets = getToolsets();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolRegistry } from './registry.js';
|
|
2
|
+
export interface ServiceEntry {
|
|
3
|
+
name: string;
|
|
4
|
+
register: (registry: ToolRegistry) => void;
|
|
5
|
+
enabled?: () => boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare const SERVICES: ServiceEntry[];
|
|
8
|
+
export declare const GENERATED_GATES: Record<string, {
|
|
9
|
+
enabled: () => boolean;
|
|
10
|
+
hint: string;
|
|
11
|
+
}>;
|
package/dist/services.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { registerGmailTools } from './tools/gmail.js';
|
|
2
|
+
import { registerDriveTools } from './tools/drive.js';
|
|
3
|
+
import { registerCalendarTools } from './tools/calendar.js';
|
|
4
|
+
import { registerSheetsTools } from './tools/sheets.js';
|
|
5
|
+
import { registerDocsTools } from './tools/docs.js';
|
|
6
|
+
import { registerContactsTools } from './tools/contacts.js';
|
|
7
|
+
import { registerSearchConsoleTools } from './tools/searchconsole.js';
|
|
8
|
+
import { registerTasksTools } from './tools/tasks.js';
|
|
9
|
+
import { registerMeetTools } from './tools/meet.js';
|
|
10
|
+
import { registerSlidesTools } from './tools/slides.js';
|
|
11
|
+
import { registerFormsTools } from './tools/forms.js';
|
|
12
|
+
import { registerChatTools } from './tools/chat.js';
|
|
13
|
+
import { registerAdminTools } from './tools/admin.js';
|
|
14
|
+
import { getOptionalBundles, getAdminAccounts } from './auth.js';
|
|
15
|
+
export const SERVICES = [
|
|
16
|
+
{ name: 'gmail', register: registerGmailTools },
|
|
17
|
+
{ name: 'drive', register: registerDriveTools },
|
|
18
|
+
{ name: 'calendar', register: registerCalendarTools },
|
|
19
|
+
{ name: 'sheets', register: registerSheetsTools },
|
|
20
|
+
{ name: 'docs', register: registerDocsTools },
|
|
21
|
+
{ name: 'contacts', register: registerContactsTools },
|
|
22
|
+
{ name: 'searchconsole', register: registerSearchConsoleTools },
|
|
23
|
+
{ name: 'tasks', register: registerTasksTools },
|
|
24
|
+
{ name: 'meet', register: registerMeetTools },
|
|
25
|
+
{ name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
|
|
26
|
+
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
27
|
+
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
28
|
+
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
29
|
+
];
|
|
30
|
+
// Opt-in gates for generated-only services whose scopes are not granted by
|
|
31
|
+
// default; shared services (admin, forms, chat) reuse their curated gate in
|
|
32
|
+
// buildRegistry. workspaceevents has no dedicated scope (subscriptions use the
|
|
33
|
+
// underlying resource scopes), so it registers ungated.
|
|
34
|
+
const bundleGate = (name) => ({
|
|
35
|
+
enabled: () => new Set(getOptionalBundles()).has(name),
|
|
36
|
+
hint: `add "${name}" to GOOGLE_OPTIONAL_SCOPES`,
|
|
37
|
+
});
|
|
38
|
+
export const GENERATED_GATES = {
|
|
39
|
+
appsmarket: bundleGate('appsmarket'),
|
|
40
|
+
classroom: bundleGate('classroom'),
|
|
41
|
+
cloudidentity: bundleGate('cloudidentity'),
|
|
42
|
+
cloudsearch: bundleGate('cloudsearch'),
|
|
43
|
+
driveactivity: bundleGate('driveactivity'),
|
|
44
|
+
drivelabels: bundleGate('drivelabels'),
|
|
45
|
+
groupsmigration: bundleGate('groupsmigration'),
|
|
46
|
+
groupssettings: bundleGate('groupssettings'),
|
|
47
|
+
keep: bundleGate('keep'),
|
|
48
|
+
licensing: bundleGate('licensing'),
|
|
49
|
+
postmaster: bundleGate('postmaster'),
|
|
50
|
+
reseller: bundleGate('reseller'),
|
|
51
|
+
script: bundleGate('script'),
|
|
52
|
+
vault: bundleGate('vault'),
|
|
53
|
+
};
|
package/dist/tools/docs.d.ts
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
|
+
export declare function extractPlainTextInRange(body: any, start: number, end: number): string;
|
|
3
|
+
export type HeadingLevel = 'title' | 1 | 2 | 3 | 4 | 5 | 6;
|
|
4
|
+
export interface HeadingEntry {
|
|
5
|
+
i: number;
|
|
6
|
+
level: HeadingLevel;
|
|
7
|
+
text: string;
|
|
8
|
+
startIndex: number;
|
|
9
|
+
endIndex: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function buildHeadingIndex(body: any): HeadingEntry[];
|
|
12
|
+
export type HeadingResolution = {
|
|
13
|
+
match: HeadingEntry;
|
|
14
|
+
} | {
|
|
15
|
+
error: 'not_found' | 'ambiguous';
|
|
16
|
+
candidates: HeadingEntry[];
|
|
17
|
+
};
|
|
18
|
+
export declare function resolveHeading(headings: HeadingEntry[], query: string): HeadingResolution;
|
|
19
|
+
export declare function findTab(tabs: any[] | undefined, tabId: string): any | undefined;
|
|
20
|
+
export declare function listTabs(tabs: any[] | undefined): {
|
|
21
|
+
tabId: string;
|
|
22
|
+
title: string;
|
|
23
|
+
}[];
|
|
2
24
|
export declare function registerDocsTools(server: ToolRegistry): void;
|
|
3
25
|
export declare function buildParagraphStyle(input: {
|
|
4
26
|
namedStyleType?: string;
|
package/dist/tools/docs.js
CHANGED
|
@@ -4,29 +4,129 @@ import { google } from 'googleapis';
|
|
|
4
4
|
import { ACCOUNTS } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
6
|
import { handleGoogleApiError } from './_errors.js';
|
|
7
|
+
import { capText } from '../trim.js';
|
|
7
8
|
const accountEnum = z.enum(ACCOUNTS);
|
|
8
9
|
function extractPlainText(body) {
|
|
10
|
+
return extractPlainTextInRange(body, 0, Infinity);
|
|
11
|
+
}
|
|
12
|
+
export function extractPlainTextInRange(body, start, end) {
|
|
9
13
|
if (!body?.content)
|
|
10
14
|
return '';
|
|
11
15
|
let text = '';
|
|
12
16
|
for (const element of body.content) {
|
|
17
|
+
const es = element.startIndex ?? 0;
|
|
18
|
+
const ee = element.endIndex ?? Infinity;
|
|
19
|
+
if (ee <= start || es >= end)
|
|
20
|
+
continue;
|
|
13
21
|
if (element.paragraph) {
|
|
14
22
|
for (const pe of element.paragraph.elements ?? []) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
const run = pe.textRun?.content;
|
|
24
|
+
if (!run)
|
|
25
|
+
continue;
|
|
26
|
+
const ps = pe.startIndex ?? es;
|
|
27
|
+
const from = Math.max(0, start - ps);
|
|
28
|
+
const to = Math.min(run.length, end - ps);
|
|
29
|
+
if (to > from)
|
|
30
|
+
text += run.slice(from, to);
|
|
18
31
|
}
|
|
19
32
|
}
|
|
20
33
|
else if (element.table) {
|
|
21
34
|
for (const row of element.table.tableRows ?? []) {
|
|
22
35
|
for (const cell of row.tableCells ?? []) {
|
|
23
|
-
text +=
|
|
36
|
+
text += extractPlainTextInRange(cell, start, end);
|
|
24
37
|
}
|
|
25
38
|
}
|
|
26
39
|
}
|
|
27
40
|
}
|
|
28
41
|
return text;
|
|
29
42
|
}
|
|
43
|
+
const HEADING_RANKS = {
|
|
44
|
+
TITLE: 0,
|
|
45
|
+
HEADING_1: 1,
|
|
46
|
+
HEADING_2: 2,
|
|
47
|
+
HEADING_3: 3,
|
|
48
|
+
HEADING_4: 4,
|
|
49
|
+
HEADING_5: 5,
|
|
50
|
+
HEADING_6: 6,
|
|
51
|
+
};
|
|
52
|
+
export function buildHeadingIndex(body) {
|
|
53
|
+
const found = [];
|
|
54
|
+
let docEnd = 0;
|
|
55
|
+
const walk = (b) => {
|
|
56
|
+
if (!b?.content)
|
|
57
|
+
return;
|
|
58
|
+
for (const element of b.content) {
|
|
59
|
+
if (typeof element.endIndex === 'number' && element.endIndex > docEnd)
|
|
60
|
+
docEnd = element.endIndex;
|
|
61
|
+
if (element.paragraph) {
|
|
62
|
+
const rank = HEADING_RANKS[element.paragraph.paragraphStyle?.namedStyleType ?? ''];
|
|
63
|
+
if (rank !== undefined) {
|
|
64
|
+
let text = '';
|
|
65
|
+
for (const pe of element.paragraph.elements ?? []) {
|
|
66
|
+
if (pe.textRun?.content)
|
|
67
|
+
text += pe.textRun.content;
|
|
68
|
+
}
|
|
69
|
+
found.push({ rank, text: text.trim(), startIndex: element.startIndex ?? 0 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (element.table) {
|
|
73
|
+
for (const row of element.table.tableRows ?? []) {
|
|
74
|
+
for (const cell of row.tableCells ?? [])
|
|
75
|
+
walk(cell);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(body);
|
|
81
|
+
return found.map((h, i) => {
|
|
82
|
+
let endIndex = docEnd;
|
|
83
|
+
for (let j = i + 1; j < found.length; j++) {
|
|
84
|
+
if (found[j].rank <= h.rank) {
|
|
85
|
+
endIndex = found[j].startIndex;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
i,
|
|
91
|
+
level: (h.rank === 0 ? 'title' : h.rank),
|
|
92
|
+
text: h.text,
|
|
93
|
+
startIndex: h.startIndex,
|
|
94
|
+
endIndex,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export function resolveHeading(headings, query) {
|
|
99
|
+
const q = query.trim().toLowerCase();
|
|
100
|
+
const exact = headings.filter((h) => h.text.toLowerCase() === q);
|
|
101
|
+
if (exact.length === 1)
|
|
102
|
+
return { match: exact[0] };
|
|
103
|
+
if (exact.length > 1)
|
|
104
|
+
return { error: 'ambiguous', candidates: exact };
|
|
105
|
+
const partial = headings.filter((h) => h.text.toLowerCase().includes(q));
|
|
106
|
+
if (partial.length === 1)
|
|
107
|
+
return { match: partial[0] };
|
|
108
|
+
if (partial.length > 1)
|
|
109
|
+
return { error: 'ambiguous', candidates: partial };
|
|
110
|
+
return { error: 'not_found', candidates: headings };
|
|
111
|
+
}
|
|
112
|
+
export function findTab(tabs, tabId) {
|
|
113
|
+
for (const tab of tabs ?? []) {
|
|
114
|
+
if (tab.tabProperties?.tabId === tabId)
|
|
115
|
+
return tab;
|
|
116
|
+
const nested = findTab(tab.childTabs, tabId);
|
|
117
|
+
if (nested)
|
|
118
|
+
return nested;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
export function listTabs(tabs) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const tab of tabs ?? []) {
|
|
125
|
+
out.push({ tabId: tab.tabProperties?.tabId ?? '', title: tab.tabProperties?.title ?? '' });
|
|
126
|
+
out.push(...listTabs(tab.childTabs));
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
30
130
|
export function registerDocsTools(server) {
|
|
31
131
|
server.registerTool('docs_create', {
|
|
32
132
|
description: 'Create a new Google Docs document',
|
|
@@ -90,22 +190,100 @@ export function registerDocsTools(server) {
|
|
|
90
190
|
}
|
|
91
191
|
});
|
|
92
192
|
server.registerTool('docs_read', {
|
|
93
|
-
description: 'Read document content as plain text',
|
|
193
|
+
description: 'Read document content as plain text with a heading index (returns up to maxChars characters per call). Pass heading or headingIndex to read one section, tabId to read a specific tab.',
|
|
94
194
|
inputSchema: {
|
|
95
195
|
account: accountEnum.describe('Google account alias'),
|
|
96
196
|
documentId: z.string().describe('Google Docs document ID'),
|
|
197
|
+
heading: z.string().optional()
|
|
198
|
+
.describe('Read only the section under this heading (case-insensitive; exact match first, then substring)'),
|
|
199
|
+
headingIndex: z.number().int().min(0).optional()
|
|
200
|
+
.describe('Read only the section at this position in the headings array (the `i` field)'),
|
|
201
|
+
tabId: z.string().optional()
|
|
202
|
+
.describe('Read a specific tab instead of the first tab'),
|
|
203
|
+
maxChars: z.number().min(1).max(2_000_000).default(50_000).optional()
|
|
204
|
+
.describe('Max characters of text to return (default: 50000)'),
|
|
205
|
+
offset: z.number().min(0).default(0).optional()
|
|
206
|
+
.describe('Character offset to continue a truncated read'),
|
|
97
207
|
},
|
|
98
|
-
}, async ({ account, documentId }) => {
|
|
208
|
+
}, async ({ account, documentId, heading, headingIndex, tabId, maxChars, offset }) => {
|
|
99
209
|
try {
|
|
210
|
+
if (heading !== undefined && headingIndex !== undefined) {
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
213
|
+
error: 'invalid_params',
|
|
214
|
+
message: 'Provide either heading or headingIndex, not both',
|
|
215
|
+
}, null, 2) }],
|
|
216
|
+
isError: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
100
219
|
const auth = await getClient(account);
|
|
101
220
|
const docs = google.docs({ version: 'v1', auth });
|
|
102
|
-
const res = await docs.documents.get({
|
|
103
|
-
|
|
221
|
+
const res = await docs.documents.get({
|
|
222
|
+
documentId,
|
|
223
|
+
...(tabId !== undefined ? { includeTabsContent: true } : {}),
|
|
224
|
+
});
|
|
225
|
+
let body = res.data.body;
|
|
226
|
+
if (tabId !== undefined) {
|
|
227
|
+
const tab = findTab(res.data.tabs, tabId);
|
|
228
|
+
if (!tab) {
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
231
|
+
error: 'not_found',
|
|
232
|
+
message: `Tab "${tabId}" not found in document ${documentId}`,
|
|
233
|
+
availableTabs: listTabs(res.data.tabs),
|
|
234
|
+
}, null, 2) }],
|
|
235
|
+
isError: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
body = tab.documentTab?.body;
|
|
239
|
+
}
|
|
240
|
+
const headings = buildHeadingIndex(body);
|
|
241
|
+
let section;
|
|
242
|
+
if (headingIndex !== undefined) {
|
|
243
|
+
section = headings[headingIndex];
|
|
244
|
+
if (!section) {
|
|
245
|
+
return {
|
|
246
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
247
|
+
error: 'invalid_params',
|
|
248
|
+
message: `headingIndex ${headingIndex} is out of range (document has ${headings.length} headings)`,
|
|
249
|
+
headings: headings.map(({ i, level, text }) => ({ i, level, text })),
|
|
250
|
+
}, null, 2) }],
|
|
251
|
+
isError: true,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else if (heading !== undefined) {
|
|
256
|
+
const resolved = resolveHeading(headings, heading);
|
|
257
|
+
if ('error' in resolved) {
|
|
258
|
+
return {
|
|
259
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
260
|
+
error: resolved.error === 'ambiguous' ? 'ambiguous_heading' : 'not_found',
|
|
261
|
+
message: resolved.error === 'ambiguous'
|
|
262
|
+
? `Heading "${heading}" matches multiple headings; use headingIndex to disambiguate`
|
|
263
|
+
: `Heading "${heading}" not found in document ${documentId}`,
|
|
264
|
+
candidates: resolved.candidates.map(({ i, level, text }) => ({ i, level, text })),
|
|
265
|
+
}, null, 2) }],
|
|
266
|
+
isError: true,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
section = resolved.match;
|
|
270
|
+
}
|
|
271
|
+
const text = section
|
|
272
|
+
? extractPlainTextInRange(body, section.startIndex, section.endIndex)
|
|
273
|
+
: extractPlainText(body);
|
|
274
|
+
const from = offset ?? 0;
|
|
275
|
+
const capped = capText(text, maxChars ?? 50_000, from);
|
|
104
276
|
return {
|
|
105
277
|
content: [{ type: 'text', text: JSON.stringify({
|
|
106
278
|
documentId: res.data.documentId,
|
|
107
279
|
title: res.data.title,
|
|
108
|
-
|
|
280
|
+
...(tabId !== undefined ? { tabId } : {}),
|
|
281
|
+
headings: headings.map(({ i, level, text: t }) => ({ i, level, text: t })),
|
|
282
|
+
...(section ? { section: { i: section.i, level: section.level, text: section.text } } : {}),
|
|
283
|
+
text: capped.text,
|
|
284
|
+
...(capped.truncated || from > 0
|
|
285
|
+
? { truncated: capped.truncated, totalChars: capped.totalChars, offset: from }
|
|
286
|
+
: {}),
|
|
109
287
|
}, null, 2) }],
|
|
110
288
|
};
|
|
111
289
|
}
|
|
@@ -1775,7 +1775,7 @@ export function registerAdminGeneratedTools(registry) {
|
|
|
1775
1775
|
},
|
|
1776
1776
|
});
|
|
1777
1777
|
registerGeneratedTool(registry, {
|
|
1778
|
-
name: "
|
|
1778
|
+
name: "admin_users_replace",
|
|
1779
1779
|
cud: "update",
|
|
1780
1780
|
description: "Updates a user. This method supports patch semantics, meaning that you only need to include the fields you wish to update. Fields that are not present in the re",
|
|
1781
1781
|
method: { id: "directory.users.update", httpMethod: "PUT", path: "admin/directory/v1/users/{userKey}", baseUrl: "https://admin.googleapis.com/", requiredParams: ["userKey"] },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "5.2.0-alpha.
|
|
3
|
+
"version": "5.2.0-alpha.5",
|
|
4
4
|
"description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"test": "vitest run",
|
|
57
57
|
"test:watch": "vitest",
|
|
58
58
|
"gen:discovery": "tsx scripts/fetch-discovery.ts",
|
|
59
|
-
"gen:tools": "tsx scripts/gen-tools.ts"
|
|
59
|
+
"gen:tools": "tsx scripts/gen-tools.ts",
|
|
60
|
+
"gen:coverage": "tsx scripts/gen-coverage.ts"
|
|
60
61
|
},
|
|
61
62
|
"dependencies": {
|
|
62
63
|
"@modelcontextprotocol/sdk": "^1.29.0",
|