mcp-google-multi 5.1.0-alpha.2 → 5.1.0-alpha.4

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
@@ -72,6 +72,7 @@ Now the only thing on disk is the **encrypted** token store. Pass the token via
72
72
  | `GOOGLE_TOOLSETS` | — | `all` (default) or a CSV filter of: `gmail`, `drive`, `calendar`, `sheets`, `docs`, `contacts`, `searchconsole`, `tasks`, `meet`, `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
+ | `GOOGLE_TRIM` | — | `off` (or `0`/`false`/`no`) disables compact JSON serialization of tool responses |
75
76
 
76
77
  Inspect the resolved setup any time: `mcp-google-multi config check`.
77
78
 
@@ -81,12 +82,34 @@ The server does **not** dump ~170 tool schemas into your model's context. At boo
81
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
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
+
84
99
  ## Escape hatch: any Workspace REST method
85
100
 
86
101
  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).
87
102
 
88
103
  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.
89
104
 
105
+ ## Lean responses by default
106
+
107
+ 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`:
108
+
109
+ - `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.)
110
+ - `gmail_read` / `gmail_read_thread` cap each message body at 50k chars (`bodyTruncated` + `bodyTotalChars` flags); pass `full: true` for the whole body.
111
+ - `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.
112
+
90
113
  ## Write-control (deny-by-default)
91
114
 
92
115
  Reads are never gated. **Every create/update/delete is off until you opt in** — pick a profile:
package/dist/accounts.js CHANGED
@@ -40,6 +40,9 @@ function parseAccounts() {
40
40
  if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
41
41
  throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
42
42
  }
43
+ if (aliases.includes(alias)) {
44
+ throw new Error(`Duplicate alias "${alias}" in GOOGLE_ACCOUNTS. Each alias must be unique.`);
45
+ }
43
46
  aliases.push(alias);
