mcp-google-multi 5.1.0-alpha.1 → 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
@@ -71,6 +71,8 @@ Now the only thing on disk is the **encrypted** token store. Pass the token via
71
71
  | `GOOGLE_ADMIN_ACCOUNTS` | — | aliases granted Workspace-admin scopes (the account's own super-admin OAuth) |
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
+ | `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 |
74
76
 
75
77
  Inspect the resolved setup any time: `mcp-google-multi config check`.
76
78
 
@@ -80,6 +82,20 @@ The server does **not** dump ~170 tool schemas into your model's context. At boo
80
82
 
81
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.
82
84
 
85
+ ## Escape hatch: any Workspace REST method
86
+
87
+ 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).
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.
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
+
83
99
  ## Write-control (deny-by-default)
84
100
 
85
101
  Reads are never gated. **Every create/update/delete is off until you opt in** — pick a profile:
@@ -0,0 +1,46 @@
1
+ export declare const WORKSPACE_APIS: Record<string, {
2
+ id: string;
3
+ version: string;
4
+ }>;
5
+ export interface DiscoveryParam {
6
+ location: 'path' | 'query';
7
+ required?: boolean;
8
+ type?: string;
9
+ description?: string;
10
+ }
11
+ export interface DiscoveryMethod {
12
+ id: string;
13
+ api: string;
14
+ httpMethod: string;
15
+ path: string;
16
+ baseUrl: string;
17
+ description: string;
18
+ params: Record<string, DiscoveryParam>;
19
+ requiredParams: string[];
20
+ scopes: string[];
21
+ }
22
+ interface DiscoveryDoc {
23
+ baseUrl?: string;
24
+ rootUrl?: string;
25
+ servicePath?: string;
26
+ resources?: Record<string, unknown>;
27
+ methods?: Record<string, unknown>;
28
+ }
29
+ export interface DiscoveryDeps {
30
+ fetchFn?: (url: string) => Promise<{
31
+ ok: boolean;
32
+ status: number;
33
+ json(): Promise<unknown>;
34
+ }>;
35
+ cacheDir?: string;
36
+ now?: () => number;
37
+ }
38
+ export declare function discoveryCacheDir(env?: NodeJS.ProcessEnv): string;
39
+ export declare function isGoogleApiUrl(url: string): boolean;
40
+ export declare function buildMethodIndex(doc: DiscoveryDoc, api: string): DiscoveryMethod[];
41
+ export declare function loadMethodIndex(api: string, deps?: DiscoveryDeps): Promise<DiscoveryMethod[]>;
42
+ export declare function clearDiscoveryMemoryCache(): void;
43
+ export declare function cudFromMethod(method: Pick<DiscoveryMethod, 'httpMethod' | 'id'>): 'read' | 'create' | 'update' | 'delete';
44
+ export declare function expandPath(template: string, pathParams: Record<string, string>): string;
45
+ export declare function searchMethods(index: DiscoveryMethod[], query: string, limit?: number): DiscoveryMethod[];
46
+ export {};
@@ -0,0 +1,185 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ export const WORKSPACE_APIS = {
5
+ gmail: { id: 'gmail', version: 'v1' },
6
+ drive: { id: 'drive', version: 'v3' },
7
+ calendar: { id: 'calendar', version: 'v3' },
8
+ sheets: { id: 'sheets', version: 'v4' },
9
+ docs: { id: 'docs', version: 'v1' },
10
+ slides: { id: 'slides', version: 'v1' },
11
+ forms: { id: 'forms', version: 'v1' },
12
+ people: { id: 'people', version: 'v1' },
13
+ searchconsole: { id: 'searchconsole', version: 'v1' },
14
+ tasks: { id: 'tasks', version: 'v1' },
15
+ chat: { id: 'chat', version: 'v1' },
16
+ meet: { id: 'meet', version: 'v2' },
17
+ driveactivity: { id: 'driveactivity', version: 'v2' },
18
+ drivelabels: { id: 'drivelabels', version: 'v2' },
19
+ admin_directory: { id: 'admin', version: 'directory_v1' },
20
+ admin_reports: { id: 'admin', version: 'reports_v1' },
21
+ groupssettings: { id: 'groupssettings', version: 'v1' },
22
+ };
23
+ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
24
+ const STALE_RETRY_MS = 5 * 60 * 1000;
25
+ const FETCH_TIMEOUT_MS = 10_000;
26
+ export function discoveryCacheDir(env = process.env) {
27
+ return (env.DISCOVERY_CACHE_PATH ??
28
+ path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
29
+ }
30
+ // A poisoned cache file could repoint baseUrl off-Google; refuse to send the
31
+ // user's Bearer token anywhere but a googleapis.com host.
32
+ export function isGoogleApiUrl(url) {
33
+ try {
34
+ const u = new URL(url);
35
+ return u.protocol === 'https:' && (u.hostname === 'googleapis.com' || u.hostname.endsWith('.googleapis.com'));
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ const memoryCache = new Map();
42
+ export function buildMethodIndex(doc, api) {
43
+ const baseUrl = doc.baseUrl ?? `${doc.rootUrl ?? ''}${doc.servicePath ?? ''}`;
44
+ const out = [];
45
+ const walk = (node) => {
46
+ for (const m of Object.values(node.methods ?? {})) {
47
+ const method = m;
48
+ if (!method.id || !method.httpMethod || !method.path)
49
+ continue;
50
+ const params = method.parameters ?? {};
51
+ out.push({
52
+ id: method.id,
53
+ api,
54
+ httpMethod: method.httpMethod.toUpperCase(),
55
+ path: method.path,
56
+ baseUrl,
57
+ description: (method.description ?? '').split('\n')[0].slice(0, 160),
58
+ params,
59
+ requiredParams: Object.entries(params)
60
+ .filter(([, p]) => p.required)
61
+ .map(([name]) => name),
62
+ scopes: method.scopes ?? [],
63
+ });
64
+ }
65
+ for (const r of Object.values(node.resources ?? {})) {
66
+ walk(r);
67
+ }
68
+ };
69
+ walk(doc);
70
+ return out;
71
+ }
72
+ export async function loadMethodIndex(api, deps = {}) {
73
+ const now = deps.now ?? Date.now;
74
+ const cached = memoryCache.get(api);
75
+ if (cached && now() < cached.refreshAfter)
76
+ return cached.index;
77
+ const spec = WORKSPACE_APIS[api];
78
+ if (!spec) {
79
+ throw new Error(`Unknown api "${api}". Known: ${Object.keys(WORKSPACE_APIS).join(', ')}`);
80
+ }
81
+ const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
82
+ const cacheDir = deps.cacheDir ?? discoveryCacheDir();
83
+ const cacheFile = path.join(cacheDir, `${api}.json`);
84
+ const readCache = () => {
85
+ try {
86
+ return JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ };
92
+ const cacheFresh = () => {
93
+ try {
94
+ return now() - fs.statSync(cacheFile).mtimeMs < TTL_MS;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ };
100
+ let doc;
101
+ let staleFallback = false;
102
+ if (cacheFresh())
103
+ doc = readCache();
104
+ if (!doc) {
105
+ const url = `https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`;
106
+ try {
107
+ const res = await fetchFn(url);
108
+ if (!res.ok)
109
+ throw new Error(`HTTP ${res.status}`);
110
+ doc = (await res.json());
111
+ fs.mkdirSync(cacheDir, { recursive: true });
112
+ fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
113
+ }
114
+ catch (err) {
115
+ doc = readCache();
116
+ staleFallback = true;
117
+ if (!doc) {
118
+ throw new Error(`Could not fetch the Google API Discovery document for "${api}" and no local cache exists (${err.message}). Retry when online.`, { cause: err });
119
+ }
120
+ }
121
+ }
122
+ const index = buildMethodIndex(doc, api);
123
+ memoryCache.set(api, { index, refreshAfter: now() + (staleFallback ? STALE_RETRY_MS : TTL_MS) });
124
+ return index;
125
+ }
126
+ export function clearDiscoveryMemoryCache() {
127
+ memoryCache.clear();
128
+ }
129
+ const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect)/i;
130
+ const POST_UPDATE_VERB = /^(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format)/i;
131
+ const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout)/i;
132
+ export function cudFromMethod(method) {
133
+ switch (method.httpMethod) {
134
+ case 'GET':
135
+ case 'HEAD':
136
+ return 'read';
137
+ case 'DELETE':
138
+ return 'delete';
139
+ case 'PUT':
140
+ case 'PATCH':
141
+ return 'update';
142
+ default: {
143
+ // POST carries reads (batchGet), updates (modify) and permanent deletes
144
+ // (batchDelete, clear, obliterate) — classify by verb or write-control misfires.
145
+ const lastSegment = method.id.split('.').pop() ?? '';
146
+ if (POST_READ_VERB.test(lastSegment))
147
+ return 'read';
148
+ if (POST_UPDATE_VERB.test(lastSegment))
149
+ return 'update';
150
+ if (POST_DELETE_VERB.test(lastSegment))
151
+ return 'delete';
152
+ return 'create';
153
+ }
154
+ }
155
+ }
156
+ export function expandPath(template, pathParams) {
157
+ return template.replace(/\{(\+?)([^}]+)\}/g, (_match, plus, name) => {
158
+ const value = pathParams[name];
159
+ if (value === undefined) {
160
+ throw new Error(`Missing required path parameter "${name}"`);
161
+ }
162
+ return plus ? String(value).split('/').map(encodeURIComponent).join('/') : encodeURIComponent(String(value));
163
+ });
164
+ }
165
+ export function searchMethods(index, query, limit = 10) {
166
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
167
+ if (tokens.length === 0)
168
+ return index.slice(0, limit);
169
+ const scored = index
170
+ .map((m) => {
171
+ const id = m.id.toLowerCase();
172
+ const desc = m.description.toLowerCase();
173
+ let score = 0;
174
+ for (const t of tokens) {
175
+ if (id.includes(t))
176
+ score += 3;
177
+ if (desc.includes(t))
178
+ score += 1;
179
+ }
180
+ return { m, score };
181
+ })
182
+ .filter((s) => s.score > 0);
183
+ scored.sort((a, b) => b.score - a.score || a.m.id.localeCompare(b.m.id));
184
+ return scored.slice(0, limit).map((s) => s.m);
185
+ }
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { registerAdminTools } from './tools/admin.js';
20
20
  import { getOptionalBundles, getAdminAccounts } from './auth.js';
21
21
  import { ToolRegistry } from './registry.js';
22
22
  import { registerDiscoverTools } from './discover.js';
23
+ import { registerEscapeTools } from './tools/google-api.js';
23
24
  import { getToolsets, toolsetEnabled } from './toolsets.js';
24
25
  import { resolvePolicy, isAllowed, describePolicy } from './write-control.js';
25
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -61,12 +62,13 @@ function buildRegistry(server, policy) {
61
62
  }
62
63
  svc.register(registry);
63
64
  }
64
- registerDiscoverTools(registry, policy);
65
- if (registry.tools.length === 0) {
65
+ if (registry.services().length === 0) {
66
66
  throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
67
67
  `Known services: ${SERVICES.map((s) => s.name).join(', ')}. ` +
68
68
  `Note: forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
69
69
  }
