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

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
 
@@ -87,6 +88,14 @@ Two eager tools cover everything the curated set doesn't: `google_api_search` fi
87
88
 
88
89
  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
90
 
91
+ ## Lean responses by default
92
+
93
+ 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`:
94
+
95
+ - `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.)
96
+ - `gmail_read` / `gmail_read_thread` cap each message body at 50k chars (`bodyTruncated` + `bodyTotalChars` flags); pass `full: true` for the whole body.
97
+ - `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.
98
+
90
99
  ## Write-control (deny-by-default)
91
100
 
92
101
  Reads are never gated. **Every create/update/delete is off until you opt in** — pick a profile:
@@ -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,6 +1,7 @@
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';
4
5
  const CUD_OVERRIDES = {
5
6
  drive_untrash: 'update',
6
7
  };
@@ -28,6 +29,7 @@ export class ToolRegistry {
28
29
  registerTool;
29
30
  revealed = new Set();
30
31
  jsonSchemaCache = new Map();
32
+ compactOutput = trimEnabled();
31
33
  registeringMeta = false;
32
34
  constructor(server, policy) {
33
35
  this.server = server;
@@ -54,7 +56,10 @@ export class ToolRegistry {
54
56
  : (...args) => isAllowed({ name, service, cud }, policy)
55
57
  ? handler(...args)
56
58
  : writeDisabledResult({ name, service, cud }, policy);
57
- return server.registerTool(name, { ...config, annotations }, guarded);
59
+ const finalHandler = this.compactOutput
60
+ ? async (...args) => compactResult(await guarded(...args))
61
+ : guarded;
62
+ return server.registerTool(name, { ...config, annotations }, finalHandler);
58
63
  });
59
64
  }
60
65
  registerMeta = ((name, config, handler) => {
@@ -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.3",
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",