44
47
  configs[alias] = {
45
48
  email,
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ export declare const CSV_RE: RegExp;
3
+ export declare function fanoutAccountField(description: string): z.ZodType;
4
+ export type AccountSelector = {
5
+ ok: true;
6
+ fanout: boolean;
7
+ aliases: string[];
8
+ } | {
9
+ ok: false;
10
+ invalid: string[];
11
+ };
12
+ export declare function parseAccountSelector(value: string, accounts?: readonly string[]): AccountSelector;
13
+ export declare function invalidAccountsResult(invalid: string[], accounts?: readonly string[]): {
14
+ content: {
15
+ type: "text";
16
+ text: string;
17
+ }[];
18
+ isError: true;
19
+ };
20
+ export interface FanoutEntry {
21
+ account: string;
22
+ ok: boolean;
23
+ data?: unknown;
24
+ error?: unknown;
25
+ }
26
+ export declare function runFanout(handler: (...a: unknown[]) => unknown, args: unknown[], aliases: string[]): Promise<{
27
+ content: {
28
+ type: 'text';
29
+ text: string;
30
+ }[];
31
+ isError?: true;
32
+ }>;
package/dist/fanout.js ADDED
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+ import { ACCOUNTS } from './accounts.js';
3
+ export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
4
+ const FANOUT_CONCURRENCY = 5;
5
+ export function fanoutAccountField(description) {
6
+ const csvExample = ACCOUNTS.length > 1 ? `; or a CSV subset like "${ACCOUNTS.slice(0, 2).join(',')}"` : '';
7
+ return z
8
+ .union([z.enum([...ACCOUNTS, '*']), z.string().regex(CSV_RE)])
9
+ .describe(`${description}; "*" = all accounts${csvExample}`);
10
+ }
11
+ export function parseAccountSelector(value, accounts = ACCOUNTS) {
12
+ if (value === '*')
13
+ return { ok: true, fanout: true, aliases: [...accounts] };
14
+ if (!value.includes(','))
15
+ return { ok: true, fanout: false, aliases: [value] };
16
+ const seen = new Set();
17
+ const aliases = [];
18
+ const invalid = [];
19
+ for (const token of value.split(',').map((t) => t.trim()).filter(Boolean)) {
20
+ if (!accounts.includes(token)) {
21
+ invalid.push(token);
22
+ }
23
+ else if (!seen.has(token)) {
24
+ seen.add(token);
25
+ aliases.push(token);
26
+ }
27
+ }
28
+ if (invalid.length > 0)
29
+ return { ok: false, invalid };
30
+ return { ok: true, fanout: aliases.length > 1, aliases };
31
+ }
32
+ export function invalidAccountsResult(invalid, accounts = ACCOUNTS) {
33
+ return {
34
+ content: [
35
+ {
36
+ type: 'text',
37
+ text: JSON.stringify({
38
+ error: 'validation_error',
39
+ message: `Unknown account alias(es): ${invalid.join(', ')}.`,
40
+ hint: `Valid aliases: ${accounts.join(', ')}; or "*" for all accounts.`,
41
+ retriable: false,
42
+ }),
43
+ },
44
+ ],
45
+ isError: true,
46
+ };
47
+ }
48
+ function parsePayload(result) {
49
+ const text = result?.content?.[0]?.text;
50
+ if (typeof text !== 'string')
51
+ return null;
52
+ try {
53
+ return JSON.parse(text);
54
+ }
55
+ catch {
56
+ return text;
57
+ }
58
+ }
59
+ export async function runFanout(handler, args, aliases) {
60
+ const results = new Array(aliases.length);
61
+ let next = 0;
62
+ const worker = async () => {
63
+ while (next < aliases.length) {
64
+ const i = next++;
65
+ const account = aliases[i];
66
+ try {
67
+ const res = (await handler({ ...args[0], account }, ...args.slice(1)));
68
+ const payload = parsePayload(res);
69
+ results[i] = res?.isError ? { account, ok: false, error: payload } : { account, ok: true, data: payload };
70
+ }
71
+ catch (err) {
72
+ results[i] = {
73
+ account,
74
+ ok: false,
75
+ error: { error: 'internal', message: err.message, retriable: false, account },
76
+ };
77
+ }
78
+ }
79
+ };
80
+ await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
81
+ const failed = results.filter((r) => !r.ok).length;
82
+ const body = { results, partial: failed > 0 };
83
+ const out = { content: [{ type: 'text', text: JSON.stringify(body) }] };
84
+ return failed === results.length ? { ...out, isError: true } : out;
85
+ }
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { getOptionalBundles, getAdminAccounts } from './auth.js';
21
21
  import { ToolRegistry } from './registry.js';
22
22
  import { registerDiscoverTools } from './discover.js';
23
23
  import { registerEscapeTools } from './tools/google-api.js';
24
+ import { registerAccountTools } from './tools/accounts-tool.js';
24
25
  import { getToolsets, toolsetEnabled } from './toolsets.js';
25
26
  import { resolvePolicy, isAllowed, describePolicy } from './write-control.js';
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -69,6 +70,7 @@ function buildRegistry(server, policy) {
69
70
  }
70
71
  registerDiscoverTools(registry, policy);
71
72
  registerEscapeTools(registry, policy);
73
+ registerAccountTools(registry);
72
74
  return registry;
73
75
  }