70
+ registerDiscoverTools(registry, policy);
71
+ registerEscapeTools(registry, policy);
70
72
  return registry;
71
73
  }
72
74
  async function main() {
@@ -90,7 +92,8 @@ async function main() {
90
92
  console.log(`CUD tools enabled: ${cud.length - disabled.length}/${cud.length}`);
91
93
  console.log(`Disabled: ${disabled.map((t) => t.name).join(', ') || '(none)'}`);
92
94
  console.log(`Services: ${registry.services().join(', ')}`);
93
- console.log(`Tool surface: ${counts.eager} eager (discover), ${counts.hidden} deferred until discovery`);
95
+ console.log(`Tool surface: ${counts.eager} eager (discover + escape hatch), ${counts.hidden} deferred until discovery`);
96
+ console.log(`Escape hatch: google_api_call CUD verdicts follow profile=${policy.profile} and your allow/deny globs`);
94
97
  return;
95
98
  }
96
99
  const policy = resolvePolicy();
@@ -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
  };
@@ -0,0 +1,10 @@
1
+ import type { ToolRegistry } from '../registry.js';
2
+ import { type Policy } from '../write-control.js';
3
+ import { getClient } from '../client.js';
4
+ import { type Toolsets } from '../toolsets.js';
5
+ import { type DiscoveryDeps } from '../discovery-client.js';
6
+ export interface EscapeDeps extends DiscoveryDeps {
7
+ getClientFn?: typeof getClient;
8
+ toolsets?: Toolsets;
9
+ }
10
+ export declare function registerEscapeTools(registry: ToolRegistry, policy: Policy, deps?: EscapeDeps): void;
@@ -0,0 +1,211 @@
1
+ import { z } from 'zod';
2
+ import { isAllowed, writeDisabledResult } from '../write-control.js';
3
+ import { ACCOUNTS } from '../accounts.js';
4
+ import { getClient } from '../client.js';
5
+ import { handleGoogleApiError } from './_errors.js';
6
+ import { coerceJson } from './_coerce.js';
7
+ import { getToolsets, toolsetEnabled } from '../toolsets.js';
8
+ import { WORKSPACE_APIS, cudFromMethod, expandPath, isGoogleApiUrl, loadMethodIndex, searchMethods, } from '../discovery-client.js';
9
+ const accountEnum = z.enum(ACCOUNTS);
10
+ const MAX_RESPONSE_CHARS = 100_000;
11
+ // Policy/toolset namespace for each API alias must match the NAMED tools' service
12
+ // names, or user deny globs and GOOGLE_TOOLSETS silently miss escape-hatch calls.
13
+ const SERVICE_FOR_ALIAS = {
14
+ gmail: 'gmail',
15
+ drive: 'drive',
16
+ calendar: 'calendar',
17
+ sheets: 'sheets',
18
+ docs: 'docs',
19
+ slides: null,
20
+ forms: 'forms',
21
+ people: 'contacts',
22
+ searchconsole: 'searchconsole',
23
+ tasks: 'tasks',
24
+ chat: 'chat',
25
+ meet: 'meet',
26
+ driveactivity: null,
27
+ drivelabels: null,
28
+ admin_directory: 'admin',
29
+ admin_reports: 'admin',
30
+ groupssettings: null,
31
+ };
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
+ function describeMethod(m) {
51
+ return {
52
+ api: m.api,
53
+ methodId: m.id,
54
+ httpMethod: m.httpMethod,
55
+ path: m.path,
56
+ description: m.description,
57
+ requiredParams: m.requiredParams,
58
+ cud: cudFromMethod(m),
59
+ };
60
+ }
61
+ export function registerEscapeTools(registry, policy, deps = {}) {
62
+ const getClientFn = deps.getClientFn ?? getClient;
63
+ const toolsets = deps.toolsets ?? getToolsets();
64
+ const apiEnabled = (alias) => {
65
+ const service = SERVICE_FOR_ALIAS[alias];
66
+ return service === null || service === undefined || toolsetEnabled(toolsets, service);
67
+ };
68
+ const enabledApis = Object.keys(WORKSPACE_APIS).filter(apiEnabled);
69
+ const apiList = enabledApis.join(', ');
70
+ const toolsetDisabled = (alias) => jsonResult({
71
+ error: 'toolset_disabled',
72
+ message: `API "${alias}" maps to service "${SERVICE_FOR_ALIAS[alias]}", which is excluded by GOOGLE_TOOLSETS.`,
73
+ hint: 'Add the service to GOOGLE_TOOLSETS (or unset it) to use this API.',
74
+ retriable: false,
75
+ }, true);
76
+ registry.registerMeta('google_api_search', {
77
+ description: 'Search the Google API Discovery index for any Workspace REST method, including ones with no ' +
78
+ 'dedicated tool here. Returns method ids + parameters to invoke via google_api_call. ' +
79
+ `APIs: ${apiList}.`,
80
+ inputSchema: {
81
+ query: z.string().describe('Keywords, e.g. "slides create presentation" or "drive revisions"'),
82
+ api: z.string().optional().describe('Restrict the search to one API alias'),
83
+ maxResults: z.number().min(1).max(25).default(10).optional(),
84
+ },
85
+ annotations: { openWorldHint: true },
86
+ }, async ({ query, api, maxResults }) => {
87
+ if (api && !WORKSPACE_APIS[api]) {
88
+ return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
89
+ }
90
+ if (api && !apiEnabled(api))
91
+ return toolsetDisabled(api);
92
+ const apis = api ? [api] : enabledApis;
93
+ const unavailable = [];
94
+ const indexes = await Promise.all(apis.map(async (a) => {
95
+ try {
96
+ return await loadMethodIndex(a, deps);
97
+ }
98
+ catch {
99
+ unavailable.push(a);
100
+ return [];
101
+ }
102
+ }));
103
+ const matches = searchMethods(indexes.flat(), query, maxResults ?? 10);
104
+ return jsonResult({
105
+ methods: matches.map(describeMethod),
106
+ ...(unavailable.length > 0 ? { unavailableApis: unavailable } : {}),
107
+ next: 'Invoke with google_api_call({account, api, methodId, pathParams, queryParams, body}).',
108
+ });
109
+ });
110
+ registry.registerMeta('google_api_call', {
111
+ description: 'Invoke any Google Workspace REST method by Discovery id (escape hatch for operations without a ' +
112
+ 'dedicated tool). Find methods with google_api_search first. Subject to the same write-control ' +
113
+ 'policy as named tools.',
114
+ inputSchema: {
115
+ account: accountEnum.describe('Google account alias'),
116
+ api: z.string().describe(`API alias: ${apiList}`),
117
+ methodId: z.string().describe('Discovery method id, e.g. "drive.revisions.list"'),
118
+ pathParams: coerceJson(z.record(z.string(), z.union([z.string(), z.number()])).optional())
119
+ .describe('Values for {placeholders} in the method path'),
120
+ queryParams: coerceJson(z
121
+ .record(z.string(), z.union([
122
+ z.string(),
123
+ z.number(),
124
+ z.boolean(),
125
+ z.array(z.union([z.string(), z.number(), z.boolean()])),
126
+ ]))
127
+ .optional()).describe('Query-string parameters; use an array for repeated params (e.g. resourceNames)'),
128
+ body: coerceJson(z.record(z.string(), z.unknown()).optional()).describe('JSON request body'),
129
+ },
130
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
131
+ }, async ({ account, api, methodId, pathParams, queryParams, body }) => {
132
+ if (!WORKSPACE_APIS[api]) {
133
+ return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
134
+ }
135
+ if (!apiEnabled(api))
136
+ 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
+ let index;
146
+ try {
147
+ index = await loadMethodIndex(api, deps);
148
+ }
149
+ catch (err) {
150
+ return jsonResult({ error: 'discovery_unavailable', message: err.message, retriable: true }, true);
151
+ }
152
+ const method = index.find((m) => m.id === methodId);
153
+ if (!method) {
154
+ return jsonResult({
155
+ error: 'unknown_method',
156
+ message: `No method "${methodId}" in ${api}.`,
157
+ hint: `Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
158
+ retriable: false,
159
+ }, true);
160
+ }
161
+ const cud = cudFromMethod(method);
162
+ const policyService = SERVICE_FOR_ALIAS[api] ?? api;
163
+ const lastSegment = method.id.split('.').pop() ?? method.id;
164
+ const toolRef = { name: `${policyService}_${lastSegment}`, service: policyService, cud };
165
+ if (cud !== 'read' && !isAllowed(toolRef, policy)) {
166
+ return writeDisabledResult(toolRef, policy);
167
+ }
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
+ }
210
+ });
211
+ }
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.1",
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",