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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,6 +82,20 @@ The server does **not** dump ~170 tool schemas into your model's context. At boo
82
82
 
83
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.
84
84
 
85
+ ## Multi-account: fan out one call across accounts
86
+
87
+ Every read tool's `account` argument also accepts `"*"` (all configured accounts) or a CSV subset (`"work,personal"`). The server runs the call once per account (bounded concurrency) and returns per-account results — one account failing never hides the others:
88
+
89
+ ```jsonc
90
+ { "results": [
91
+ { "account": "work", "ok": true, "data": { /* … */ } },
92
+ { "account": "personal", "ok": false, "error": { "error": "auth_required", /* … */ } }
93
+ ],
94
+ "partial": true }
95
+ ```
96
+
97
+ Fan-out is **read-only by design** (write tools take exactly one account), and the three read tools that save files to disk (`drive_download`, `drive_export`, `gmail_download_attachment`) are excluded so parallel accounts can't clobber the same path. `account_list` shows what's configured: alias, email, token health (`ok` / `expired_refreshable` / `needs_reauth` / `missing` / `decrypt_error`), and granted-vs-configured scopes — without ever touching token values.
98
+
85
99
  ## Escape hatch: any Workspace REST method
86
100
 
87
101
  Two eager tools cover everything the curated set doesn't: `google_api_search` finds any method in Google's API Discovery index (including APIs with no dedicated tools here, like Slides), and `google_api_call` invokes a method by its Discovery id (`drive.revisions.list`, `slides.presentations.create`, …) with path/query params and a JSON body. Calls run through your account's OAuth client and the **same write-control policy** as named tools: the read/create/update/delete class is derived from the method's HTTP verb and name (POST deletes like `batchDelete`/`clear` count as deletes), and policy globs/`GOOGLE_TOOLSETS` match the same service names as named tools (`people` counts as `contacts`, `admin_*` as `admin`; `slides`/`driveactivity`/`drivelabels`/`groupssettings` have no named service and are always available).
package/dist/accounts.js CHANGED
@@ -40,6 +40,9 @@ function parseAccounts() {
40
40
  if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
41
41
  throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
42
42
  }
43
+ if (aliases.includes(alias)) {
44
+ throw new Error(`Duplicate alias "${alias}" in GOOGLE_ACCOUNTS. Each alias must be unique.`);
45
+ }
43
46
  aliases.push(alias);