74
76
  async function main() {
@@ -24,6 +24,7 @@ export declare class ToolRegistry {
24
24
  readonly registerTool: McpServer['registerTool'];
25
25
  private readonly revealed;
26
26
  private readonly jsonSchemaCache;
27
+ private readonly compactOutput;
27
28
  private registeringMeta;
28
29
  constructor(server: McpServer, policy: Policy);
29
30
  registerMeta: McpServer['registerTool'];
package/dist/registry.js CHANGED
@@ -1,12 +1,20 @@
1
1
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { z } from 'zod';
3
3
  import { isAllowed, writeDisabledResult } from './write-control.js';
4
+ import { compactResult, trimEnabled } from './trim.js';
5
+ import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
4
6
  const CUD_OVERRIDES = {
5
7
  drive_untrash: 'update',
6
8
  };
7
9
  const SERVICE_OVERRIDES = {
8
10
  reports_activities_list: 'admin',
9
11
  };
12
+ // Read tools that write to the local filesystem: fanning the same savePath
13
+ // across accounts would clobber (last write wins).
14
+ const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
15
+ function isAccountEnum(field) {
16
+ return field?._zod?.def?.type === 'enum';
17
+ }
10
18
  const DELETE_VERB = /(^|_)(delete|remove|trash|clear|empty)(_|$)/;
11
19
  const CREATE_VERB = /(^|_)(create|add|insert|send|upload|copy|import|append|submit|duplicate|share|quick)(_|$)/;
12
20
  const UPDATE_VERB = /(^|_)(update|patch|modify|set|move|write|format|merge|unmerge|sort|replace|resize|publish|resolve)(_|$)/;
@@ -28,6 +36,7 @@ export class ToolRegistry {
28
36
  registerTool;
29
37
  revealed = new Set();
30
38
  jsonSchemaCache = new Map();
39
+ compactOutput = trimEnabled();
31
40
  registeringMeta = false;
32
41
  constructor(server, policy) {
33
42
  this.server = server;
@@ -40,21 +49,41 @@ export class ToolRegistry {
40
49
  destructiveHint: cud === 'delete' || cud === 'update',
41
50
  ...config.annotations,
42
51
  };
52
+ // Fan-out is read-only and never applies to meta tools: google_api_call's
53
+ // registry cud is "read" but it executes arbitrary writes.
54
+ let inputShape = config.inputSchema ?? {};
55
+ let baseHandler = handler;
56
+ if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
57
+ const description = inputShape.account.description ?? 'Google account alias';
58
+ inputShape = { ...inputShape, account: fanoutAccountField(description) };
59
+ baseHandler = async (...args) => {
60
+ const first = args[0];
61
+ const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '');
62
+ if (!parsed.ok)
63
+ return invalidAccountsResult(parsed.invalid);
64
+ if (!parsed.fanout)
65
+ return handler({ ...first, account: parsed.aliases[0] }, ...args.slice(1));
66
+ return runFanout(handler, args, parsed.aliases);
67
+ };
68
+ }
43
69
  this.tools.push({
44
70
  name,
45
71
  service,
46
72
  cud,
47
73
  description: config.description ?? '',
48
- inputShape: config.inputSchema ?? {},
74
+ inputShape,
49
75
  annotations,
50
76
  meta: this.registeringMeta,
51
77
  });
52
78
  const guarded = cud === 'read'
53
- ? handler
79
+ ? baseHandler
54
80
  : (...args) => isAllowed({ name, service, cud }, policy)
55
- ? handler(...args)
81
+ ? baseHandler(...args)
56
82
  : writeDisabledResult({ name, service, cud }, policy);
57
- return server.registerTool(name, { ...config, annotations }, guarded);
83
+ const finalHandler = this.compactOutput
84
+ ? async (...args) => compactResult(await guarded(...args))
85
+ : guarded;
86
+ return server.registerTool(name, { ...config, inputSchema: inputShape, annotations }, finalHandler);
58
87
  });
59
88
  }
