mcp-google-multi 5.1.3-alpha.1 → 5.2.0-alpha.2

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 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
  [![npm](https://img.shields.io/npm/v/mcp-google-multi?label=npm&color=cb3837)](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
- - 🧰 **~170 tools** across 12 services → full list in **[COVERAGE.md](./COVERAGE.md)**.
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 Slides), 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`; `slides`/`driveactivity`/`drivelabels`/`groupssettings` have no named service and are always available).
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
- ~170 tools across Gmail, Drive, Calendar, Sheets, Docs, Contacts, Search Console, Tasks, Meet, and (optional) Forms, Chat, Workspace Admin. **Full per-tool list → [COVERAGE.md](./COVERAGE.md).** Every tool takes an `account` argument matching one of your aliases.
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',
@@ -0,0 +1,39 @@
1
+ import { getClient } from './client.js';
2
+ export declare const MAX_RESPONSE_CHARS = 100000;
3
+ export type QueryValue = string | number | boolean;
4
+ export type QueryParams = Record<string, QueryValue | QueryValue[]>;
5
+ export interface ApiMethodRef {
6
+ id: string;
7
+ httpMethod: string;
8
+ path: string;
9
+ baseUrl: string;
10
+ requiredParams: string[];
11
+ }
12
+ export interface ExecuteDeps {
13
+ getClientFn?: typeof getClient;
14
+ }
15
+ export interface ExecuteArgs {
16
+ account: string;
17
+ pathParams?: Record<string, string | number>;
18
+ queryParams?: QueryParams;
19
+ body?: unknown;
20
+ }
21
+ export declare function jsonResult(payload: unknown, isError?: boolean): {
22
+ content: {
23
+ type: "text";
24
+ text: string;
25
+ }[];
26
+ } | {
27
+ isError: true;
28
+ content: {
29
+ type: "text";
30
+ text: string;
31
+ }[];
32
+ };
33
+ export declare function buildQueryString(queryParams: QueryParams | undefined): string;
34
+ export declare function executeApiMethod(method: ApiMethodRef, args: ExecuteArgs, deps?: ExecuteDeps): Promise<{
35
+ content: {
36
+ type: "text";
37
+ text: string;
38
+ }[];
39
+ }>;
@@ -0,0 +1,75 @@
1
+ import { getClient } from './client.js';
2
+ import { expandPath, isGoogleApiUrl } from './discovery-client.js';
3
+ import { handleGoogleApiError } from './tools/_errors.js';
4
+ export const MAX_RESPONSE_CHARS = 100_000;
5
+ export function jsonResult(payload, isError = false) {
6
+ const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
7
+ return isError ? { ...base, isError: true } : base;
8
+ }
9
+ export function buildQueryString(queryParams) {
10
+ const usp = new URLSearchParams();
11
+ const merged = { alt: 'json', ...queryParams };
12
+ for (const [key, value] of Object.entries(merged)) {
13
+ if (Array.isArray(value)) {
14
+ for (const v of value)
15
+ usp.append(key, String(v));
16
+ }
17
+ else {
18
+ usp.append(key, String(value));
19
+ }
20
+ }
21
+ return usp.toString();
22
+ }
23
+ export async function executeApiMethod(method, args, deps = {}) {
24
+ if (args.queryParams?.alt === 'media') {
25
+ return jsonResult({
26
+ error: 'binary_unsupported',
27
+ message: 'Binary media download (alt=media) returns no usable JSON through this tool.',
28
+ hint: 'Use drive_download / drive_export for file content.',
29
+ retriable: false,
30
+ }, true);
31
+ }
32
+ let url;
33
+ try {
34
+ url = method.baseUrl + expandPath(method.path, args.pathParams ?? {});
35
+ }
36
+ catch (err) {
37
+ return jsonResult({
38
+ error: 'invalid_params',
39
+ message: err.message,
40
+ hint: `Required params for ${method.id}: ${method.requiredParams.join(', ') || '(none)'}`,
41
+ retriable: false,
42
+ }, true);
43
+ }
44
+ if (!isGoogleApiUrl(url)) {
45
+ return jsonResult({
46
+ error: 'untrusted_host',
47
+ message: `Refusing to send credentials to a non-googleapis.com host for "${method.id}".`,
48
+ hint: 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
49
+ retriable: false,
50
+ }, true);
51
+ }
52
+ try {
53
+ const getClientFn = deps.getClientFn ?? getClient;
54
+ const auth = await getClientFn(args.account);
55
+ const res = await auth.request({
56
+ // query string built by hand: gaxios comma-joins arrays, Google needs repeated keys
57
+ url: `${url}?${buildQueryString(args.queryParams)}`,
58
+ method: method.httpMethod,
59
+ data: args.body ?? undefined,
60
+ });
61
+ const text = JSON.stringify(res.data ?? null);
62
+ if (text.length > MAX_RESPONSE_CHARS) {
63
+ return jsonResult({
64
+ truncated: true,
65
+ totalChars: text.length,
66
+ head: text.slice(0, MAX_RESPONSE_CHARS),
67
+ hint: 'Narrow the request (fields mask, pageSize) to get complete JSON.',
68
+ });
69
+ }
70
+ return { content: [{ type: 'text', text }] };
71
+ }
72
+ catch (error) {
73
+ return handleGoogleApiError(error, args.account);
74
+ }
75
+ }
package/dist/index.js CHANGED
@@ -14,10 +14,12 @@ 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';
20
21
  import { getOptionalBundles, getAdminAccounts } from './auth.js';
22
+ import { GENERATED_SERVICES } from './tools/generated/index.js';
21
23
  import { ToolRegistry } from './registry.js';
22
24
  import { registerDiscoverTools } from './discover.js';
23
25
  import { registerEscapeTools } from './tools/google-api.js';
@@ -36,15 +38,19 @@ const SERVICES = [
36
38
  { name: 'searchconsole', register: registerSearchConsoleTools },
37
39
  { name: 'tasks', register: registerTasksTools },
38
40
  { name: 'meet', register: registerMeetTools },
41
+ { name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
39
42
  { name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
40
43
  { name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
41
44
  { name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
42
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
+ const GENERATED_GATES = {};
43
49
  function buildRegistry(server, policy) {
44
50
  const registry = new ToolRegistry(server, policy);
45
51
  const toolsets = getToolsets();
46
52
  if (toolsets !== 'all') {
47
- const known = new Set(SERVICES.map((s) => s.name));
53
+ const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
48
54
  for (const requested of toolsets) {
49
55
  if (!known.has(requested)) {
50
56
  process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
@@ -63,10 +69,23 @@ function buildRegistry(server, policy) {
63
69
  }
64
70
  svc.register(registry);
65
71
  }
72
+ for (const gen of GENERATED_SERVICES) {
73
+ if (!toolsetEnabled(toolsets, gen.name))
74
+ continue;
75
+ const curated = SERVICES.find((s) => s.name === gen.name);
76
+ const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
77
+ if (gate && !gate()) {
78
+ if (!curated && toolsets !== 'all') {
79
+ process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
80
+ }
81
+ continue;
82
+ }
83
+ gen.register(registry);
84
+ }
66
85
  if (registry.services().length === 0) {
67
86
  throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
68
87
  `Known services: ${SERVICES.map((s) => s.name).join(', ')}. ` +
69
- `Note: forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
88
+ `Note: slides/forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
70
89
  }
71
90
  registerDiscoverTools(registry, policy);
72
91
  registerEscapeTools(registry, policy);
package/dist/registry.js CHANGED
@@ -44,7 +44,7 @@ export class ToolRegistry {
44
44
  this.policy = policy;
45
45
  this.registerTool = ((name, config, handler) => {
46
46
  const service = SERVICE_OVERRIDES[name] ?? (name.includes('_') ? name.slice(0, name.indexOf('_')) : name);
47
- const cud = inferCud(name);
47
+ const cud = config.cud ?? inferCud(name);
48
48
  // destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
49
49
  const annotations = {
50
50
  readOnlyHint: cud === 'read',
@@ -84,7 +84,8 @@ export class ToolRegistry {
84
84
  const finalHandler = this.compactOutput
85
85
  ? async (...args) => compactResult(await guarded(...args))
86
86
  : guarded;
87
- return server.registerTool(name, { ...config, inputSchema: inputShape, annotations }, finalHandler);
87
+ const { cud: _cud, ...sdkConfig } = config;
88
+ return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, finalHandler);
88
89
  });
89
90
  }
90
91
  registerMeta = ((name, config, handler) => {
@@ -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,21 @@
1
+ import { z } from 'zod';
2
+ import { type ApiMethodRef, type ExecuteDeps } from '../../executor.js';
3
+ import type { Cud, ToolRegistry } from '../../registry.js';
4
+ export interface GeneratedParam {
5
+ field: string;
6
+ api: string;
7
+ location: 'path' | 'query';
8
+ }
9
+ export interface GeneratedToolDef {
10
+ name: string;
11
+ cud: Cud;
12
+ description: string;
13
+ method: ApiMethodRef;
14
+ params: GeneratedParam[];
15
+ hasBody: boolean;
16
+ shape: z.ZodRawShape;
17
+ }
18
+ export declare function accountField(): z.ZodEnum<{
19
+ [x: string]: string;
20
+ }>;
21
+ export declare function registerGeneratedTool(registry: ToolRegistry, def: GeneratedToolDef, deps?: ExecuteDeps): void;
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { ACCOUNTS } from '../../accounts.js';
3
+ import { executeApiMethod } from '../../executor.js';
4
+ export function accountField() {
5
+ return z.enum(ACCOUNTS).describe('Google account alias');
6
+ }
7
+ export function registerGeneratedTool(registry, def, deps = {}) {
8
+ // Widened locally: `cud` is a registry extension the SDK config type doesn't
9
+ // carry; the registry reads it before handing the config to the SDK.
10
+ const register = registry.registerTool;
11
+ register(def.name, {
12
+ description: def.description,
13
+ inputSchema: def.shape,
14
+ cud: def.cud,
15
+ annotations: { openWorldHint: true },
16
+ }, async (args) => {
17
+ const pathParams = {};
18
+ const queryParams = {};
19
+ for (const p of def.params) {
20
+ const value = args[p.field];
21
+ if (value === undefined)
22
+ continue;
23
+ if (p.location === 'path')
24
+ pathParams[p.api] = value;
25
+ else
26
+ queryParams[p.api] = value;
27
+ }
28
+ return executeApiMethod(def.method, {
29
+ account: args.account,
30
+ pathParams,
31
+ queryParams,
32
+ body: def.hasBody ? args.body : undefined,
33
+ }, deps);
34
+ });
35
+ }
@@ -0,0 +1,5 @@
1
+ import type { ToolRegistry } from '../../registry.js';
2
+ export declare const GENERATED_SERVICES: Array<{
3
+ name: string;
4
+ register: (registry: ToolRegistry) => void;
5
+ }>;
@@ -0,0 +1,4 @@
1
+ import { registerTasksGeneratedTools } from './tasks.js';
2
+ export const GENERATED_SERVICES = [
3
+ { name: "tasks", register: registerTasksGeneratedTools },
4
+ ];
@@ -0,0 +1,2 @@
1
+ import type { ToolRegistry } from '../../registry.js';
2
+ export declare function registerTasksGeneratedTools(registry: ToolRegistry): void;
@@ -0,0 +1,35 @@
1
+ // GENERATED by scripts/gen-tools.ts — do not edit. Regenerate: npm run gen:tools
2
+ import { z } from 'zod';
3
+ import { coerceJson } from '../_coerce.js';
4
+ import { accountField, registerGeneratedTool } from './_shared.js';
5
+ export function registerTasksGeneratedTools(registry) {
6
+ registerGeneratedTool(registry, {
7
+ name: "tasks_tasklists_update",
8
+ cud: "update",
9
+ description: "Updates the authenticated user's specified task list.",
10
+ method: { id: "tasks.tasklists.update", httpMethod: "PUT", path: "tasks/v1/users/@me/lists/{tasklist}", baseUrl: "https://tasks.googleapis.com/", requiredParams: ["tasklist"] },
11
+ params: [{ "field": "tasklist", "api": "tasklist", "location": "path" }, { "field": "fields", "api": "fields", "location": "query" }],
12
+ hasBody: true,
13
+ shape: {
14
+ account: accountField(),
15
+ tasklist: z.string().describe("Task list identifier."),
16
+ body: coerceJson(z.record(z.string(), z.unknown())).describe("TaskList JSON request body. Top-level fields: etag, id, kind, selfLink, title, updated."),
17
+ fields: z.string().optional().describe('Response field mask.'),
18
+ },
19
+ });
20
+ registerGeneratedTool(registry, {
21
+ name: "tasks_tasks_update",
22
+ cud: "update",
23
+ description: "Updates the specified task.",
24
+ method: { id: "tasks.tasks.update", httpMethod: "PUT", path: "tasks/v1/lists/{tasklist}/tasks/{task}", baseUrl: "https://tasks.googleapis.com/", requiredParams: ["tasklist", "task"] },
25
+ params: [{ "field": "task", "api": "task", "location": "path" }, { "field": "tasklist", "api": "tasklist", "location": "path" }, { "field": "fields", "api": "fields", "location": "query" }],
26
+ hasBody: true,
27
+ shape: {
28
+ account: accountField(),
29
+ task: z.string().describe("Task identifier."),
30
+ tasklist: z.string().describe("Task list identifier."),
31
+ body: coerceJson(z.record(z.string(), z.unknown())).describe("Task JSON request body. Top-level fields: assignmentInfo, completed, deleted, due, etag, hidden, id, kind, links, notes, parent, position, +5 more."),
32
+ fields: z.string().optional().describe('Response field mask.'),
33
+ },
34
+ });
35
+ }
@@ -2,12 +2,11 @@ import { z } from 'zod';
2
2
  import { isAllowed, writeDisabledResult } from '../write-control.js';
3
3
  import { ACCOUNTS } from '../accounts.js';
4
4
  import { getClient } from '../client.js';
5
- import { handleGoogleApiError } from './_errors.js';
6
5
  import { coerceJson } from './_coerce.js';
7
6
  import { getToolsets, toolsetEnabled } from '../toolsets.js';
8
- import { WORKSPACE_APIS, cudFromMethod, expandPath, isGoogleApiUrl, loadMethodIndex, searchMethods, } from '../discovery-client.js';
7
+ import { executeApiMethod, jsonResult } from '../executor.js';
8
+ import { WORKSPACE_APIS, cudFromMethod, loadMethodIndex, searchMethods, } from '../discovery-client.js';
9
9
  const accountEnum = z.enum(ACCOUNTS);
10
- const MAX_RESPONSE_CHARS = 100_000;
11
10
  // Policy/toolset namespace for each API alias must match the NAMED tools' service
12
11
  // names, or user deny globs and GOOGLE_TOOLSETS silently miss escape-hatch calls.
13
12
  const SERVICE_FOR_ALIAS = {
@@ -29,24 +28,6 @@ const SERVICE_FOR_ALIAS = {
29
28
  admin_reports: 'admin',
30
29
  groupssettings: 'groupssettings',
31
30
  };
32
- function buildQueryString(queryParams) {
33
- const usp = new URLSearchParams();
34
- const merged = { alt: 'json', ...queryParams };
35
- for (const [key, value] of Object.entries(merged)) {
36
- if (Array.isArray(value)) {
37
- for (const v of value)
38
- usp.append(key, String(v));
39
- }
40
- else {
41
- usp.append(key, String(value));
42
- }
43
- }
44
- return usp.toString();
45
- }
46
- function jsonResult(payload, isError = false) {
47
- const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
48
- return isError ? { ...base, isError: true } : base;
49
- }
50
31
  function describeMethod(m) {
51
32
  return {
52
33
  api: m.api,
@@ -134,14 +115,6 @@ export function registerEscapeTools(registry, policy, deps = {}) {
134
115
  }
135
116
  if (!apiEnabled(api))
136
117
  return toolsetDisabled(api);
137
- if (queryParams?.alt === 'media') {
138
- return jsonResult({
139
- error: 'binary_unsupported',
140
- message: 'Binary media download (alt=media) returns no usable JSON through the escape hatch.',
141
- hint: 'Use drive_download / drive_export for file content.',
142
- retriable: false,
143
- }, true);
144
- }
145
118
  let index;
146
119
  try {
147
120
  index = await loadMethodIndex(api, deps);
@@ -165,47 +138,11 @@ export function registerEscapeTools(registry, policy, deps = {}) {
165
138
  if (cud !== 'read' && !isAllowed(toolRef, policy)) {
166
139
  return writeDisabledResult(toolRef, policy);
167
140
  }
168
- let url;
169
- try {
170
- url = method.baseUrl + expandPath(method.path, pathParams ?? {});
171
- }
172
- catch (err) {
173
- return jsonResult({
174
- error: 'invalid_params',
175
- message: err.message,
176
- hint: `Required params for ${method.id}: ${method.requiredParams.join(', ') || '(none)'}`,
177
- retriable: false,
178
- }, true);
179
- }
180
- if (!isGoogleApiUrl(url)) {
181
- return jsonResult({
182
- error: 'untrusted_host',
183
- message: `Refusing to send credentials to a non-googleapis.com host for "${method.id}".`,
184
- hint: 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
185
- retriable: false,
186
- }, true);
187
- }
188
- try {
189
- const auth = await getClientFn(account);
190
- const res = await auth.request({
191
- // query string built by hand: gaxios comma-joins arrays, Google needs repeated keys
192
- url: `${url}?${buildQueryString(queryParams)}`,
193
- method: method.httpMethod,
194
- data: body ?? undefined,
195
- });
196
- const text = JSON.stringify(res.data ?? null);
197
- if (text.length > MAX_RESPONSE_CHARS) {
198
- return jsonResult({
199
- truncated: true,
200
- totalChars: text.length,
201
- head: text.slice(0, MAX_RESPONSE_CHARS),
202
- hint: 'Narrow the request (fields mask, pageSize) to get complete JSON.',
203
- });
204
- }
205
- return { content: [{ type: 'text', text }] };
206
- }
207
- catch (error) {
208
- return handleGoogleApiError(error, account);
209
- }
141
+ return executeApiMethod(method, {
142
+ account: account,
143
+ pathParams: pathParams,
144
+ queryParams: queryParams,
145
+ body,
146
+ }, { getClientFn });
210
147
  });
211
148
  }
@@ -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-alpha.1",
3
+ "version": "5.2.0-alpha.2",
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,11 @@
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",
59
+ "gen:tools": "tsx scripts/gen-tools.ts"
58
60
  },
59
61
  "dependencies": {
60
62
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -72,6 +74,7 @@
72
74
  "@types/server-destroy": "^1.0.3",
73
75
  "eslint": "^10.2.1",
74
76
  "semantic-release": "^25.0.3",
77
+ "tsx": "^4.23.1",
75
78
  "typescript": "^6.0.3",
76
79
  "typescript-eslint": "^8.59.1",
77
80
  "vitest": "^3.2.6"