44
47
  configs[alias] = {
45
48
  email,
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ export declare const CSV_RE: RegExp;
3
+ export declare function fanoutAccountField(description: string): z.ZodType;
4
+ export type AccountSelector = {
5
+ ok: true;
6
+ fanout: boolean;
7
+ aliases: string[];
8
+ } | {
9
+ ok: false;
10
+ invalid: string[];
11
+ };
12
+ export declare function parseAccountSelector(value: string, accounts?: readonly string[]): AccountSelector;
13
+ export declare function invalidAccountsResult(invalid: string[], accounts?: readonly string[]): {
14
+ content: {
15
+ type: "text";
16
+ text: string;
17
+ }[];
18
+ isError: true;
19
+ };
20
+ export interface FanoutEntry {
21
+ account: string;
22
+ ok: boolean;
23
+ data?: unknown;
24
+ error?: unknown;
25
+ }
26
+ export declare function runFanout(handler: (...a: unknown[]) => unknown, args: unknown[], aliases: string[]): Promise<{
27
+ content: {
28
+ type: 'text';
29
+ text: string;
30
+ }[];
31
+ isError?: true;
32
+ }>;
package/dist/fanout.js ADDED
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+ import { ACCOUNTS } from './accounts.js';
3
+ export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
4
+ const FANOUT_CONCURRENCY = 5;
5
+ export function fanoutAccountField(description) {
6
+ const csvExample = ACCOUNTS.length > 1 ? `; or a CSV subset like "${ACCOUNTS.slice(0, 2).join(',')}"` : '';
7
+ return z
8
+ .union([z.enum([...ACCOUNTS, '*']), z.string().regex(CSV_RE)])
9
+ .describe(`${description}; "*" = all accounts${csvExample}`);
10
+ }
11
+ export function parseAccountSelector(value, accounts = ACCOUNTS) {
12
+ if (value === '*')
13
+ return { ok: true, fanout: true, aliases: [...accounts] };
14
+ if (!value.includes(','))
15
+ return { ok: true, fanout: false, aliases: [value] };
16
+ const seen = new Set();
17
+ const aliases = [];
18
+ const invalid = [];
19
+ for (const token of value.split(',').map((t) => t.trim()).filter(Boolean)) {
20
+ if (!accounts.includes(token)) {
21
+ invalid.push(token);
22
+ }
23
+ else if (!seen.has(token)) {
24
+ seen.add(token);
25
+ aliases.push(token);
26
+ }
27
+ }
28
+ if (invalid.length > 0)
29
+ return { ok: false, invalid };
30
+ return { ok: true, fanout: aliases.length > 1, aliases };
31
+ }
32
+ export function invalidAccountsResult(invalid, accounts = ACCOUNTS) {
33
+ return {
34
+ content: [
35
+ {
36
+ type: 'text',
37
+ text: JSON.stringify({
38
+ error: 'validation_error',
39
+ message: `Unknown account alias(es): ${invalid.join(', ')}.`,
40
+ hint: `Valid aliases: ${accounts.join(', ')}; or "*" for all accounts.`,
41
+ retriable: false,
42
+ }),
43
+ },
44
+ ],
45
+ isError: true,
46
+ };
47
+ }
48
+ function parsePayload(result) {
49
+ const text = result?.content?.[0]?.text;
50
+ if (typeof text !== 'string')
51
+ return null;
52
+ try {
53
+ return JSON.parse(text);
54
+ }
55
+ catch {
56
+ return text;
57
+ }
58
+ }
59
+ export async function runFanout(handler, args, aliases) {
60
+ const results = new Array(aliases.length);
61
+ let next = 0;
62
+ const worker = async () => {
63
+ while (next < aliases.length) {
64
+ const i = next++;
65
+ const account = aliases[i];
66
+ try {
67
+ const res = (await handler({ ...args[0], account }, ...args.slice(1)));
68
+ const payload = parsePayload(res);
69
+ results[i] = res?.isError ? { account, ok: false, error: payload } : { account, ok: true, data: payload };
70
+ }
71
+ catch (err) {
72
+ results[i] = {
73
+ account,
74
+ ok: false,
75
+ error: { error: 'internal', message: err.message, retriable: false, account },
76
+ };
77
+ }
78
+ }
79
+ };
80
+ await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
81
+ const failed = results.filter((r) => !r.ok).length;
82
+ const body = { results, partial: failed > 0 };
83
+ const out = { content: [{ type: 'text', text: JSON.stringify(body) }] };
84
+ return failed === results.length ? { ...out, isError: true } : out;
85
+ }
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { getOptionalBundles, getAdminAccounts } from './auth.js';
21
21
  import { ToolRegistry } from './registry.js';
22
22
  import { registerDiscoverTools } from './discover.js';
23
23
  import { registerEscapeTools } from './tools/google-api.js';
24
+ import { registerAccountTools } from './tools/accounts-tool.js';
24
25
  import { getToolsets, toolsetEnabled } from './toolsets.js';
25
26
  import { resolvePolicy, isAllowed, describePolicy } from './write-control.js';
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -69,6 +70,7 @@ function buildRegistry(server, policy) {
69
70
  }
70
71
  registerDiscoverTools(registry, policy);
71
72
  registerEscapeTools(registry, policy);
73
+ registerAccountTools(registry);
72
74
  return registry;
73
75
  }