60
89
  registerMeta = ((name, config, handler) => {
@@ -0,0 +1,29 @@
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export interface AccountHealthDeps {
3
+ hasToken: (alias: string) => boolean;
4
+ readToken: (alias: string) => {
5
+ expiry_date?: number;
6
+ refresh_token?: string;
7
+ scope?: string;
8
+ } | null;
9
+ fileExists: (p: string) => boolean;
10
+ now: () => number;
11
+ }
12
+ export type TokenStatus = 'ok' | 'expired_refreshable' | 'needs_reauth' | 'missing' | 'decrypt_error';
13
+ export interface AccountHealth {
14
+ alias: string;
15
+ email: string;
16
+ admin: boolean;
17
+ token: {
18
+ status: TokenStatus;
19
+ expiryDate?: string;
20
+ hint?: string;
21
+ };
22
+ scopes: {
23
+ configured: number;
24
+ granted: number;
25
+ missing: string[];
26
+ };
27
+ }
28
+ export declare function deriveAccountHealth(alias: string, deps?: AccountHealthDeps): AccountHealth;
29
+ export declare function registerAccountTools(registry: ToolRegistry, deps?: AccountHealthDeps): void;
@@ -0,0 +1,80 @@
1
+ import * as fs from 'node:fs';
2
+ import { ACCOUNTS, ACCOUNT_CONFIG } from '../accounts.js';
3
+ import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
4
+ import { hasToken, readToken } from '../token-store.js';
5
+ const DEFAULT_DEPS = {
6
+ hasToken,
7
+ readToken,
8
+ fileExists: fs.existsSync,
9
+ now: Date.now,
10
+ };
11
+ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
12
+ const config = ACCOUNT_CONFIG[alias];
13
+ const admin = getAdminAccounts().includes(alias);
14
+ const configured = resolveScopesForAccount(alias);
15
+ const base = { alias, email: config.email, admin };
16
+ const noScopes = { configured: configured.length, granted: 0, missing: configured };
17
+ if (!deps.hasToken(alias)) {
18
+ const legacy = deps.fileExists(config.tokenPath);
19
+ return {
20
+ ...base,
21
+ token: {
22
+ status: 'missing',
23
+ hint: legacy
24
+ ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
25
+ : `Run: npx mcp-google-multi auth --account ${alias}`,
26
+ },
27
+ scopes: noScopes,
28
+ };
29
+ }
30
+ let token;
31
+ try {
32
+ token = deps.readToken(alias);
33
+ }
34
+ catch {
35
+ return {
36
+ ...base,
37
+ token: { status: 'decrypt_error', hint: 'Wrong MASTER_KEY or corrupt token file — re-auth or restore the key.' },
38
+ scopes: noScopes,
39
+ };
40
+ }
41
+ const granted = typeof token?.scope === 'string' ? token.scope.split(' ').filter(Boolean) : [];
42
+ const missing = configured.filter((s) => !granted.includes(s));
43
+ const expiry = typeof token?.expiry_date === 'number' ? token.expiry_date : undefined;
44
+ const refreshable = typeof token?.refresh_token === 'string' && token.refresh_token.length > 0;
45
+ let status;
46
+ if (expiry !== undefined && expiry > deps.now())
47
+ status = 'ok';
48
+ else if (refreshable)
49
+ status = 'expired_refreshable';
50
+ else
51
+ status = 'needs_reauth';
52
+ return {
53
+ ...base,
54
+ token: {
55
+ status,
56
+ expiryDate: expiry !== undefined ? new Date(expiry).toISOString() : undefined,
57
+ hint: status === 'needs_reauth'
58
+ ? `Run: npx mcp-google-multi auth --account ${alias}`
59
+ : missing.length > 0
60
+ ? 'Re-auth to grant the missing scopes'
61
+ : undefined,
62
+ },
63
+ scopes: { configured: configured.length, granted: granted.length, missing },
64
+ };
65
+ }
66
+ export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
67
+ registry.registerMeta('account_list', {
68
+ description: 'List the configured Google accounts: alias, email, admin flag, token health ' +
69
+ '(ok / expired_refreshable / needs_reauth / missing / decrypt_error), and granted vs configured scopes. ' +
70
+ 'Use this to see which account aliases are available and healthy.',
71
+ inputSchema: {},
72
+ }, async () => ({
73
+ content: [
74
+ {
75
+ type: 'text',
76
+ text: JSON.stringify({ accounts: ACCOUNTS.map((alias) => deriveAccountHealth(alias, deps)) }),
77
+ },
78
+ ],
79
+ }));
80
+ }
@@ -1,2 +1,22 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
2
  export declare function registerCalendarTools(server: ToolRegistry): void;
3
+ export declare function formatEvent(event: any, opts?: {
4
+ full?: boolean;
5
+ }): {
6
+ id: any;
7
+ summary: any;
8
+ description: string;
9
+ location: any;
10
+ start: any;
11
+ end: any;
12
+ status: any;
13
+ htmlLink: any;
14
+ organizer: any;
15
+ attendees: any;
16
+ recurringEventId: any;
17
+ hangoutLink: any;
18
+ created: any;
19
+ updated: any;
20
+ } | {
21
+ [k: string]: any;
22
+ };
@@ -4,6 +4,7 @@ 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 { sliceClean } from '../trim.js';
7
8
  const accountEnum = z.enum(ACCOUNTS);
