mcp-google-multi 5.1.2 → 5.2.0-alpha.1
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 +8 -8
- package/dist/auth.js +4 -1
- package/dist/index.js +3 -1
- package/dist/token-store.js +30 -2
- package/dist/tools/forms.js +93 -0
- package/dist/tools/slides.d.ts +4 -0
- package/dist/tools/slides.js +183 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# mcp-google-multi
|
|
2
2
|
|
|
3
|
-
A **local** [MCP](https://modelcontextprotocol.io) server that gives Claude Code (and any MCP client) access to your **Google Workspace** — Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console (plus optional Forms, Chat, and Workspace Admin) — across **multiple Google accounts** at once.
|
|
3
|
+
A **local** [MCP](https://modelcontextprotocol.io) server that gives Claude Code (and any MCP client) access to your **Google Workspace** — Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console (plus optional Slides, Forms, Chat, and Workspace Admin) — across **multiple Google accounts** at once.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ A **local** [MCP](https://modelcontextprotocol.io) server that gives Claude Code
|
|
|
9
9
|
- 🔑 **Multi-account** — drive any number of your Google accounts from one server, each by a short alias.
|
|
10
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
11
|
- 📦 **npm-first** — install and run with `npx`; everything configured through env vars.
|
|
12
|
-
- 🧰 **~
|
|
12
|
+
- 🧰 **~175 tools** across 13 services → full list in **[COVERAGE.md](./COVERAGE.md)**.
|
|
13
13
|
|
|
14
14
|
> v5 is **local + user-OAuth only**. Service accounts and hosting (and the APIs they unlock) are on the [roadmap](https://github.com/bakissation/mcp-google-multi/milestones). **Upgrading from v4?** Jump to [Upgrading](#upgrading-from-v4).
|
|
15
15
|
|
|
@@ -67,9 +67,9 @@ Now the only thing on disk is the **encrypted** token store. Pass the token via
|
|
|
67
67
|
| `GOOGLE_PROFILE` | — | write policy: `read-only` (default) · `safe-writes` · `full-writes` |
|
|
68
68
|
| `GOOGLE_READ_ONLY` | — | `true` = hard kill-switch for all writes |
|
|
69
69
|
| `GOOGLE_WRITE_ALLOW` / `GOOGLE_WRITE_DENY` | — | glob overrides, e.g. `calendar:*`, `*:delete*` |
|
|
70
|
-
| `GOOGLE_OPTIONAL_SCOPES` | — | extra bundles: `forms`, `chat` |
|
|
70
|
+
| `GOOGLE_OPTIONAL_SCOPES` | — | extra bundles: `slides`, `forms`, `chat` |
|
|
71
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`, `forms`, `chat`, `admin` |
|
|
72
|
+
| `GOOGLE_TOOLSETS` | — | `all` (default) or a CSV filter of: `gmail`, `drive`, `calendar`, `sheets`, `docs`, `contacts`, `searchconsole`, `tasks`, `meet`, `slides`, `forms`, `chat`, `admin` |
|
|
73
73
|
| `TOKEN_STORE_PATH` | — | override the encrypted token dir (default: `~/.config/mcp-google-multi/tokens`) |
|
|
74
74
|
| `DISCOVERY_CACHE_PATH` | — | override the Discovery-doc cache dir (default: `~/.config/mcp-google-multi/discovery`) |
|
|
75
75
|
| `GOOGLE_TRIM` | — | `off` (or `0`/`false`/`no`) disables compact JSON serialization of tool responses |
|
|
@@ -80,7 +80,7 @@ Inspect the resolved setup any time: `mcp-google-multi config check`.
|
|
|
80
80
|
|
|
81
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
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 `forms`/`chat`/`admin` does not enable them without their `GOOGLE_OPTIONAL_SCOPES` / `GOOGLE_ADMIN_ACCOUNTS` gates.
|
|
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
84
|
|
|
85
85
|
## Multi-account: fan out one call across accounts
|
|
86
86
|
|
|
@@ -100,7 +100,7 @@ Fan-out is **read-only by design** (write tools take exactly one account), and t
|
|
|
100
100
|
|
|
101
101
|
## Escape hatch: any Workspace REST method
|
|
102
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
|
|
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
104
|
|
|
105
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
106
|
|
|
@@ -126,12 +126,12 @@ Reads are never gated. **Every create/update/delete is off until you opt in**
|
|
|
126
126
|
|
|
127
127
|
## What's covered
|
|
128
128
|
|
|
129
|
-
~
|
|
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
130
|
|
|
131
131
|
## Google Cloud setup
|
|
132
132
|
|
|
133
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 (+ Forms / Chat / Admin SDK if you enable those bundles).
|
|
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
135
|
3. **APIs & Services → Credentials → Create Credentials → OAuth client ID → Desktop app**.
|
|
136
136
|
4. Add the redirect URI `http://localhost:4242/oauth2callback`.
|
|
137
137
|
5. Copy the **Client ID** + **Client Secret** into your environment.
|
package/dist/auth.js
CHANGED
|
@@ -9,7 +9,7 @@ import { writeToken } from './token-store.js';
|
|
|
9
9
|
// ─── Scope tiers ────────────────────────────────────────────────────────
|
|
10
10
|
//
|
|
11
11
|
// BASE: always granted. Existing v3 surface + Tasks + Meet (added in v4.0.0).
|
|
12
|
-
// OPTIONAL: per-account opt-in via env GOOGLE_OPTIONAL_SCOPES="forms,chat".
|
|
12
|
+
// OPTIONAL: per-account opt-in via env GOOGLE_OPTIONAL_SCOPES="slides,forms,chat".
|
|
13
13
|
// ADMIN: per-account opt-in via env GOOGLE_ADMIN_ACCOUNTS="alias1,alias2".
|
|
14
14
|
//
|
|
15
15
|
// Personal Gmail accounts will 403 on admin scopes — never grant by default.
|
|
@@ -27,6 +27,9 @@ export const BASE_SCOPES = [
|
|
|
27
27
|
'https://www.googleapis.com/auth/meetings.space.readonly',
|
|
28
28
|
];
|
|
29
29
|
export const OPTIONAL_SCOPE_BUNDLES = {
|
|
30
|
+
slides: [
|
|
31
|
+
'https://www.googleapis.com/auth/presentations',
|
|
32
|
+
],
|
|
30
33
|
forms: [
|
|
31
34
|
'https://www.googleapis.com/auth/forms.body',
|
|
32
35
|
'https://www.googleapis.com/auth/forms.responses.readonly',
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { registerContactsTools } from './tools/contacts.js';
|
|
|
14
14
|
import { registerSearchConsoleTools } from './tools/searchconsole.js';
|
|
15
15
|
import { registerTasksTools } from './tools/tasks.js';
|
|
16
16
|
import { registerMeetTools } from './tools/meet.js';
|
|
17
|
+
import { registerSlidesTools } from './tools/slides.js';
|
|
17
18
|
import { registerFormsTools } from './tools/forms.js';
|
|
18
19
|
import { registerChatTools } from './tools/chat.js';
|
|
19
20
|
import { registerAdminTools } from './tools/admin.js';
|
|
@@ -36,6 +37,7 @@ const SERVICES = [
|
|
|
36
37
|
{ name: 'searchconsole', register: registerSearchConsoleTools },
|
|
37
38
|
{ name: 'tasks', register: registerTasksTools },
|
|
38
39
|
{ name: 'meet', register: registerMeetTools },
|
|
40
|
+
{ name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
|
|
39
41
|
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
40
42
|
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
41
43
|
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
@@ -66,7 +68,7 @@ function buildRegistry(server, policy) {
|
|
|
66
68
|
if (registry.services().length === 0) {
|
|
67
69
|
throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
|
|
68
70
|
`Known services: ${SERVICES.map((s) => s.name).join(', ')}. ` +
|
|
69
|
-
`Note: forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
|
|
71
|
+
`Note: slides/forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
|
|
70
72
|
}
|
|
71
73
|
registerDiscoverTools(registry, policy);
|
|
72
74
|
registerEscapeTools(registry, policy);
|
package/dist/token-store.js
CHANGED
|
@@ -6,6 +6,8 @@ const ENC_VERSION = 1;
|
|
|
6
6
|
const ALGO = 'aes-256-gcm';
|
|
7
7
|
const LOCK_TIMEOUT_MS = 5_000;
|
|
8
8
|
const LOCK_RETRY_MS = 10;
|
|
9
|
+
const RENAME_ATTEMPTS = 5;
|
|
10
|
+
const RENAME_RETRY_MS = 20;
|
|
9
11
|
export function deriveKey(masterKey) {
|
|
10
12
|
if (!masterKey) {
|
|
11
13
|
throw new Error('MASTER_KEY is required to encrypt/decrypt tokens. Generate one with: openssl rand -base64 32');
|
|
@@ -144,10 +146,36 @@ function writeTokenAtomic(alias, data) {
|
|
|
144
146
|
finally {
|
|
145
147
|
fs.closeSync(fd);
|
|
146
148
|
}
|
|
147
|
-
|
|
149
|
+
renameWithRetry(tmp, p);
|
|
148
150
|
}
|
|
149
151
|
finally {
|
|
150
|
-
|
|
152
|
+
try {
|
|
153
|
+
fs.rmSync(tmp, { force: true });
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// force only suppresses ENOENT; a Windows handle-holder can make this
|
|
157
|
+
// throw and mask the real write error. The orphan tmp is harmless.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Windows uses classic rename semantics (MoveFileExW without POSIX semantics),
|
|
162
|
+
// so replacing a token file that another process momentarily holds open — a
|
|
163
|
+
// concurrent readToken, antivirus, an indexer — fails with a transient
|
|
164
|
+
// EPERM/EACCES/EBUSY. Reads take no lock, so the token lock cannot prevent
|
|
165
|
+
// this; a short bounded retry absorbs it. POSIX rename never fails this way.
|
|
166
|
+
function renameWithRetry(from, to) {
|
|
167
|
+
for (let attempt = 1;; attempt++) {
|
|
168
|
+
try {
|
|
169
|
+
fs.renameSync(from, to);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const code = error.code;
|
|
174
|
+
const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
|
175
|
+
if (!transient || attempt >= RENAME_ATTEMPTS)
|
|
176
|
+
throw error;
|
|
177
|
+
sleep(RENAME_RETRY_MS * attempt);
|
|
178
|
+
}
|
|
151
179
|
}
|
|
152
180
|
}
|
|
153
181
|
export function writeToken(alias, data) {
|
package/dist/tools/forms.js
CHANGED
|
@@ -3,6 +3,7 @@ import { google } from 'googleapis';
|
|
|
3
3
|
import { ACCOUNTS } from '../accounts.js';
|
|
4
4
|
import { getClient } from '../client.js';
|
|
5
5
|
import { handleGoogleApiError } from './_errors.js';
|
|
6
|
+
import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
|
|
6
7
|
const accountEnum = z.enum(ACCOUNTS);
|
|
7
8
|
export function registerFormsTools(server) {
|
|
8
9
|
server.registerTool('forms_get', {
|
|
@@ -90,6 +91,98 @@ export function registerFormsTools(server) {
|
|
|
90
91
|
return handleFormsError(error, account);
|
|
91
92
|
}
|
|
92
93
|
});
|
|
94
|
+
server.registerTool('forms_create', {
|
|
95
|
+
description: 'Create a new Google Form with a title (add questions with forms_batch_update)',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
account: accountEnum.describe('Google account alias'),
|
|
98
|
+
title: z.string().describe('Form title shown to responders'),
|
|
99
|
+
documentTitle: z.string().optional().describe('Drive file name (default: the title)'),
|
|
100
|
+
},
|
|
101
|
+
}, async ({ account, title, documentTitle }) => {
|
|
102
|
+
try {
|
|
103
|
+
const auth = await getClient(account);
|
|
104
|
+
const forms = google.forms({ version: 'v1', auth });
|
|
105
|
+
const res = await forms.forms.create({
|
|
106
|
+
requestBody: { info: { title, documentTitle } },
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
110
|
+
formId: res.data.formId,
|
|
111
|
+
title: res.data.info?.title,
|
|
112
|
+
responderUri: res.data.responderUri,
|
|
113
|
+
revisionId: res.data.revisionId,
|
|
114
|
+
}, null, 2) }],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return handleFormsError(error, account);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
server.registerTool('forms_batch_update', {
|
|
122
|
+
description: 'Generic forms.batchUpdate pass-through: add/edit/delete questions, update form info and settings. See https://developers.google.com/workspace/forms/api/reference/rest/v1/forms/request',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
account: accountEnum.describe('Google account alias'),
|
|
125
|
+
formId: z.string().describe('Form ID'),
|
|
126
|
+
requests: coerceArray(coerceJson(z.record(z.string(), z.unknown())))
|
|
127
|
+
.describe('Array of Request objects, each with one request-type key like {createItem: {...}}'),
|
|
128
|
+
includeFormInResponse: coerceBoolean.optional().describe('Return the updated form in the response'),
|
|
129
|
+
writeControl: coerceJson(z.object({
|
|
130
|
+
requiredRevisionId: z.string().optional(),
|
|
131
|
+
targetRevisionId: z.string().optional(),
|
|
132
|
+
}).optional()).describe('Optional optimistic concurrency control'),
|
|
133
|
+
},
|
|
134
|
+
}, async ({ account, formId, requests, includeFormInResponse, writeControl }) => {
|
|
135
|
+
try {
|
|
136
|
+
const auth = await getClient(account);
|
|
137
|
+
const forms = google.forms({ version: 'v1', auth });
|
|
138
|
+
const res = await forms.forms.batchUpdate({
|
|
139
|
+
formId,
|
|
140
|
+
requestBody: {
|
|
141
|
+
requests: requests,
|
|
142
|
+
includeFormInResponse,
|
|
143
|
+
writeControl,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
return handleFormsError(error, account);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
server.registerTool('forms_set_publish_settings', {
|
|
155
|
+
description: 'Publish/unpublish a form and toggle whether it accepts responses (legacy forms without publish state are not supported)',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
account: accountEnum.describe('Google account alias'),
|
|
158
|
+
formId: z.string().describe('Form ID'),
|
|
159
|
+
isPublished: coerceBoolean.describe('Form is published and reachable by responders'),
|
|
160
|
+
isAcceptingResponses: coerceBoolean.optional().describe('Form accepts responses (requires published; default: follows isPublished)'),
|
|
161
|
+
},
|
|
162
|
+
}, async ({ account, formId, isPublished, isAcceptingResponses }) => {
|
|
163
|
+
try {
|
|
164
|
+
const auth = await getClient(account);
|
|
165
|
+
const forms = google.forms({ version: 'v1', auth });
|
|
166
|
+
const updateMask = ['publishState.isPublished'];
|
|
167
|
+
if (isAcceptingResponses !== undefined)
|
|
168
|
+
updateMask.push('publishState.isAcceptingResponses');
|
|
169
|
+
const res = await forms.forms.setPublishSettings({
|
|
170
|
+
formId,
|
|
171
|
+
requestBody: {
|
|
172
|
+
publishSettings: {
|
|
173
|
+
publishState: { isPublished, isAcceptingResponses },
|
|
174
|
+
},
|
|
175
|
+
updateMask: updateMask.join(','),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return handleFormsError(error, account);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
93
186
|
}
|
|
94
187
|
function handleFormsError(error, account) {
|
|
95
188
|
return handleGoogleApiError(error, account, "Forms tools require the optional \"forms\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=forms (or include \"forms\" in the list) and re-auth.");
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ToolRegistry } from '../registry.js';
|
|
2
|
+
export declare function registerSlidesTools(server: ToolRegistry): void;
|
|
3
|
+
export declare function extractPageText(elements: Array<Record<string, any>> | undefined): string;
|
|
4
|
+
export declare function summarizePresentation(p: Record<string, any>): Record<string, unknown>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { google } from 'googleapis';
|
|
3
|
+
import { ACCOUNTS } from '../accounts.js';
|
|
4
|
+
import { getClient } from '../client.js';
|
|
5
|
+
import { handleGoogleApiError } from './_errors.js';
|
|
6
|
+
import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
|
|
7
|
+
import { sliceClean } from '../trim.js';
|
|
8
|
+
const accountEnum = z.enum(ACCOUNTS);
|
|
9
|
+
const SLIDE_TEXT_DIGEST_CHARS = 200;
|
|
10
|
+
export function registerSlidesTools(server) {
|
|
11
|
+
server.registerTool('slides_create', {
|
|
12
|
+
description: 'Create a new Google Slides presentation (starts with one blank slide)',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
account: accountEnum.describe('Google account alias'),
|
|
15
|
+
title: z.string().describe('Presentation title'),
|
|
16
|
+
},
|
|
17
|
+
}, async ({ account, title }) => {
|
|
18
|
+
try {
|
|
19
|
+
const auth = await getClient(account);
|
|
20
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
21
|
+
const res = await slides.presentations.create({ requestBody: { title } });
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
24
|
+
presentationId: res.data.presentationId,
|
|
25
|
+
title: res.data.title,
|
|
26
|
+
url: `https://docs.google.com/presentation/d/${res.data.presentationId}/edit`,
|
|
27
|
+
firstSlideObjectId: res.data.slides?.[0]?.objectId,
|
|
28
|
+
}, null, 2) }],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return handleSlidesError(error, account);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
server.registerTool('slides_get', {
|
|
36
|
+
description: 'Get a presentation as a compact summary (title, per-slide objectId + text digest). Pass full:true for the raw Presentation JSON (can be very large).',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
account: accountEnum.describe('Google account alias'),
|
|
39
|
+
presentationId: z.string().describe('Presentation ID'),
|
|
40
|
+
full: coerceBoolean.optional().describe('Return the untrimmed Presentation resource'),
|
|
41
|
+
},
|
|
42
|
+
}, async ({ account, presentationId, full }) => {
|
|
43
|
+
try {
|
|
44
|
+
const auth = await getClient(account);
|
|
45
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
46
|
+
const res = await slides.presentations.get({ presentationId });
|
|
47
|
+
const payload = full ? res.data : summarizePresentation(res.data);
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return handleSlidesError(error, account);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
server.registerTool('slides_page_get', {
|
|
57
|
+
description: 'Get the full JSON of one slide/page (all page elements with geometry and text)',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
account: accountEnum.describe('Google account alias'),
|
|
60
|
+
presentationId: z.string().describe('Presentation ID'),
|
|
61
|
+
pageObjectId: z.string().describe('Page object ID (from slides_get)'),
|
|
62
|
+
},
|
|
63
|
+
}, async ({ account, presentationId, pageObjectId }) => {
|
|
64
|
+
try {
|
|
65
|
+
const auth = await getClient(account);
|
|
66
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
67
|
+
const res = await slides.presentations.pages.get({ presentationId, pageObjectId });
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return handleSlidesError(error, account);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
server.registerTool('slides_page_thumbnail', {
|
|
77
|
+
description: 'Get a rendered thumbnail image URL for one slide (the contentUrl expires after ~30 minutes)',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
account: accountEnum.describe('Google account alias'),
|
|
80
|
+
presentationId: z.string().describe('Presentation ID'),
|
|
81
|
+
pageObjectId: z.string().describe('Page object ID (from slides_get)'),
|
|
82
|
+
mimeType: z.enum(['PNG']).optional().describe('Thumbnail mime type (default: PNG)'),
|
|
83
|
+
thumbnailSize: z.enum(['LARGE', 'MEDIUM', 'SMALL', 'WIDTH2000_PX']).optional()
|
|
84
|
+
.describe('LARGE=1600px, MEDIUM=800px, SMALL=200px, WIDTH2000_PX=2000px wide (default: server-chosen)'),
|
|
85
|
+
},
|
|
86
|
+
}, async ({ account, presentationId, pageObjectId, mimeType, thumbnailSize }) => {
|
|
87
|
+
try {
|
|
88
|
+
const auth = await getClient(account);
|
|
89
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
90
|
+
const res = await slides.presentations.pages.getThumbnail({
|
|
91
|
+
presentationId,
|
|
92
|
+
pageObjectId,
|
|
93
|
+
'thumbnailProperties.mimeType': mimeType,
|
|
94
|
+
'thumbnailProperties.thumbnailSize': thumbnailSize,
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return handleSlidesError(error, account);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
server.registerTool('slides_batch_update', {
|
|
105
|
+
description: 'Generic presentations.batchUpdate pass-through. Accepts the full Request union (create/move/delete slides, insert text/shapes/images/tables, styling, replaceAllText, …). See https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations/request',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
account: accountEnum.describe('Google account alias'),
|
|
108
|
+
presentationId: z.string().describe('Presentation ID'),
|
|
109
|
+
requests: coerceArray(coerceJson(z.record(z.string(), z.unknown())))
|
|
110
|
+
.describe('Array of Request objects, each with one request-type key like {createSlide: {...}}'),
|
|
111
|
+
writeControl: coerceJson(z.object({
|
|
112
|
+
requiredRevisionId: z.string().optional(),
|
|
113
|
+
}).optional()).describe('Optional optimistic concurrency control'),
|
|
114
|
+
},
|
|
115
|
+
}, async ({ account, presentationId, requests, writeControl }) => {
|
|
116
|
+
try {
|
|
117
|
+
const auth = await getClient(account);
|
|
118
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
119
|
+
const res = await slides.presentations.batchUpdate({
|
|
120
|
+
presentationId,
|
|
121
|
+
requestBody: {
|
|
122
|
+
requests: requests,
|
|
123
|
+
writeControl,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
128
|
+
presentationId: res.data.presentationId,
|
|
129
|
+
replies: res.data.replies ?? [],
|
|
130
|
+
}, null, 2) }],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return handleSlidesError(error, account);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// ─── Helpers (exported for unit tests) ───────────────────────────────────
|
|
139
|
+
export function extractPageText(elements) {
|
|
140
|
+
const parts = [];
|
|
141
|
+
const visit = (els) => {
|
|
142
|
+
for (const el of els ?? []) {
|
|
143
|
+
for (const te of el.shape?.text?.textElements ?? []) {
|
|
144
|
+
const content = te.textRun?.content ?? te.autoText?.content;
|
|
145
|
+
if (content)
|
|
146
|
+
parts.push(content);
|
|
147
|
+
}
|
|
148
|
+
for (const row of el.table?.tableRows ?? []) {
|
|
149
|
+
for (const cell of row.tableCells ?? []) {
|
|
150
|
+
for (const te of cell.text?.textElements ?? []) {
|
|
151
|
+
if (te.textRun?.content)
|
|
152
|
+
parts.push(te.textRun.content);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
visit(el.elementGroup?.children);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
visit(elements);
|
|
160
|
+
return parts.join('').replace(/\s+/g, ' ').trim();
|
|
161
|
+
}
|
|
162
|
+
export function summarizePresentation(p) {
|
|
163
|
+
const slides = (p.slides ?? []).map((s, index) => {
|
|
164
|
+
const text = extractPageText(s.pageElements);
|
|
165
|
+
return {
|
|
166
|
+
index,
|
|
167
|
+
objectId: s.objectId,
|
|
168
|
+
elementCount: (s.pageElements ?? []).length,
|
|
169
|
+
...(text ? { text: sliceClean(text, SLIDE_TEXT_DIGEST_CHARS) } : {}),
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
presentationId: p.presentationId,
|
|
174
|
+
title: p.title,
|
|
175
|
+
revisionId: p.revisionId,
|
|
176
|
+
slideCount: slides.length,
|
|
177
|
+
slides,
|
|
178
|
+
hint: 'Pass full:true for the raw Presentation JSON, or slides_page_get for one slide.',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function handleSlidesError(error, account) {
|
|
182
|
+
return handleGoogleApiError(error, account, "Slides tools require the optional \"slides\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=slides (or include \"slides\" in the list) and re-auth.");
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.0-alpha.1",
|
|
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",
|
|
@@ -52,9 +52,10 @@
|
|
|
52
52
|
"auth": "node dist/index.js auth --account",
|
|
53
53
|
"migrate-tokens": "node dist/index.js migrate-tokens",
|
|
54
54
|
"lint": "eslint .",
|
|
55
|
-
"typecheck": "tsc --noEmit",
|
|
55
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
|
|
56
56
|
"test": "vitest run",
|
|
57
|
-
"test:watch": "vitest"
|
|
57
|
+
"test:watch": "vitest",
|
|
58
|
+
"gen:discovery": "tsx scripts/fetch-discovery.ts"
|
|
58
59
|
},
|
|
59
60
|
"dependencies": {
|
|
60
61
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
"@types/server-destroy": "^1.0.3",
|
|
73
74
|
"eslint": "^10.2.1",
|
|
74
75
|
"semantic-release": "^25.0.3",
|
|
76
|
+
"tsx": "^4.23.1",
|
|
75
77
|
"typescript": "^6.0.3",
|
|
76
78
|
"typescript-eslint": "^8.59.1",
|
|
77
79
|
"vitest": "^3.2.6"
|