74
76
  async function main() {
package/dist/registry.js CHANGED
@@ -2,12 +2,19 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { z } from 'zod';
3
3
  import { isAllowed, writeDisabledResult } from './write-control.js';
4
4
  import { compactResult, trimEnabled } from './trim.js';
5
+ import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
5
6
  const CUD_OVERRIDES = {
6
7
  drive_untrash: 'update',
7
8
  };
8
9
  const SERVICE_OVERRIDES = {
9
10
  reports_activities_list: 'admin',
10
11
  };
12
+ // Read tools that write to the local filesystem: fanning the same savePath
13
+ // across accounts would clobber (last write wins).
14
+ const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
15
+ function isAccountEnum(field) {
16
+ return field?._zod?.def?.type === 'enum';
17
+ }
11
18
  const DELETE_VERB = /(^|_)(delete|remove|trash|clear|empty)(_|$)/;
12
19
  const CREATE_VERB = /(^|_)(create|add|insert|send|upload|copy|import|append|submit|duplicate|share|quick)(_|$)/;
13
20
  const UPDATE_VERB = /(^|_)(update|patch|modify|set|move|write|format|merge|unmerge|sort|replace|resize|publish|resolve)(_|$)/;
@@ -42,24 +49,41 @@ export class ToolRegistry {
42
49
  destructiveHint: cud === 'delete' || cud === 'update',
43
50
  ...config.annotations,
44
51
  };
52
+ // Fan-out is read-only and never applies to meta tools: google_api_call's
53
+ // registry cud is "read" but it executes arbitrary writes.
54
+ let inputShape = config.inputSchema ?? {};
55
+ let baseHandler = handler;
56
+ if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
57
+ const description = inputShape.account.description ?? 'Google account alias';
58
+ inputShape = { ...inputShape, account: fanoutAccountField(description) };
59
+ baseHandler = async (...args) => {
60
+ const first = args[0];
61
+ const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '');
62
+ if (!parsed.ok)
63
+ return invalidAccountsResult(parsed.invalid);
64
+ if (!parsed.fanout)
65
+ return handler({ ...first, account: parsed.aliases[0] }, ...args.slice(1));
66
+ return runFanout(handler, args, parsed.aliases);
67
+ };
68
+ }
45
69
  this.tools.push({
46
70
  name,
47
71
  service,
48
72
  cud,
49
73
  description: config.description ?? '',
50
- inputShape: config.inputSchema ?? {},
74
+ inputShape,
51
75
  annotations,
52
76
  meta: this.registeringMeta,
53
77
  });
54
78
  const guarded = cud === 'read'
55
- ? handler
79
+ ? baseHandler
56
80
  : (...args) => isAllowed({ name, service, cud }, policy)
57
- ? handler(...args)
81
+ ? baseHandler(...args)
58
82
  : writeDisabledResult({ name, service, cud }, policy);
59
83
  const finalHandler = this.compactOutput
60
84
  ? async (...args) => compactResult(await guarded(...args))
61
85
  : guarded;
62
- return server.registerTool(name, { ...config, annotations }, finalHandler);
86
+ return server.registerTool(name, { ...config, inputSchema: inputShape, annotations }, finalHandler);
63
87
  });
64
88
  }