8
9
  export function registerCalendarTools(server) {
9
10
  server.registerTool('calendar_list_calendars', {
@@ -33,7 +34,7 @@ export function registerCalendarTools(server) {
33
34
  }
34
35
  });
35
36
  server.registerTool('calendar_list_events', {
36
- description: 'List events from a Google Calendar',
37
+ description: 'List events from a Google Calendar (trimmed view: descriptions capped, created/updated and empty fields omitted — use calendar_get_event for a full event)',
37
38
  inputSchema: {
38
39
  account: accountEnum.describe('Google account alias'),
39
40
  calendarId: z.string().default('primary').optional()
@@ -66,7 +67,7 @@ export function registerCalendarTools(server) {
66
67
  params.timeMin = new Date().toISOString();
67
68
  }
68
69
  const res = await cal.events.list(params);
69
- const events = (res.data.items ?? []).map(formatEvent);
70
+ const events = (res.data.items ?? []).map((e) => formatEvent(e, { full: false }));
70
71
  return {
71
72
  content: [{ type: 'text', text: JSON.stringify(events, null, 2) }],
72
73
  };
@@ -288,7 +289,7 @@ export function registerCalendarTools(server) {
288
289
  }
289
290
  });
290
291
  server.registerTool('calendar_list_instances', {
291
- description: 'List all occurrences of a recurring calendar event',
292
+ description: 'List all occurrences of a recurring calendar event (trimmed view like calendar_list_events — use calendar_get_event for a full event)',
292
293
  inputSchema: {
293
294
  account: accountEnum.describe('Google account alias'),
294
295
  calendarId: z.string().default('primary').optional()
@@ -310,7 +311,7 @@ export function registerCalendarTools(server) {
310
311
  timeMax,
311
312
  maxResults: maxResults ?? 25,
312
313
  });
313
- const events = (res.data.items ?? []).map(formatEvent);
314
+ const events = (res.data.items ?? []).map((e) => formatEvent(e, { full: false }));
314
315
  return {
315
316
  content: [{ type: 'text', text: JSON.stringify(events, null, 2) }],
316
317
  };
@@ -376,11 +377,19 @@ export function registerCalendarTools(server) {
376
377
  }
377
378
  });
378
379
  }
379
- function formatEvent(event) {
380
- return {
380
+ const LIST_DESCRIPTION_CAP = 300;
381
+ const TRUNCATION_MARKER = '… [truncated, use calendar_get_event]';
382
+ export function formatEvent(event, opts = {}) {
383
+ const full = opts.full ?? true;
384
+ const description = event.description ?? '';
385
+ // Slack margin: truncating a 301-char description to 300 + 37-char marker would GROW it.
386
+ const cappedDescription = full || description.length <= LIST_DESCRIPTION_CAP + TRUNCATION_MARKER.length
387
+ ? description
388
+ : `${sliceClean(description, LIST_DESCRIPTION_CAP)}${TRUNCATION_MARKER}`;
389
+ const base = {
381
390
  id: event.id,
382
391
  summary: event.summary ?? '',
383
- description: event.description ?? '',
392
+ description: cappedDescription,
384
393
  location: event.location ?? '',
385
394
  start: event.start?.dateTime ?? event.start?.date ?? '',
386
395
  end: event.end?.dateTime ?? event.end?.date ?? '',
@@ -391,9 +400,14 @@ function formatEvent(event) {
391
400
  email: a.email,
392
401
  responseStatus: a.responseStatus,
393
402
  })),
394
- created: event.created,
395
- updated: event.updated,
403
+ recurringEventId: event.recurringEventId,
404
+ hangoutLink: event.hangoutLink,
405
+ created: full ? event.created : undefined,
406
+ updated: full ? event.updated : undefined,
396
407
  };
408
+ if (full)
409
+ return base;
410
+ return Object.fromEntries(Object.entries(base).filter(([, v]) => v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0)));
397
411
  }
398
412
  function handleCalendarError(error, account) {
399
413
  return handleGoogleApiError(error, account);
@@ -4,6 +4,7 @@ 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
  import * as fs from 'fs';
8
9
  import * as path from 'path';
9
10
  import { pipeline } from 'node:stream/promises';
@@ -59,12 +60,16 @@ export function registerDriveTools(server) {
59
60
  }
60
61
  });
