mcp-google-multi 4.4.0 → 4.5.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.
@@ -1,6 +1,7 @@
1
1
  export interface AccountConfig {
2
2
  email: string;
3
3
  tokenPath: string;
4
+ encPath: string;
4
5
  }
5
6
  /** Tuple of account aliases (at least one) — usable with z.enum() */
6
7
  export declare const ACCOUNTS: [string, ...string[]];
package/dist/accounts.js CHANGED
@@ -3,7 +3,9 @@ import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
5
  dotenv.config({ path: path.resolve(__dirname, '..', '.env') });
6
- const tokenDir = path.resolve(__dirname, '..', 'tokens');
6
+ const tokenDir = process.env.TOKEN_STORE_PATH
7
+ ? path.resolve(process.env.TOKEN_STORE_PATH)
8
+ : path.resolve(__dirname, '..', 'tokens');
7
9
  /**
8
10
  * Parse accounts from the GOOGLE_ACCOUNTS env var.
9
11
  * Format: "alias1:email1,alias2:email2,..."
@@ -39,6 +41,7 @@ function parseAccounts() {
39
41
  configs[alias] = {
40
42
  email,
41
43
  tokenPath: path.join(tokenDir, alias, 'token.json'),
44
+ encPath: path.join(tokenDir, `${alias}.enc`),
42
45
  };
43
46
  }
44
47
  if (aliases.length === 0) {
package/dist/auth.js CHANGED
@@ -2,11 +2,10 @@ import { google } from 'googleapis';
2
2
  import http from 'node:http';
3
3
  import { URL } from 'node:url';
4
4
  import { randomBytes } from 'node:crypto';
5
- import fs from 'node:fs/promises';
6
- import path from 'node:path';
7
5
  import open from 'open';
8
6
  import destroyer from 'server-destroy';
9
7
  import { ACCOUNTS, ACCOUNT_CONFIG } from './accounts.js';
8
+ import { writeToken } from './token-store.js';
10
9
  // ─── Scope tiers ────────────────────────────────────────────────────────
11
10
  //
12
11
  // BASE: always granted. Existing v3 surface + Tasks + Meet (added in v4.0.0).
@@ -98,6 +97,10 @@ export async function runAuthFlow(args) {
98
97
  }
99
98
  const config = ACCOUNT_CONFIG[alias];
100
99
  const scopes = resolveScopesForAccount(alias);
100
+ if (!process.env.MASTER_KEY) {
101
+ console.error('MASTER_KEY is not set. Generate one (openssl rand -base64 32) and add it to .env before authenticating.');
102
+ process.exit(1);
103
+ }
101
104
  const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
102
105
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
103
106
  const expectedState = randomBytes(32).toString('hex');
@@ -145,13 +148,11 @@ export async function runAuthFlow(args) {
145
148
  return;
146
149
  }
147
150
  const { tokens } = await oauth2Client.getToken(code);
148
- await fs.mkdir(path.dirname(config.tokenPath), { recursive: true });
149
- // 0o600: tokens grant full account access; keep them user-only.
150
- await fs.writeFile(config.tokenPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
151
+ writeToken(alias, tokens);
151
152
  res.writeHead(200, { 'Content-Type': 'text/html' });
152
153
  res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
153
154
  server.destroy();
154
- console.log(`Token saved to ${config.tokenPath}`);
155
+ console.log(`Token saved (encrypted) for ${alias}.`);
155
156
  resolve();
156
157
  }
157
158
  }
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { google } from 'googleapis';
2
- import fs from 'node:fs/promises';
3
2
  import { ACCOUNT_CONFIG } from './accounts.js';
3
+ import { readToken, writeToken } from './token-store.js';
4
4
  export async function getClient(account) {
5
5
  const config = ACCOUNT_CONFIG[account];
6
6
  if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
@@ -8,26 +8,15 @@ export async function getClient(account) {
8
8
  'Check that .env exists in the project root or pass them as env vars.');
9
9
  }
10
10
  const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
11
- let tokenData;
12
- try {
13
- const raw = await fs.readFile(config.tokenPath, 'utf-8');
14
- tokenData = JSON.parse(raw);
15
- }
16
- catch {
17
- throw new Error(`No token file found for account "${account}" at ${config.tokenPath}. ` +
18
- `Run: node dist/index.js auth --account ${account}`);
11
+ const tokenData = readToken(account);
12
+ if (!tokenData) {
13
+ throw new Error(`No token found for account "${account}" (${config.email}). ` +
14
+ `Run: npx mcp-google-multi auth --account ${account}`);
19
15
  }
20
16
  oauth2Client.setCredentials(tokenData);
21
- // 0o600: tokens grant full account access; keep them user-only.
22
- oauth2Client.on('tokens', async (tokens) => {
23
- try {
24
- const existing = JSON.parse(await fs.readFile(config.tokenPath, 'utf-8'));
25
- const merged = { ...existing, ...tokens };
26
- await fs.writeFile(config.tokenPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
27
- }
28
- catch {
29
- await fs.writeFile(config.tokenPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
30
- }
17
+ oauth2Client.on('tokens', (tokens) => {
18
+ const existing = readToken(account) ?? {};
19
+ writeToken(account, { ...existing, ...tokens });
31
20
  });
32
21
  return oauth2Client;
33
22
  }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { registerFormsTools } from './tools/forms.js';
18
18
  import { registerChatTools } from './tools/chat.js';
19
19
  import { registerAdminTools, registerAlertCenterTools } from './tools/admin.js';
20
20
  import { getOptionalBundles, getAdminAccounts } from './auth.js';
21
+ import { ToolRegistry } from './registry.js';
21
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
23
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
23
24
  async function main() {
@@ -26,29 +27,35 @@ async function main() {
26
27
  await runAuthFlow(process.argv);
27
28
  return;
28
29
  }
30
+ if (process.argv.includes('migrate-tokens')) {
31
+ const { runMigrateTokens } = await import('./migrate-tokens.js');
32
+ runMigrateTokens();
33
+ return;
34
+ }
29
35
  // MCP server mode — no console.log (stdio is the MCP channel)
30
36
  const server = new McpServer({
31
37
  name: 'mcp-google-multi',
32
38
  version: pkg.version,
33
39
  });
34
- registerGmailTools(server);
35
- registerDriveTools(server);
36
- registerCalendarTools(server);
37
- registerSheetsTools(server);
38
- registerDocsTools(server);
39
- registerContactsTools(server);
40
- registerSearchConsoleTools(server);
41
- registerTasksTools(server);
42
- registerMeetTools(server);
40
+ const registry = new ToolRegistry(server);
41
+ registerGmailTools(registry);
42
+ registerDriveTools(registry);
43
+ registerCalendarTools(registry);
44
+ registerSheetsTools(registry);
45
+ registerDocsTools(registry);
46
+ registerContactsTools(registry);
47
+ registerSearchConsoleTools(registry);
48
+ registerTasksTools(registry);
49
+ registerMeetTools(registry);
43
50
  const optional = new Set(getOptionalBundles());
44
51
  if (optional.has('forms'))
45
- registerFormsTools(server);
52
+ registerFormsTools(registry);
46
53
  if (optional.has('chat'))
47
- registerChatTools(server);
54
+ registerChatTools(registry);
48
55
  if (optional.has('alertcenter'))
49
- registerAlertCenterTools(server);
56
+ registerAlertCenterTools(registry);
50
57
  if (getAdminAccounts().length > 0)
51
- registerAdminTools(server);
58
+ registerAdminTools(registry);
52
59
  const transport = new StdioServerTransport();
53
60
  await server.connect(transport);
54
61
  }
@@ -0,0 +1 @@
1
+ export declare function runMigrateTokens(): void;
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs';
2
+ import { ACCOUNTS, ACCOUNT_CONFIG } from './accounts.js';
3
+ import { writeToken, hasToken } from './token-store.js';
4
+ export function runMigrateTokens() {
5
+ let migrated = 0;
6
+ let skipped = 0;
7
+ for (const alias of ACCOUNTS) {
8
+ const plain = ACCOUNT_CONFIG[alias].tokenPath;
9
+ if (!fs.existsSync(plain))
10
+ continue;
11
+ if (hasToken(alias)) {
12
+ console.log(`• ${alias}: encrypted token already exists, skipping`);
13
+ skipped++;
14
+ continue;
15
+ }
16
+ writeToken(alias, JSON.parse(fs.readFileSync(plain, 'utf8')));
17
+ console.log(`✓ ${alias}: migrated ${plain} → encrypted store`);
18
+ migrated++;
19
+ }
20
+ console.log(`Done. ${migrated} migrated, ${skipped} skipped. ` +
21
+ 'Delete the plaintext tokens/<alias>/token.json once verified.');
22
+ }
@@ -0,0 +1,13 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export type Cud = 'read' | 'create' | 'update' | 'delete';
3
+ export interface ToolEntry {
4
+ name: string;
5
+ service: string;
6
+ cud: Cud;
7
+ }
8
+ export declare function inferCud(name: string): Cud;
9
+ export declare class ToolRegistry {
10
+ readonly tools: ToolEntry[];
11
+ readonly registerTool: McpServer['registerTool'];
12
+ constructor(server: McpServer);
13
+ }
@@ -0,0 +1,29 @@
1
+ const CUD_OVERRIDES = {
2
+ drive_untrash: 'update',
3
+ };
4
+ const DELETE_VERB = /(^|_)(delete|remove|trash|clear|empty)(_|$)/;
5
+ const CREATE_VERB = /(^|_)(create|add|insert|send|upload|copy|import|append|submit|duplicate|share|quick)(_|$)/;
6
+ const UPDATE_VERB = /(^|_)(update|patch|modify|set|move|write|format|merge|unmerge|sort|replace|resize|publish|resolve)(_|$)/;
7
+ export function inferCud(name) {
8
+ const override = CUD_OVERRIDES[name];
9
+ if (override)
10
+ return override;
11
+ if (DELETE_VERB.test(name))
12
+ return 'delete';
13
+ if (CREATE_VERB.test(name))
14
+ return 'create';
15
+ if (UPDATE_VERB.test(name))
16
+ return 'update';
17
+ return 'read';
18
+ }
19
+ export class ToolRegistry {
20
+ tools = [];
21
+ registerTool;
22
+ constructor(server) {
23
+ this.registerTool = ((name, ...rest) => {
24
+ const service = name.includes('_') ? name.slice(0, name.indexOf('_')) : name;
25
+ this.tools.push({ name, service, cud: inferCud(name) });
26
+ return server.registerTool(name, ...rest);
27
+ });
28
+ }
29
+ }
@@ -0,0 +1,7 @@
1
+ import type { TokenData } from './types.js';
2
+ export declare function deriveKey(masterKey: string): Buffer;
3
+ export declare function encryptToken(data: object, masterKey: string): string;
4
+ export declare function decryptToken(fileContents: string, masterKey: string): TokenData;
5
+ export declare function readToken(alias: string): TokenData | null;
6
+ export declare function writeToken(alias: string, data: object): void;
7
+ export declare function hasToken(alias: string): boolean;
@@ -0,0 +1,66 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ACCOUNT_CONFIG } from './accounts.js';
5
+ const ENC_VERSION = 1;
6
+ const ALGO = 'aes-256-gcm';
7
+ export function deriveKey(masterKey) {
8
+ if (!masterKey) {
9
+ throw new Error('MASTER_KEY is required to encrypt/decrypt tokens. Generate one with: openssl rand -base64 32');
10
+ }
11
+ const raw = Buffer.from(masterKey, 'base64');
12
+ if (raw.length === 32)
13
+ return raw;
14
+ return createHash('sha256').update(masterKey, 'utf8').digest();
15
+ }
16
+ export function encryptToken(data, masterKey) {
17
+ const key = deriveKey(masterKey);
18
+ const iv = randomBytes(12);
19
+ const cipher = createCipheriv(ALGO, key, iv);
20
+ const ciphertext = Buffer.concat([
21
+ cipher.update(Buffer.from(JSON.stringify(data), 'utf8')),
22
+ cipher.final(),
23
+ ]);
24
+ const file = {
25
+ v: ENC_VERSION,
26
+ iv: iv.toString('base64'),
27
+ tag: cipher.getAuthTag().toString('base64'),
28
+ data: ciphertext.toString('base64'),
29
+ };
30
+ return JSON.stringify(file);
31
+ }
32
+ export function decryptToken(fileContents, masterKey) {
33
+ const key = deriveKey(masterKey);
34
+ const file = JSON.parse(fileContents);
35
+ if (file.v !== ENC_VERSION) {
36
+ throw new Error(`Unsupported token file version: ${file.v}`);
37
+ }
38
+ const decipher = createDecipheriv(ALGO, key, Buffer.from(file.iv, 'base64'));
39
+ decipher.setAuthTag(Buffer.from(file.tag, 'base64'));
40
+ const plaintext = Buffer.concat([
41
+ decipher.update(Buffer.from(file.data, 'base64')),
42
+ decipher.final(),
43
+ ]);
44
+ return JSON.parse(plaintext.toString('utf8'));
45
+ }
46
+ function masterKey() {
47
+ return process.env.MASTER_KEY ?? '';
48
+ }
49
+ export function readToken(alias) {
50
+ let contents;
51
+ try {
52
+ contents = fs.readFileSync(ACCOUNT_CONFIG[alias].encPath, 'utf8');
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ return decryptToken(contents, masterKey());
58
+ }
59
+ export function writeToken(alias, data) {
60
+ const p = ACCOUNT_CONFIG[alias].encPath;
61
+ fs.mkdirSync(path.dirname(p), { recursive: true });
62
+ fs.writeFileSync(p, encryptToken(data, masterKey()), { mode: 0o600 });
63
+ }
64
+ export function hasToken(alias) {
65
+ return fs.existsSync(ACCOUNT_CONFIG[alias].encPath);
66
+ }
@@ -0,0 +1,4 @@
1
+ import { z } from 'zod';
2
+ export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<T>>;
3
+ export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
4
+ export declare const coerceBoolean: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodBoolean>;
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ function parseJsonLoose(s) {
3
+ try {
4
+ return JSON.parse(s);
5
+ }
6
+ catch {
7
+ return s;
8
+ }
9
+ }
10
+ export function coerceArray(element) {
11
+ return z.preprocess((val) => {
12
+ if (typeof val !== 'string')
13
+ return val;
14
+ const t = val.trim();
15
+ if (t === '')
16
+ return [];
17
+ if (t.startsWith('['))
18
+ return parseJsonLoose(t);
19
+ return t.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
20
+ }, z.array(element));
21
+ }
22
+ export function coerceJson(schema) {
23
+ return z.preprocess((val) => (typeof val === 'string' ? parseJsonLoose(val) : val), schema);
24
+ }
25
+ export const coerceBoolean = z.preprocess((val) => {
26
+ if (typeof val === 'boolean')
27
+ return val;
28
+ if (typeof val === 'string') {
29
+ const t = val.trim().toLowerCase();
30
+ if (['true', '1', 'yes', 'y'].includes(t))
31
+ return true;
32
+ if (['false', '0', 'no', 'n'].includes(t))
33
+ return false;
34
+ }
35
+ return val;
36
+ }, z.boolean());
@@ -1,5 +1,12 @@
1
1
  import type { Account } from '../accounts.js';
2
- /** Maps googleapis errors to MCP tool responses. 401 → re-auth instruction; 403 + hint → structured hint payload; 429 → retry-after; else → {error, code}. */
2
+ export interface ErrorEnvelope {
3
+ error: string;
4
+ message: string;
5
+ hint?: string;
6
+ retriable: boolean;
7
+ account: string;
8
+ }
9
+ export declare function mapGoogleError(error: any, account: Account, forbiddenHint?: string): ErrorEnvelope;
3
10
  export declare function handleGoogleApiError(error: any, account: Account, forbiddenHint?: string): {
4
11
  content: {
5
12
  type: "text";
@@ -1,42 +1,69 @@
1
- /** Maps googleapis errors to MCP tool responses. 401 → re-auth instruction; 403 + hint → structured hint payload; 429 → retry-after; else → {error, code}. */
2
- export function handleGoogleApiError(error, account, forbiddenHint) {
3
- if (error.code === 401) {
1
+ function statusOf(error) {
2
+ const c = error?.code ?? error?.status ?? error?.response?.status;
3
+ const n = typeof c === 'string' ? Number(c) : c;
4
+ return Number.isFinite(n) ? n : undefined;
5
+ }
6
+ function reasonOf(error) {
7
+ return (error?.errors?.[0]?.reason ??
8
+ error?.response?.data?.error?.errors?.[0]?.reason ??
9
+ error?.response?.data?.error?.status);
10
+ }
11
+ function messageOf(error) {
12
+ return error?.response?.data?.error?.message ?? error?.message ?? String(error);
13
+ }
14
+ export function mapGoogleError(error, account, forbiddenHint) {
15
+ const status = statusOf(error);
16
+ const reason = reasonOf(error);
17
+ const message = messageOf(error);
18
+ if (status === 401) {
4
19
  return {
5
- content: [{
6
- type: 'text',
7
- text: `Authentication error for account "${account}". Run: node dist/index.js auth --account ${account}`,
8
- }],
9
- isError: true,
20
+ error: 'auth_required',
21
+ message: `Authentication failed for account "${account}".`,
22
+ hint: `Run: npx mcp-google-multi auth --account ${account}`,
23
+ retriable: false,
24
+ account,
10
25
  };
11
26
  }
12
- if (error.code === 403 && forbiddenHint) {
13
- return {
14
- content: [{
15
- type: 'text',
16
- text: JSON.stringify({
17
- error: 'forbidden',
18
- hint: forbiddenHint,
19
- original: error.message ?? String(error),
20
- }),
21
- }],
22
- isError: true,
23
- };
27
+ if (status === 403) {
28
+ const scopeIssue = reason === 'insufficientPermissions' ||
29
+ reason === 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' ||
30
+ /insufficient.*scope/i.test(message);
31
+ if (scopeIssue) {
32
+ return {
33
+ error: 'insufficient_scope',
34
+ message,
35
+ hint: forbiddenHint ?? `Re-auth "${account}" with the scope this operation needs.`,
36
+ retriable: false,
37
+ account,
38
+ };
39
+ }
40
+ return { error: 'forbidden', message, hint: forbiddenHint, retriable: false, account };
24
41
  }
25
- if (error.code === 429) {
26
- const retryAfter = error.response?.headers?.['retry-after'] ?? 'unknown';
42
+ if (status === 400 && /invalid[_ ]scope/i.test(message)) {
43
+ return { error: 'invalid_scope', message, retriable: false, account };
44
+ }
45
+ if (status === 404) {
46
+ return { error: 'not_found', message, retriable: false, account };
47
+ }
48
+ if (status === 429) {
49
+ const retryAfter = error?.response?.headers?.['retry-after'];
27
50
  return {
28
- content: [{
29
- type: 'text',
30
- text: JSON.stringify({ error: 'rate_limited', retryAfter }),
31
- }],
32
- isError: true,
51
+ error: 'rate_limited',
52
+ message,
53
+ hint: retryAfter ? `Retry after ${retryAfter}s.` : 'Back off and retry.',
54
+ retriable: true,
55
+ account,
33
56
  };
34
57
  }
58
+ if (status !== undefined && status >= 500) {
59
+ return { error: 'upstream_error', message, retriable: true, account };
60
+ }
61
+ return { error: 'upstream_error', message, retriable: false, account };
62
+ }
63
+ export function handleGoogleApiError(error, account, forbiddenHint) {
64
+ const envelope = mapGoogleError(error, account, forbiddenHint);
35
65
  return {
36
- content: [{
37
- type: 'text',
38
- text: JSON.stringify({ error: error.message ?? String(error), code: error.code }),
39
- }],
66
+ content: [{ type: 'text', text: JSON.stringify(envelope) }],
40
67
  isError: true,
41
68
  };
42
69
  }
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { ToolRegistry } from '../registry.js';
2
2
  /**
3
3
  * Admin SDK tools require Workspace super-admin (or delegated admin) privileges on the target account.
4
4
  * Personal `@gmail.com` accounts will 403 on every endpoint.
@@ -6,7 +6,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  * Writes are gated behind GOOGLE_ALLOW_ADMIN_WRITES=true to prevent accidents on small orgs
7
7
  * (a stray users.update on a 3-person Workspace is a bad day).
8
8
  */
9
- export declare function registerAdminTools(server: McpServer): void;
9
+ export declare function registerAdminTools(server: ToolRegistry): void;
10
10
  /**
11
11
  * Alert Center tools. Gated behind the `alertcenter` optional bundle, NOT the
12
12
  * admin bundle: the apps.alerts scope cannot be granted via this server's
@@ -14,4 +14,4 @@ export declare function registerAdminTools(server: McpServer): void;
14
14
  * domain-wide delegation. Registered separately so a missing/ungrantable
15
15
  * apps.alerts scope never blocks the working Admin SDK admin tools.
16
16
  */
17
- export declare function registerAlertCenterTools(server: McpServer): void;
17
+ export declare function registerAlertCenterTools(server: ToolRegistry): void;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceBoolean } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -75,7 +76,7 @@ export function registerAdminTools(server) {
75
76
  maxResults: z.number().min(1).max(500).optional(),
76
77
  pageToken: z.string().optional(),
77
78
  orderBy: z.enum(['email', 'familyName', 'givenName']).optional(),
78
- showDeleted: z.boolean().optional(),
79
+ showDeleted: coerceBoolean.optional(),
79
80
  projection: z.enum(['basic', 'custom', 'full']).optional(),
80
81
  },
81
82
  }, async ({ account, customer, domain, query, maxResults, pageToken, orderBy, showDeleted, projection }) => {
@@ -130,9 +131,9 @@ export function registerAdminTools(server) {
130
131
  userKey: z.string().describe('User email or ID'),
131
132
  givenName: z.string().optional(),
132
133
  familyName: z.string().optional(),
133
- suspended: z.boolean().optional(),
134
+ suspended: coerceBoolean.optional(),
134
135
  password: z.string().optional(),
135
- changePasswordAtNextLogin: z.boolean().optional(),
136
+ changePasswordAtNextLogin: coerceBoolean.optional(),
136
137
  orgUnitPath: z.string().optional(),
137
138
  },
138
139
  }, async ({ account, userKey, givenName, familyName, suspended, password, changePasswordAtNextLogin, orgUnitPath }) => {
@@ -217,7 +218,7 @@ export function registerAdminTools(server) {
217
218
  account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
218
219
  groupKey: z.string().describe('Group email or ID'),
219
220
  roles: z.string().optional().describe('Comma-separated roles to include (OWNER, MANAGER, MEMBER)'),
220
- includeDerivedMembership: z.boolean().optional(),
221
+ includeDerivedMembership: coerceBoolean.optional(),
221
222
  maxResults: z.number().min(1).max(200).optional(),
222
223
  pageToken: z.string().optional(),
223
224
  },
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerCalendarTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerCalendarTools(server: ToolRegistry): void;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceArray, coerceBoolean } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -111,7 +112,7 @@ export function registerCalendarTools(server) {
111
112
  .describe('Comma-separated email addresses of attendees'),
112
113
  calendarId: z.string().default('primary').optional()
113
114
  .describe('Calendar ID (default: primary)'),
114
- allDay: z.boolean().default(false).optional()
115
+ allDay: coerceBoolean.default(false).optional()
115
116
  .describe('If true, start/end are dates (YYYY-MM-DD) not datetimes'),
116
117
  },
117
118
  }, async ({ account, summary, start, end, description, location, attendees, calendarId, allDay }) => {
@@ -240,7 +241,7 @@ export function registerCalendarTools(server) {
240
241
  calendarId: z.string().default('primary').optional()
241
242
  .describe('Calendar ID (default: primary)'),
242
243
  text: z.string().describe('Natural language event, e.g. "Lunch with Farouk Thursday 1pm at Le Boulanger"'),
243
- sendNotifications: z.boolean().optional().describe('Send notifications to attendees (default: false)'),
244
+ sendNotifications: coerceBoolean.optional().describe('Send notifications to attendees (default: false)'),
244
245
  },
245
246
  }, async ({ account, calendarId, text, sendNotifications }) => {
246
247
  try {
@@ -266,7 +267,7 @@ export function registerCalendarTools(server) {
266
267
  calendarId: z.string().describe('Source calendar ID'),
267
268
  eventId: z.string().describe('Event ID to move'),
268
269
  destinationCalendarId: z.string().describe('Destination calendar ID'),
269
- sendNotifications: z.boolean().optional().describe('Send notifications (default: false)'),
270
+ sendNotifications: coerceBoolean.optional().describe('Send notifications (default: false)'),
270
271
  },
271
272
  }, async ({ account, calendarId, eventId, destinationCalendarId, sendNotifications }) => {
272
273
  try {
@@ -322,7 +323,7 @@ export function registerCalendarTools(server) {
322
323
  description: 'Check free/busy times for one or more calendars within a time window. Returns only busy blocks, not event details.',
323
324
  inputSchema: {
324
325
  account: accountEnum.describe('Google account alias'),
325
- calendarIds: z.array(z.string()).describe('Calendar IDs to check, e.g. ["primary", "user@example.com"]'),
326
+ calendarIds: coerceArray(z.string()).describe('Calendar IDs to check, e.g. ["primary", "user@example.com"]'),
326
327
  timeMin: z.string().describe('ISO 8601 start of window'),
327
328
  timeMax: z.string().describe('ISO 8601 end of window'),
328
329
  timeZone: z.string().optional().describe('Timezone (default: UTC)'),
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerChatTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerChatTools(server: ToolRegistry): void;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceJson } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -55,7 +56,7 @@ export function registerChatTools(server) {
55
56
  account: accountEnum.describe('Google account alias'),
56
57
  parent: z.string().describe('Space resource name, format: spaces/{space}'),
57
58
  text: z.string().optional().describe('Plain message text'),
58
- cardsV2: z.array(z.record(z.string(), z.any())).optional().describe('Optional Card v2 payloads'),
59
+ cardsV2: coerceJson(z.array(z.record(z.string(), z.any()))).optional().describe('Optional Card v2 payloads'),
59
60
  threadKey: z.string().optional().describe('Thread key to group messages'),
60
61
  messageReplyOption: z.enum(['MESSAGE_REPLY_OPTION_UNSPECIFIED', 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD', 'REPLY_MESSAGE_OR_FAIL']).optional(),
61
62
  },
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerContactsTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerContactsTools(server: ToolRegistry): void;
@@ -1,5 +1,5 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerDocsTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerDocsTools(server: ToolRegistry): void;
3
3
  export declare function buildParagraphStyle(input: {
4
4
  namedStyleType?: string;
5
5
  alignment?: string;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceBoolean, coerceJson } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -56,7 +57,7 @@ export function registerDocsTools(server) {
56
57
  inputSchema: {
57
58
  account: accountEnum.describe('Google account alias'),
58
59
  documentId: z.string().describe('Google Docs document ID'),
59
- includeTabsContent: z.boolean().optional()
60
+ includeTabsContent: coerceBoolean.optional()
60
61
  .describe('Include full content for every tab (default: false — first tab only)'),
61
62
  suggestionsViewMode: z.enum([
62
63
  'DEFAULT_FOR_CURRENT_ACCESS',
@@ -154,7 +155,7 @@ export function registerDocsTools(server) {
154
155
  documentId: z.string().describe('Google Docs document ID'),
155
156
  findText: z.string().describe('Text to search for'),
156
157
  replaceText: z.string().describe('Replacement text'),
157
- matchCase: z.boolean().default(true).optional()
158
+ matchCase: coerceBoolean.default(true).optional()
158
159
  .describe('Case-sensitive match (default: true)'),
159
160
  },
160
161
  }, async ({ account, documentId, findText, replaceText, matchCase }) => {
@@ -224,9 +225,9 @@ export function registerDocsTools(server) {
224
225
  documentId: z.string().describe('Google Docs document ID'),
225
226
  startIndex: z.number().min(1).describe('Start index (inclusive, 1-based)'),
226
227
  endIndex: z.number().min(2).describe('End index (exclusive)'),
227
- bold: z.boolean().optional().describe('Set bold'),
228
- italic: z.boolean().optional().describe('Set italic'),
229
- underline: z.boolean().optional().describe('Set underline'),
228
+ bold: coerceBoolean.optional().describe('Set bold'),
229
+ italic: coerceBoolean.optional().describe('Set italic'),
230
+ underline: coerceBoolean.optional().describe('Set underline'),
230
231
  fontSize: z.number().min(1).optional().describe('Font size in points'),
231
232
  fontFamily: z.string().optional().describe('Font family name (e.g. "Arial", "Times New Roman")'),
232
233
  },
@@ -443,9 +444,9 @@ export function registerDocsTools(server) {
443
444
  indentEnd: z.number().optional().describe('End indent in points'),
444
445
  indentFirstLine: z.number().optional().describe('First-line indent in points'),
445
446
  direction: z.enum(['LEFT_TO_RIGHT', 'RIGHT_TO_LEFT']).optional(),
446
- keepLinesTogether: z.boolean().optional(),
447
- keepWithNext: z.boolean().optional(),
448
- avoidWidowAndOrphan: z.boolean().optional(),
447
+ keepLinesTogether: coerceBoolean.optional(),
448
+ keepWithNext: coerceBoolean.optional(),
449
+ avoidWidowAndOrphan: coerceBoolean.optional(),
449
450
  },
450
451
  }, async ({ account, documentId, startIndex, endIndex, ...style }) => {
451
452
  try {
@@ -489,9 +490,9 @@ export function registerDocsTools(server) {
489
490
  marginHeader: z.number().optional(),
490
491
  marginFooter: z.number().optional(),
491
492
  pageNumberStart: z.number().optional(),
492
- useFirstPageHeaderFooter: z.boolean().optional(),
493
- useEvenPageHeaderFooter: z.boolean().optional(),
494
- useCustomHeaderFooterMargins: z.boolean().optional(),
493
+ useFirstPageHeaderFooter: coerceBoolean.optional(),
494
+ useEvenPageHeaderFooter: coerceBoolean.optional(),
495
+ useCustomHeaderFooterMargins: coerceBoolean.optional(),
495
496
  },
496
497
  }, async ({ account, documentId, ...style }) => {
497
498
  try {
@@ -807,8 +808,8 @@ export function registerDocsTools(server) {
807
808
  tableStartIndex: z.number().min(1).describe('Start index of the table in the document'),
808
809
  rowIndex: z.number().min(0).describe('Target row (0-based)'),
809
810
  columnIndex: z.number().min(0).describe('Target column (0-based)'),
810
- insertBelow: z.boolean().optional().describe('insertRow only: insert below the target row (default: false)'),
811
- insertRight: z.boolean().optional().describe('insertColumn only: insert right of the target column (default: false)'),
811
+ insertBelow: coerceBoolean.optional().describe('insertRow only: insert below the target row (default: false)'),
812
+ insertRight: coerceBoolean.optional().describe('insertColumn only: insert right of the target column (default: false)'),
812
813
  rowSpan: z.number().min(1).optional().describe('mergeCells only: how many rows the merged cell spans (default 1)'),
813
814
  columnSpan: z.number().min(1).optional().describe('mergeCells/unmergeCells: column span (default 1)'),
814
815
  },
@@ -981,7 +982,7 @@ export function registerDocsTools(server) {
981
982
  inputSchema: {
982
983
  account: accountEnum.describe('Google account alias'),
983
984
  documentId: z.string().describe('Google Docs document ID'),
984
- requests: z.array(z.record(z.string(), z.any())).describe('Array of Request objects, each with one request-type key'),
985
+ requests: coerceJson(z.array(z.record(z.string(), z.any()))).describe('Array of Request objects, each with one request-type key'),
985
986
  writeControl: z.object({
986
987
  requiredRevisionId: z.string().optional(),
987
988
  targetRevisionId: z.string().optional(),
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerDriveTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerDriveTools(server: ToolRegistry): void;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceArray, coerceBoolean } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -505,9 +506,9 @@ export function registerDriveTools(server) {
505
506
  role: z.enum(['reader', 'commenter', 'writer', 'fileOrganizer', 'organizer', 'owner']).describe('Permission role'),
506
507
  emailAddress: z.string().optional().describe('Required when type is "user" or "group"'),
507
508
  domain: z.string().optional().describe('Required when type is "domain"'),
508
- sendNotification: z.boolean().optional().describe('Send notification email (default: true)'),
509
+ sendNotification: coerceBoolean.optional().describe('Send notification email (default: true)'),
509
510
  emailMessage: z.string().optional().describe('Custom message in notification email'),
510
- transferOwnership: z.boolean().optional().describe('Transfer ownership to the recipient. Requires role="owner". Recipient must accept ownership.'),
511
+ transferOwnership: coerceBoolean.optional().describe('Transfer ownership to the recipient. Requires role="owner". Recipient must accept ownership.'),
511
512
  expirationTime: z.string().optional().describe('RFC 3339 timestamp when access expires. Only valid for role="reader" or "commenter".'),
512
513
  },
513
514
  }, async ({ account, fileId, type, role, emailAddress, domain, sendNotification, emailMessage, transferOwnership, expirationTime }) => {
@@ -567,9 +568,9 @@ export function registerDriveTools(server) {
567
568
  .describe('New role'),
568
569
  expirationTime: z.string().optional()
569
570
  .describe('New RFC 3339 expiration timestamp. Only valid for role "reader" or "commenter".'),
570
- removeExpiration: z.boolean().optional()
571
+ removeExpiration: coerceBoolean.optional()
571
572
  .describe('Clear the existing expirationTime'),
572
- transferOwnership: z.boolean().optional()
573
+ transferOwnership: coerceBoolean.optional()
573
574
  .describe('Promote to owner. Requires role="owner".'),
574
575
  },
575
576
  }, async ({ account, fileId, permissionId, role, expirationTime, removeExpiration, transferOwnership }) => {
@@ -658,7 +659,7 @@ export function registerDriveTools(server) {
658
659
  inputSchema: {
659
660
  account: accountEnum.describe('Google account alias'),
660
661
  fileId: z.string().describe('Google Drive file ID'),
661
- includeDeleted: z.boolean().optional().describe('Include deleted comments (default: false)'),
662
+ includeDeleted: coerceBoolean.optional().describe('Include deleted comments (default: false)'),
662
663
  pageSize: z.number().min(1).max(100).optional().describe('Max comments per page (default: 20)'),
663
664
  pageToken: z.string().optional().describe('Token from a previous page'),
664
665
  startModifiedTime: z.string().optional().describe('Only return comments modified after this RFC 3339 timestamp'),
@@ -689,7 +690,7 @@ export function registerDriveTools(server) {
689
690
  account: accountEnum.describe('Google account alias'),
690
691
  fileId: z.string().describe('Google Drive file ID'),
691
692
  commentId: z.string().describe('Comment ID'),
692
- includeDeleted: z.boolean().optional(),
693
+ includeDeleted: coerceBoolean.optional(),
693
694
  },
694
695
  }, async ({ account, fileId, commentId, includeDeleted }) => {
695
696
  try {
@@ -793,7 +794,7 @@ export function registerDriveTools(server) {
793
794
  account: accountEnum.describe('Google account alias'),
794
795
  fileId: z.string().describe('Google Drive file ID'),
795
796
  commentId: z.string().describe('Parent comment ID'),
796
- includeDeleted: z.boolean().optional(),
797
+ includeDeleted: coerceBoolean.optional(),
797
798
  pageSize: z.number().min(1).max(100).optional(),
798
799
  pageToken: z.string().optional(),
799
800
  },
@@ -899,10 +900,10 @@ export function registerDriveTools(server) {
899
900
  account: accountEnum.describe('Google account alias'),
900
901
  fileId: z.string().describe('Google Drive file ID'),
901
902
  revisionId: z.string().describe('Revision ID'),
902
- keepForever: z.boolean().optional().describe('Pin this revision indefinitely'),
903
- published: z.boolean().optional().describe('Toggle published state (Docs only)'),
904
- publishAuto: z.boolean().optional().describe('Auto-publish subsequent revisions'),
905
- publishedOutsideDomain: z.boolean().optional().describe('Allow publish outside domain'),
903
+ keepForever: coerceBoolean.optional().describe('Pin this revision indefinitely'),
904
+ published: coerceBoolean.optional().describe('Toggle published state (Docs only)'),
905
+ publishAuto: coerceBoolean.optional().describe('Auto-publish subsequent revisions'),
906
+ publishedOutsideDomain: coerceBoolean.optional().describe('Allow publish outside domain'),
906
907
  },
907
908
  }, async ({ account, fileId, revisionId, keepForever, published, publishAuto, publishedOutsideDomain }) => {
908
909
  try {
@@ -984,10 +985,10 @@ export function registerDriveTools(server) {
984
985
  fileId: z.string().describe('Google Drive file ID'),
985
986
  proposalId: z.string().describe('Access proposal ID'),
986
987
  action: z.enum(['ACCEPT', 'DENY']).describe('Whether to accept or deny the proposal'),
987
- role: z.array(z.enum(['reader', 'commenter', 'writer', 'fileOrganizer'])).optional()
988
+ role: coerceArray(z.enum(['reader', 'commenter', 'writer', 'fileOrganizer'])).optional()
988
989
  .describe('Required when action is ACCEPT'),
989
990
  view: z.string().optional().describe('Optional view, e.g. "published"'),
990
- sendNotification: z.boolean().optional()
991
+ sendNotification: coerceBoolean.optional()
991
992
  .describe('Email the requester about the resolution'),
992
993
  },
993
994
  }, async ({ account, fileId, proposalId, action, role, view, sendNotification }) => {
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerFormsTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerFormsTools(server: ToolRegistry): void;
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerGmailTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerGmailTools(server: ToolRegistry): void;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceArray, coerceBoolean } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -319,8 +320,8 @@ export function registerGmailTools(server) {
319
320
  inputSchema: {
320
321
  account: accountEnum.describe('Google account alias'),
321
322
  messageId: z.string().describe('Gmail message ID'),
322
- addLabelIds: z.array(z.string()).optional().describe('Label IDs to add'),
323
- removeLabelIds: z.array(z.string()).optional().describe('Label IDs to remove'),
323
+ addLabelIds: coerceArray(z.string()).optional().describe('Label IDs to add'),
324
+ removeLabelIds: coerceArray(z.string()).optional().describe('Label IDs to remove'),
324
325
  },
325
326
  }, async ({ account, messageId, addLabelIds, removeLabelIds }) => {
326
327
  try {
@@ -384,9 +385,9 @@ export function registerGmailTools(server) {
384
385
  description: 'Add/remove labels across up to 1000 Gmail messages at once. Useful for bulk archiving, marking as read, etc.',
385
386
  inputSchema: {
386
387
  account: accountEnum.describe('Google account alias'),
387
- messageIds: z.array(z.string()).describe('Message IDs (up to 1000)'),
388
- addLabelIds: z.array(z.string()).optional().describe('Label IDs to add'),
389
- removeLabelIds: z.array(z.string()).optional().describe('Label IDs to remove'),
388
+ messageIds: coerceArray(z.string()).describe('Message IDs (up to 1000)'),
389
+ addLabelIds: coerceArray(z.string()).optional().describe('Label IDs to add'),
390
+ removeLabelIds: coerceArray(z.string()).optional().describe('Label IDs to remove'),
390
391
  },
391
392
  }, async ({ account, messageIds, addLabelIds, removeLabelIds }) => {
392
393
  try {
@@ -412,7 +413,7 @@ export function registerGmailTools(server) {
412
413
  description: 'Permanently delete multiple Gmail messages. Irreversible.',
413
414
  inputSchema: {
414
415
  account: accountEnum.describe('Google account alias'),
415
- messageIds: z.array(z.string()).describe('Message IDs (up to 1000)'),
416
+ messageIds: coerceArray(z.string()).describe('Message IDs (up to 1000)'),
416
417
  },
417
418
  }, async ({ account, messageIds }) => {
418
419
  try {
@@ -592,7 +593,7 @@ export function registerGmailTools(server) {
592
593
  startHistoryId: z.string().describe('History ID from a previous gmail_get_profile or gmail_read response'),
593
594
  maxResults: z.number().min(1).max(500).default(100).optional()
594
595
  .describe('Max results to return (default: 100)'),
595
- historyTypes: z.array(z.enum(['messageAdded', 'messageDeleted', 'labelAdded', 'labelRemoved'])).optional()
596
+ historyTypes: coerceArray(z.enum(['messageAdded', 'messageDeleted', 'labelAdded', 'labelRemoved'])).optional()
596
597
  .describe('Filter by history event types'),
597
598
  },
598
599
  }, async ({ account, startHistoryId, maxResults, historyTypes }) => {
@@ -635,13 +636,13 @@ export function registerGmailTools(server) {
635
636
  description: 'Enable or disable Gmail vacation responder with a custom message',
636
637
  inputSchema: {
637
638
  account: accountEnum.describe('Google account alias'),
638
- enableAutoReply: z.boolean().describe('Whether to enable the vacation responder'),
639
+ enableAutoReply: coerceBoolean.describe('Whether to enable the vacation responder'),
639
640
  responseSubject: z.string().optional().describe('Subject line for auto-reply'),
640
641
  responseBodyPlainText: z.string().optional().describe('Plain text body for auto-reply'),
641
642
  startTime: z.string().optional().describe('Start time as Unix timestamp in ms'),
642
643
  endTime: z.string().optional().describe('End time as Unix timestamp in ms'),
643
- restrictToContacts: z.boolean().optional().describe('Only reply to contacts (default: false)'),
644
- restrictToDomain: z.boolean().optional().describe('Only reply to same domain (default: false)'),
644
+ restrictToContacts: coerceBoolean.optional().describe('Only reply to contacts (default: false)'),
645
+ restrictToDomain: coerceBoolean.optional().describe('Only reply to same domain (default: false)'),
645
646
  },
646
647
  }, async ({ account, enableAutoReply, responseSubject, responseBodyPlainText, startTime, endTime, restrictToContacts, restrictToDomain }) => {
647
648
  try {
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerMeetTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerMeetTools(server: ToolRegistry): void;
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerSearchConsoleTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerSearchConsoleTools(server: ToolRegistry): void;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceArray, coerceJson } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -169,18 +170,18 @@ export function registerSearchConsoleTools(server) {
169
170
  siteUrl: z.string().describe('Site URL (e.g. "https://example.com/" or "sc-domain:example.com")'),
170
171
  startDate: z.string().describe('Start date (YYYY-MM-DD). Data is available starting ~3 days ago.'),
171
172
  endDate: z.string().describe('End date (YYYY-MM-DD)'),
172
- dimensions: z.array(z.enum(['query', 'page', 'country', 'device', 'searchAppearance', 'date'])).optional()
173
+ dimensions: coerceArray(z.enum(['query', 'page', 'country', 'device', 'searchAppearance', 'date'])).optional()
173
174
  .describe('Dimensions to group by. Common: ["query", "page"], ["date"], ["query", "date"]'),
174
175
  type: z.enum(['web', 'image', 'video', 'news', 'discover', 'googleNews']).optional()
175
176
  .describe('Search type filter (default: web)'),
176
- dimensionFilterGroups: z.array(z.object({
177
+ dimensionFilterGroups: coerceJson(z.array(z.object({
177
178
  groupType: z.enum(['and']).optional(),
178
179
  filters: z.array(z.object({
179
180
  dimension: z.enum(['query', 'page', 'country', 'device', 'searchAppearance']),
180
181
  operator: z.enum(['contains', 'equals', 'notContains', 'notEquals', 'includingRegex', 'excludingRegex']),
181
182
  expression: z.string(),
182
183
  })),
183
- })).optional()
184
+ }))).optional()
184
185
  .describe('Filters to narrow results. Example: filter by page containing "/blog/" or query containing "keyword"'),
185
186
  rowLimit: z.number().min(1).max(25000).optional()
186
187
  .describe('Max rows to return (default: 1000, max: 25000)'),
@@ -1,5 +1,5 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerSheetsTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerSheetsTools(server: ToolRegistry): void;
3
3
  export declare function buildCellFormat(input: {
4
4
  backgroundColor?: {
5
5
  red?: number;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -10,7 +11,7 @@ export function registerSheetsTools(server) {
10
11
  inputSchema: {
11
12
  account: accountEnum.describe('Google account alias'),
12
13
  title: z.string().describe('Spreadsheet title'),
13
- sheetTitles: z.array(z.string()).optional()
14
+ sheetTitles: coerceArray(z.string()).optional()
14
15
  .describe('Optional tab/sheet names (default: one sheet named "Sheet1")'),
15
16
  },
16
17
  }, async ({ account, title, sheetTitles }) => {
@@ -108,7 +109,7 @@ export function registerSheetsTools(server) {
108
109
  account: accountEnum.describe('Google account alias'),
109
110
  spreadsheetId: z.string().describe('Spreadsheet ID'),
110
111
  range: z.string().describe('A1 notation target range, e.g. "Sheet1!A1:C3"'),
111
- values: z.array(z.array(z.any())).describe('2D array of values (rows x columns)'),
112
+ values: coerceJson(z.array(z.array(z.any()))).describe('2D array of values (rows x columns)'),
112
113
  valueInputOption: z.enum(['RAW', 'USER_ENTERED']).default('USER_ENTERED').optional()
113
114
  .describe('How to interpret values: RAW (literal) or USER_ENTERED (formulas/dates parsed). Default: USER_ENTERED'),
114
115
  },
@@ -141,7 +142,7 @@ export function registerSheetsTools(server) {
141
142
  account: accountEnum.describe('Google account alias'),
142
143
  spreadsheetId: z.string().describe('Spreadsheet ID'),
143
144
  range: z.string().describe('A1 notation range to search for the table (e.g. "Sheet1")'),
144
- values: z.array(z.array(z.any())).describe('2D array of rows to append'),
145
+ values: coerceJson(z.array(z.array(z.any()))).describe('2D array of rows to append'),
145
146
  valueInputOption: z.enum(['RAW', 'USER_ENTERED']).default('USER_ENTERED').optional()
146
147
  .describe('How to interpret values. Default: USER_ENTERED'),
147
148
  },
@@ -200,7 +201,7 @@ export function registerSheetsTools(server) {
200
201
  inputSchema: {
201
202
  account: accountEnum.describe('Google account alias'),
202
203
  spreadsheetId: z.string().describe('Spreadsheet ID'),
203
- ranges: z.array(z.string()).describe('Array of A1 notation ranges'),
204
+ ranges: coerceArray(z.string()).describe('Array of A1 notation ranges'),
204
205
  valueRenderOption: z.enum(['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA'])
205
206
  .default('FORMATTED_VALUE').optional()
206
207
  .describe('How to render values. Default: FORMATTED_VALUE'),
@@ -231,10 +232,10 @@ export function registerSheetsTools(server) {
231
232
  inputSchema: {
232
233
  account: accountEnum.describe('Google account alias'),
233
234
  spreadsheetId: z.string().describe('Spreadsheet ID'),
234
- data: z.array(z.object({
235
+ data: coerceJson(z.array(z.object({
235
236
  range: z.string().describe('A1 notation range'),
236
- values: z.array(z.array(z.any())).describe('2D array of values'),
237
- })).describe('Array of { range, values } objects to write'),
237
+ values: coerceJson(z.array(z.array(z.any()))).describe('2D array of values'),
238
+ }))).describe('Array of { range, values } objects to write'),
238
239
  valueInputOption: z.enum(['RAW', 'USER_ENTERED']).default('USER_ENTERED').optional()
239
240
  .describe('How to interpret values. Default: USER_ENTERED'),
240
241
  },
@@ -373,7 +374,7 @@ export function registerSheetsTools(server) {
373
374
  sheetId: z.number().describe('Sheet ID'),
374
375
  title: z.string().optional().describe('New tab title'),
375
376
  index: z.number().optional().describe('New position among tabs'),
376
- hidden: z.boolean().optional().describe('Hide the tab from the UI'),
377
+ hidden: coerceBoolean.optional().describe('Hide the tab from the UI'),
377
378
  tabColor: rgbColorSchema.optional().describe('Tab color (RGB 0..1)'),
378
379
  frozenRowCount: z.number().min(0).optional().describe('Number of frozen header rows'),
379
380
  frozenColumnCount: z.number().min(0).optional().describe('Number of frozen header columns'),
@@ -460,10 +461,10 @@ export function registerSheetsTools(server) {
460
461
  backgroundColor: rgbColorSchema.optional().describe('Cell background (RGB 0..1)'),
461
462
  textFormat: z.object({
462
463
  foregroundColor: rgbColorSchema.optional(),
463
- bold: z.boolean().optional(),
464
- italic: z.boolean().optional(),
465
- underline: z.boolean().optional(),
466
- strikethrough: z.boolean().optional(),
464
+ bold: coerceBoolean.optional(),
465
+ italic: coerceBoolean.optional(),
466
+ underline: coerceBoolean.optional(),
467
+ strikethrough: coerceBoolean.optional(),
467
468
  fontFamily: z.string().optional(),
468
469
  fontSize: z.number().min(1).optional(),
469
470
  }).optional(),
@@ -609,19 +610,19 @@ export function registerSheetsTools(server) {
609
610
  inputSchema: {
610
611
  account: accountEnum.describe('Google account alias'),
611
612
  spreadsheetId: z.string().describe('Spreadsheet ID'),
612
- ranges: z.array(gridRangeSchema).describe('Ranges the rule applies to'),
613
+ ranges: coerceJson(z.array(gridRangeSchema)).describe('Ranges the rule applies to'),
613
614
  index: z.number().min(0).optional().describe('Where in the rule order to insert (0 = highest priority)'),
614
615
  booleanRule: z.object({
615
616
  conditionType: z.string().describe(BOOLEAN_CONDITION_TYPE_DESC),
616
- conditionValues: z.array(z.string()).optional().describe('Values for the condition (e.g. ["100"] for NUMBER_GREATER, or ["=A1>10"] for CUSTOM_FORMULA)'),
617
+ conditionValues: coerceArray(z.string()).optional().describe('Values for the condition (e.g. ["100"] for NUMBER_GREATER, or ["=A1>10"] for CUSTOM_FORMULA)'),
617
618
  format: z.object({
618
619
  backgroundColor: rgbColorSchema.optional(),
619
620
  textFormat: z.object({
620
621
  foregroundColor: rgbColorSchema.optional(),
621
- bold: z.boolean().optional(),
622
- italic: z.boolean().optional(),
623
- strikethrough: z.boolean().optional(),
624
- underline: z.boolean().optional(),
622
+ bold: coerceBoolean.optional(),
623
+ italic: coerceBoolean.optional(),
624
+ strikethrough: coerceBoolean.optional(),
625
+ underline: coerceBoolean.optional(),
625
626
  }).optional(),
626
627
  }).describe('Format to apply when condition is true. Conditional formatting only supports bold/italic/strikethrough/underline/foregroundColor/backgroundColor.'),
627
628
  }).optional(),
@@ -699,7 +700,7 @@ export function registerSheetsTools(server) {
699
700
  account: accountEnum.describe('Google account alias'),
700
701
  spreadsheetId: z.string().describe('Spreadsheet ID'),
701
702
  range: gridRangeSchema,
702
- sortSpecs: z.array(sortSpecSchema).describe('One spec per sort column (in priority order)'),
703
+ sortSpecs: coerceJson(z.array(sortSpecSchema)).describe('One spec per sort column (in priority order)'),
703
704
  },
704
705
  }, async ({ account, spreadsheetId, range, sortSpecs }) => {
705
706
  try {
@@ -725,13 +726,13 @@ export function registerSheetsTools(server) {
725
726
  account: accountEnum.describe('Google account alias'),
726
727
  spreadsheetId: z.string().describe('Spreadsheet ID'),
727
728
  range: gridRangeSchema,
728
- sortSpecs: z.array(sortSpecSchema).optional(),
729
- filterSpecs: z.array(z.object({
729
+ sortSpecs: coerceJson(z.array(sortSpecSchema)).optional(),
730
+ filterSpecs: coerceJson(z.array(z.object({
730
731
  columnIndex: z.number().min(0),
731
- hiddenValues: z.array(z.string()).optional().describe('Values to hide in this column'),
732
+ hiddenValues: coerceArray(z.string()).optional().describe('Values to hide in this column'),
732
733
  conditionType: z.string().optional().describe(BOOLEAN_CONDITION_TYPE_DESC),
733
- conditionValues: z.array(z.string()).optional(),
734
- })).optional(),
734
+ conditionValues: coerceArray(z.string()).optional(),
735
+ }))).optional(),
735
736
  },
736
737
  }, async ({ account, spreadsheetId, range, sortSpecs, filterSpecs }) => {
737
738
  try {
@@ -801,10 +802,10 @@ export function registerSheetsTools(server) {
801
802
  scope: z.enum(['allSheets', 'sheet', 'range']).describe('Search scope'),
802
803
  sheetId: z.number().optional().describe('Required when scope=sheet'),
803
804
  range: gridRangeSchema.optional().describe('Required when scope=range'),
804
- matchCase: z.boolean().optional(),
805
- matchEntireCell: z.boolean().optional(),
806
- searchByRegex: z.boolean().optional(),
807
- includeFormulas: z.boolean().optional(),
805
+ matchCase: coerceBoolean.optional(),
806
+ matchEntireCell: coerceBoolean.optional(),
807
+ searchByRegex: coerceBoolean.optional(),
808
+ includeFormulas: coerceBoolean.optional(),
808
809
  },
809
810
  }, async ({ account, spreadsheetId, find, replacement, scope, sheetId, range, matchCase, matchEntireCell, searchByRegex, includeFormulas }) => {
810
811
  try {
@@ -892,7 +893,7 @@ export function registerSheetsTools(server) {
892
893
  dimension: z.enum(['ROWS', 'COLUMNS']),
893
894
  startIndex: z.number().min(0).describe('0-based, inclusive'),
894
895
  endIndex: z.number().min(1).describe('0-based, exclusive'),
895
- inheritFromBefore: z.boolean().optional(),
896
+ inheritFromBefore: coerceBoolean.optional(),
896
897
  },
897
898
  }, async ({ account, spreadsheetId, sheetId, dimension, startIndex, endIndex, inheritFromBefore }) => {
898
899
  try {
@@ -957,10 +958,10 @@ export function registerSheetsTools(server) {
957
958
  spreadsheetId: z.string().describe('Spreadsheet ID'),
958
959
  range: gridRangeSchema,
959
960
  conditionType: z.string().describe(BOOLEAN_CONDITION_TYPE_DESC),
960
- conditionValues: z.array(z.string()).optional().describe('Values per condition type (list items for ONE_OF_LIST, range A1 for ONE_OF_RANGE, etc.)'),
961
+ conditionValues: coerceArray(z.string()).optional().describe('Values per condition type (list items for ONE_OF_LIST, range A1 for ONE_OF_RANGE, etc.)'),
961
962
  inputMessage: z.string().optional().describe('Tooltip shown when cell is selected'),
962
- strict: z.boolean().optional().describe('Reject invalid input (default: false = warning only)'),
963
- showCustomUi: z.boolean().optional().describe('Show dropdown UI for list-type validations'),
963
+ strict: coerceBoolean.optional().describe('Reject invalid input (default: false = warning only)'),
964
+ showCustomUi: coerceBoolean.optional().describe('Show dropdown UI for list-type validations'),
964
965
  },
965
966
  }, async ({ account, spreadsheetId, range, conditionType, conditionValues, inputMessage, strict, showCustomUi }) => {
966
967
  try {
@@ -1048,7 +1049,7 @@ export function registerSheetsTools(server) {
1048
1049
  inputSchema: {
1049
1050
  account: accountEnum.describe('Google account alias'),
1050
1051
  spreadsheetId: z.string().describe('Spreadsheet ID'),
1051
- ranges: z.array(z.string()).describe('Array of A1 notation ranges'),
1052
+ ranges: coerceArray(z.string()).describe('Array of A1 notation ranges'),
1052
1053
  },
1053
1054
  }, async ({ account, spreadsheetId, ranges }) => {
1054
1055
  try {
@@ -1073,10 +1074,10 @@ export function registerSheetsTools(server) {
1073
1074
  inputSchema: {
1074
1075
  account: accountEnum.describe('Google account alias'),
1075
1076
  spreadsheetId: z.string().describe('Spreadsheet ID'),
1076
- requests: z.array(z.record(z.string(), z.any())).describe('Array of Request objects. Each object has exactly one key (the request type) like {repeatCell: {...}}, {addChart: {...}}, {updateBanding: {...}}, etc.'),
1077
- includeSpreadsheetInResponse: z.boolean().optional(),
1078
- responseRanges: z.array(z.string()).optional(),
1079
- responseIncludeGridData: z.boolean().optional(),
1077
+ requests: coerceJson(z.array(z.record(z.string(), z.any()))).describe('Array of Request objects. Each object has exactly one key (the request type) like {repeatCell: {...}}, {addChart: {...}}, {updateBanding: {...}}, etc.'),
1078
+ includeSpreadsheetInResponse: coerceBoolean.optional(),
1079
+ responseRanges: coerceArray(z.string()).optional(),
1080
+ responseIncludeGridData: coerceBoolean.optional(),
1080
1081
  },
1081
1082
  }, async ({ account, spreadsheetId, requests, includeSpreadsheetInResponse, responseRanges, responseIncludeGridData }) => {
1082
1083
  try {
@@ -1,2 +1,2 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function registerTasksTools(server: McpServer): void;
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export declare function registerTasksTools(server: ToolRegistry): void;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coerceBoolean } from './_coerce.js';
2
3
  import { google } from 'googleapis';
3
4
  import { ACCOUNTS } from '../accounts.js';
4
5
  import { getClient } from '../client.js';
@@ -119,10 +120,10 @@ export function registerTasksTools(server) {
119
120
  tasklistId: z.string().describe('Tasklist ID'),
120
121
  maxResults: z.number().min(1).max(100).optional(),
121
122
  pageToken: z.string().optional(),
122
- showCompleted: z.boolean().optional().describe('Include completed tasks (default: true)'),
123
- showDeleted: z.boolean().optional(),
124
- showHidden: z.boolean().optional(),
125
- showAssigned: z.boolean().optional(),
123
+ showCompleted: coerceBoolean.optional().describe('Include completed tasks (default: true)'),
124
+ showDeleted: coerceBoolean.optional(),
125
+ showHidden: coerceBoolean.optional(),
126
+ showAssigned: coerceBoolean.optional(),
126
127
  completedMax: z.string().optional().describe('RFC 3339 upper bound on completion date'),
127
128
  completedMin: z.string().optional(),
128
129
  dueMax: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "4.4.0",
3
+ "version": "4.5.0-alpha.2",
4
4
  "description": "MCP server for Gmail, Google Drive, Calendar, Sheets, Docs, and Contacts with multi-account support",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,6 +44,7 @@
44
44
  "prepublishOnly": "npm run build",
45
45
  "watch": "tsc --watch",
46
46
  "auth": "node dist/index.js auth --account",
47
+ "migrate-tokens": "node dist/index.js migrate-tokens",
47
48
  "lint": "eslint .",
48
49
  "typecheck": "tsc --noEmit",
49
50
  "test": "vitest run",