65
89
  registerMeta = ((name, config, handler) => {
@@ -0,0 +1,29 @@
1
+ import type { ToolRegistry } from '../registry.js';
2
+ export interface AccountHealthDeps {
3
+ hasToken: (alias: string) => boolean;
4
+ readToken: (alias: string) => {
5
+ expiry_date?: number;
6
+ refresh_token?: string;
7
+ scope?: string;
8
+ } | null;
9
+ fileExists: (p: string) => boolean;
10
+ now: () => number;
11
+ }
12
+ export type TokenStatus = 'ok' | 'expired_refreshable' | 'needs_reauth' | 'missing' | 'decrypt_error';
13
+ export interface AccountHealth {
14
+ alias: string;
15
+ email: string;
16
+ admin: boolean;
17
+ token: {
18
+ status: TokenStatus;
19
+ expiryDate?: string;
20
+ hint?: string;
21
+ };
22
+ scopes: {
23
+ configured: number;
24
+ granted: number;
25
+ missing: string[];
26
+ };
27
+ }
28
+ export declare function deriveAccountHealth(alias: string, deps?: AccountHealthDeps): AccountHealth;
29
+ export declare function registerAccountTools(registry: ToolRegistry, deps?: AccountHealthDeps): void;
@@ -0,0 +1,80 @@
1
+ import * as fs from 'node:fs';
2
+ import { ACCOUNTS, ACCOUNT_CONFIG } from '../accounts.js';
3
+ import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
4
+ import { hasToken, readToken } from '../token-store.js';
5
+ const DEFAULT_DEPS = {
6
+ hasToken,
7
+ readToken,
8
+ fileExists: fs.existsSync,
9
+ now: Date.now,
10
+ };
11
+ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
12
+ const config = ACCOUNT_CONFIG[alias];
13
+ const admin = getAdminAccounts().includes(alias);
14
+ const configured = resolveScopesForAccount(alias);
15
+ const base = { alias, email: config.email, admin };
16
+ const noScopes = { configured: configured.length, granted: 0, missing: configured };
17
+ if (!deps.hasToken(alias)) {
18
+ const legacy = deps.fileExists(config.tokenPath);
19
+ return {
20
+ ...base,
21
+ token: {
22
+ status: 'missing',
23
+ hint: legacy
24
+ ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
25
+ : `Run: npx mcp-google-multi auth --account ${alias}`,
26
+ },
27
+ scopes: noScopes,
28
+ };
29
+ }
30
+ let token;
31
+ try {
32
+ token = deps.readToken(alias);
33
+ }
34
+ catch {
35
+ return {
36
+ ...base,
37
+ token: { status: 'decrypt_error', hint: 'Wrong MASTER_KEY or corrupt token file — re-auth or restore the key.' },
38
+ scopes: noScopes,
39
+ };
40
+ }
41
+ const granted = typeof token?.scope === 'string' ? token.scope.split(' ').filter(Boolean) : [];
42
+ const missing = configured.filter((s) => !granted.includes(s));
43
+ const expiry = typeof token?.expiry_date === 'number' ? token.expiry_date : undefined;
44
+ const refreshable = typeof token?.refresh_token === 'string' && token.refresh_token.length > 0;
45
+ let status;
46
+ if (expiry !== undefined && expiry > deps.now())
47
+ status = 'ok';
48
+ else if (refreshable)
49
+ status = 'expired_refreshable';
50
+ else
51
+ status = 'needs_reauth';
52
+ return {
53
+ ...base,
54
+ token: {
55
+ status,
56
+ expiryDate: expiry !== undefined ? new Date(expiry).toISOString() : undefined,
57
+ hint: status === 'needs_reauth'
58
+ ? `Run: npx mcp-google-multi auth --account ${alias}`
59
+ : missing.length > 0
60
+ ? 'Re-auth to grant the missing scopes'
61
+ : undefined,
62
+ },
63
+ scopes: { configured: configured.length, granted: granted.length, missing },
64
+ };
65
+ }
66
+ export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
67
+ registry.registerMeta('account_list', {
68
+ description: 'List the configured Google accounts: alias, email, admin flag, token health ' +
69
+ '(ok / expired_refreshable / needs_reauth / missing / decrypt_error), and granted vs configured scopes. ' +
70
+ 'Use this to see which account aliases are available and healthy.',
71
+ inputSchema: {},
72
+ }, async () => ({
73
+ content: [
74
+ {
75
+ type: 'text',
76
+ text: JSON.stringify({ accounts: ACCOUNTS.map((alias) => deriveAccountHealth(alias, deps)) }),
77
+ },
78
+ ],
79
+ }));
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "5.1.0-alpha.3",
3
+ "version": "5.1.0-alpha.4",
4
4
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
5
5
  "type": "module",
6
6
  "license": "MIT",