61
62
  server.registerTool('drive_read', {
62
- description: 'Read the content of a Google Drive file',
63
+ description: 'Read the content of a Google Drive file (returns up to maxChars characters per call; non-Google-native files over 2MB return too_large)',
63
64
  inputSchema: {
64
65
  account: accountEnum.describe('Google account alias'),
65
66
  fileId: z.string().describe('Google Drive file ID'),
67
+ maxChars: z.number().min(1).max(2_000_000).default(100_000).optional()
68
+ .describe('Max characters of content to return (default: 100000)'),
69
+ offset: z.number().min(0).default(0).optional()
70
+ .describe('Character offset to continue a truncated read (Google Docs/Sheets/Slides exports page fine; plain files >2MB are rejected, not paged)'),
66
71
  },
67
- }, async ({ account, fileId }) => {
72
+ }, async ({ account, fileId, maxChars, offset }) => {
68
73
  try {
69
74
  const auth = await getClient(account);
70
75
  const drive = google.drive({ version: 'v3', auth });
@@ -74,11 +79,9 @@ export function registerDriveTools(server) {
74
79
  supportsAllDrives: true,
75
80
  });
76
81
  const { name, mimeType, size, webViewLink } = meta.data;
77
- if (mimeType && GOOGLE_WORKSPACE_TYPES.has(mimeType)) {
78
- const exported = await drive.files.export({
79
- fileId,
80
- mimeType: 'text/plain',
81
- });
82
+ const respond = (raw) => {
83
+ const from = offset ?? 0;
84
+ const capped = capText(raw, maxChars ?? 100_000, from);
82
85
  return {
83
86
  content: [{
84
87
  type: 'text',
@@ -86,10 +89,20 @@ export function registerDriveTools(server) {
86
89
  id: fileId,
87
90
  name,
88
91
  mimeType,
89
- content: String(exported.data),
92
+ content: capped.text,
93
+ ...(capped.truncated || from > 0
94
+ ? { truncated: capped.truncated, totalChars: capped.totalChars, offset: from }
95
+ : {}),
90
96
  }, null, 2),
91
97
  }],
92
98
  };
99
+ };
100
+ if (mimeType && GOOGLE_WORKSPACE_TYPES.has(mimeType)) {
101
+ const exported = await drive.files.export({
102
+ fileId,
103
+ mimeType: 'text/plain',
104
+ });
105
+ return respond(String(exported.data));
93
106
  }
94
107
  if (mimeType === 'application/pdf') {
95
108
  return {
@@ -122,17 +135,7 @@ export function registerDriveTools(server) {
122
135
  }
123
136
  if (mimeType?.startsWith('text/')) {
124
137
  const downloaded = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'text' });
125
- return {
126
- content: [{
127
- type: 'text',
128
- text: JSON.stringify({
129
- id: fileId,
130
- name,
131
- mimeType,
132
- content: String(downloaded.data),
133
- }, null, 2),
134
- }],
135
- };
138
+ return respond(String(downloaded.data));
136
139
  }
137
140
  return {
138
141
  content: [{
@@ -1,2 +1,4 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import type { GmailMessageFull } from '../types.js';
3
+ export declare function parseMessage(msg: any, bodyCap?: number): GmailMessageFull;
2
4
  export declare function registerGmailTools(server: ToolRegistry): void;
@@ -5,6 +5,7 @@ import { ACCOUNTS } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError } from './_errors.js';
7
7
  import { buildMultipartAlternative, encodeAddressHeader, encodeHeaderValue, normalizeBodyLineEndings } from './gmail-mime.js';
8
+ import { sliceClean } from '../trim.js';
8
9
  import * as path from 'path';
9
10
  import * as fs from 'fs';
10
11
  const accountEnum = z.enum(ACCOUNTS);
@@ -52,8 +53,11 @@ function getAttachments(payload) {
52
53
  }
53
54
  return attachments;
54
55
  }
55
- function parseMessage(msg) {
56
+ const BODY_CAP_CHARS = 50_000;
57
+ export function parseMessage(msg, bodyCap) {
56
58
  const headers = msg.payload?.headers ?? [];
59
+ const body = decodeBody(msg.payload);
60
+ const capped = bodyCap !== undefined && body.length > bodyCap;
57
61
  return {
58
62
  id: msg.id ?? '',
59
63
  threadId: msg.threadId ?? '',
@@ -62,7 +66,8 @@ function parseMessage(msg) {
62
66
  to: getHeader(headers, 'To'),
63
67
  cc: getHeader(headers, 'Cc'),
64
68
  date: getHeader(headers, 'Date'),
65
- body: decodeBody(msg.payload),
69
+ body: capped ? sliceClean(body, bodyCap) : body,
70
+ ...(capped ? { bodyTruncated: true, bodyTotalChars: body.length } : {}),
66
71
  attachments: getAttachments(msg.payload),
67
72
  };
68
73
  }
@@ -111,12 +116,13 @@ export function registerGmailTools(server) {
111
116
  }
112
117
  });
113
118
  server.registerTool('gmail_read', {
114
- description: 'Read a full Gmail message by ID',
119
+ description: 'Read a full Gmail message by ID (body capped at 50k chars unless full=true)',
115
120
  inputSchema: {
116
121
  account: accountEnum.describe('Google account alias'),
117
122
  messageId: z.string().describe('Gmail message ID'),
123
+ full: coerceBoolean.optional().describe('Return the entire body without the character cap'),
118
124
  },
119
- }, async ({ account, messageId }) => {
125
+ }, async ({ account, messageId, full }) => {
120
126
  try {
121
127
  const auth = await getClient(account);
122
128
  const gmail = google.gmail({ version: 'v1', auth });
@@ -125,7 +131,7 @@ export function registerGmailTools(server) {
125
131
  id: messageId,
126
132
  format: 'full',
127
133
  });
128
- const result = parseMessage(res.data);
134
+ const result = parseMessage(res.data, full ? undefined : BODY_CAP_CHARS);
129
135
  return {
130
136
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
131
137
  };
@@ -135,12 +141,13 @@ export function registerGmailTools(server) {
135
141
  }
136
142
  });
137
143
  server.registerTool('gmail_read_thread', {
138
- description: 'Read all messages in a Gmail thread',
144
+ description: 'Read all messages in a Gmail thread (bodies capped at 50k chars each unless full=true)',
139
145
  inputSchema: {
140
146
  account: accountEnum.describe('Google account alias'),
141
147
  threadId: z.string().describe('Gmail thread ID'),
148
+ full: coerceBoolean.optional().describe('Return entire bodies without the character cap'),
142
149
  },
143
- }, async ({ account, threadId }) => {
150
+ }, async ({ account, threadId, full }) => {
144
151
  try {
145
152
  const auth = await getClient(account);
146
153
  const gmail = google.gmail({ version: 'v1', auth });
@@ -149,7 +156,7 @@ export function registerGmailTools(server) {
149
156
  id: threadId,
150
157
  format: 'full',
151
158
  });
152
- const messages = (res.data.messages ?? []).map(parseMessage);
159
+ const messages = (res.data.messages ?? []).map((m) => parseMessage(m, full ? undefined : BODY_CAP_CHARS));
153
160
  return {
154
161
  content: [{ type: 'text', text: JSON.stringify(messages, null, 2) }],
155
162
  };
package/dist/trim.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export declare function trimEnabled(env?: NodeJS.ProcessEnv): boolean;
2
+ export interface CappedText {
3
+ text: string;
4
+ truncated: boolean;
5
+ totalChars: number;
6
+ }
7
+ export declare function capText(text: string, maxChars: number, offset?: number): CappedText;
8
+ export declare function sliceClean(text: string, max: number): string;
9
+ interface ToolResult {
10
+ content?: {
11
+ type?: string;
12
+ text?: string;
13
+ }[];
14
+ }
15
+ export declare function compactResult<T extends ToolResult>(result: T): T;
16
+ export {};
package/dist/trim.js ADDED
@@ -0,0 +1,29 @@
1
+ export function trimEnabled(env = process.env) {
2
+ return !/^(0|false|off|no)$/i.test((env.GOOGLE_TRIM ?? '').trim());
3
+ }
4
+ export function capText(text, maxChars, offset = 0) {
5
+ const slice = text.slice(offset, offset + maxChars);
6
+ return { text: slice, truncated: offset + slice.length < text.length, totalChars: text.length };
7
+ }
8
+ // For permanent (non-paging) caps: dropping a trailing lone high surrogate keeps
9
+ // the truncated string well-formed Unicode instead of ending in mojibake.
10
+ export function sliceClean(text, max) {
11
+ const sliced = text.slice(0, max);
12
+ return /[\uD800-\uDBFF]$/.test(sliced) ? sliced.slice(0, -1) : sliced;
13
+ }
14
+ // Pretty-printed JSON costs ~20-30% extra tokens; re-serialize compactly.
15
+ export function compactResult(result) {
16
+ if (!result || !Array.isArray(result.content))
17
+ return result;
18
+ for (const item of result.content) {
19
+ if (item?.type === 'text' && typeof item.text === 'string' && /^[\s]*[[{]/.test(item.text)) {
20
+ try {
21
+ item.text = JSON.stringify(JSON.parse(item.text));
22
+ }
23
+ catch {
24
+ // not JSON — leave untouched
25
+ }
26
+ }
27
+ }
28
+ return result;
29
+ }
package/dist/types.d.ts CHANGED
@@ -27,6 +27,8 @@ export interface GmailMessageFull {
27
27
  cc: string;
28
28
  date: string;
29
29
  body: string;
30
+ bodyTruncated?: boolean;
31
+ bodyTotalChars?: number;
30
32
  attachments: GmailAttachment[];
31
33
  }
32
34
  export interface DriveFile {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "5.1.0-alpha.2",
3
+ "version": "5.1.0-alpha.4",
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",