mcp-google-multi 6.0.0-alpha.10 → 6.0.0-alpha.12

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
@@ -4,10 +4,10 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/mcp-google-multi?label=npm&color=cb3837)](https://www.npmjs.com/package/mcp-google-multi)
6
6
 
7
- - 🧰 **Exhaustive** — 874 tools across 28 services + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
7
+ - 🧰 **Exhaustive** — 940 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
8
8
  - 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
9
9
  - 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
10
- - 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI) → [remote setup](./docs/http-setup.md)
10
+ - 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI). Pull-and-up Docker Compose with optional automatic HTTPS → [remote setup](./docs/http-setup.md)
11
11
  - ✉️ **Built for real work** — send and read email in Markdown with attachments and one-call replies, an interactive setup wizard with a `doctor` self-check, and per-account scope profiles → [features tour](./docs/features.md)
12
12
 
13
13
  ## Quick setup
package/dist/api-probe.js CHANGED
@@ -17,6 +17,9 @@ export const API_PROBES = [
17
17
  { service: 'chat', api: 'chat', url: 'https://chat.googleapis.com/v1/spaces?pageSize=1', scopePrefixes: [`${P}chat.`] },
18
18
  { service: 'meet', api: 'meet', url: 'https://meet.googleapis.com/v2/conferenceRecords?pageSize=1', scopePrefixes: [`${P}meetings.`] },
19
19
  { service: 'forms', api: 'forms', url: `https://forms.googleapis.com/v1/forms/${BOGUS_ID}`, scopePrefixes: [`${P}forms.`], notFoundMeansEnabled: true },
20
+ // Probes the Admin API only: the Data API has no no-arg read (every call
21
+ // needs a property id), so its enablement surfaces on first report instead.
22
+ { service: 'analytics', api: 'analyticsadmin', url: 'https://analyticsadmin.googleapis.com/v1beta/accountSummaries?pageSize=1', scopePrefixes: [`${P}analytics`] },
20
23
  ];
21
24
  export function planProbes(granted, probes = API_PROBES) {
22
25
  return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
@@ -1,13 +1,19 @@
1
1
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
2
  import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
3
3
  export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
4
- export declare function normalizeCallArguments(shapeKeys: ReadonlySet<string>, args: Record<string, unknown>): {
4
+ /** Declared scalar kind per schema key; drives value coercion on RENAMED keys
5
+ * only. Clients string-encode values for keys absent from the advertised
6
+ * schema, so a renamed key almost always arrives as a string — without
7
+ * coercion the rename would just move the -32602 from the key to the value. */
8
+ export type ArgKind = 'number' | 'boolean' | 'other';
9
+ export type ArgShape = ReadonlyMap<string, ArgKind>;
10
+ export declare function normalizeCallArguments(shape: ArgShape, args: Record<string, unknown>): {
5
11
  args: Record<string, unknown>;
6
12
  renamed: [string, string][];
7
13
  };
8
- export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ReadonlySet<string> | undefined, log?: (line: string) => void): JSONRPCMessage;
14
+ export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void): JSONRPCMessage;
9
15
  /** Wrap a server-side transport so tools/call argument keys are normalized
10
16
  * before the SDK validates them. The Protocol assigns `onmessage` during
11
17
  * connect(); the interceptor lives in that setter, so the wrapper works
12
18
  * identically for stdio and (per-request, stateless) HTTP transports. */
13
- export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ReadonlySet<string> | undefined, log?: (line: string) => void): Transport;
19
+ export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void): Transport;
@@ -10,16 +10,26 @@ export function argNormalizationEnabled(env = process.env) {
10
10
  return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
11
11
  }
12
12
  const snakeToCamel = (key) => key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
13
- export function normalizeCallArguments(shapeKeys, args) {
13
+ function coerceRenamedValue(value, kind) {
14
+ if (typeof value !== 'string')
15
+ return value;
16
+ const v = value.trim();
17
+ if (kind === 'number' && /^-?\d+(\.\d+)?$/.test(v))
18
+ return Number(v);
19
+ if (kind === 'boolean' && /^(true|false)$/i.test(v))
20
+ return v.toLowerCase() === 'true';
21
+ return value;
22
+ }
23
+ export function normalizeCallArguments(shape, args) {
14
24
  const renamed = [];
15
25
  let out;
16
26
  for (const key of Object.keys(args)) {
17
- if (shapeKeys.has(key) || !key.includes('_'))
27
+ if (shape.has(key) || !key.includes('_'))
18
28
  continue;
19
29
  const camel = snakeToCamel(key);
20
- if (camel !== key && shapeKeys.has(camel) && !(camel in args)) {
30
+ if (camel !== key && shape.has(camel) && !(camel in args)) {
21
31
  out ??= { ...args };
22
- out[camel] = out[key];
32
+ out[camel] = coerceRenamedValue(out[key], shape.get(camel));
23
33
  delete out[key];
24
34
  renamed.push([key, camel]);
25
35
  }
@@ -20,6 +20,8 @@ export const WORKSPACE_APIS = {
20
20
  admin_reports: { id: 'admin', version: 'reports_v1' },
21
21
  admin_datatransfer: { id: 'admin', version: 'datatransfer_v1' },
22
22
  groupssettings: { id: 'groupssettings', version: 'v1' },
23
+ analyticsadmin: { id: 'analyticsadmin', version: 'v1beta' },
24
+ analyticsdata: { id: 'analyticsdata', version: 'v1beta' },
23
25
  appsmarket: { id: 'appsmarket', version: 'v2' },
24
26
  classroom: { id: 'classroom', version: 'v1' },
25
27
  cloudidentity: { id: 'cloudidentity', version: 'v1' },
@@ -148,9 +150,13 @@ export async function loadMethodIndex(api, deps = {}) {
148
150
  export function clearDiscoveryMemoryCache() {
149
151
  memoryCache.clear();
150
152
  }
151
- const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect)/i;
153
+ // GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
154
+ // and check* predicates are POSTs purely for the request-body size — reads.
155
+ const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|(batch)?run\w*report)/i;
152
156
  const POST_UPDATE_VERB = /^(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format)/i;
153
- const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout)/i;
157
+ // archive sits with the deletes: in GA4 archiving a custom dimension/metric is
158
+ // permanent, so the most restrictive write class is the safe classification.
159
+ const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive)/i;
154
160
  export function cudFromMethod(method) {
155
161
  switch (method.httpMethod) {
156
162
  case 'GET':
@@ -1,6 +1,7 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import type { HttpConfig } from './http-config.js';
4
+ import { type ArgShape } from './arg-normalize.js';
4
5
  export type AuthOutcome = {
5
6
  ok: true;
6
7
  } | {
@@ -29,7 +30,7 @@ export interface HttpHostOptions {
29
30
  * shared lock instead of wedging the transport (default 120s). */
30
31
  dispatchTimeoutMs?: number;
31
32
  /** tools/call argument-key normalization lookup (arg-normalize.ts); absent = off. */
32
- argShapeFor?: (tool: string) => ReadonlySet<string> | undefined;
33
+ argShapeFor?: (tool: string) => ArgShape | undefined;
33
34
  }
34
35
  export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
35
36
  /** Front guard: an Origin, if present, must be allowlisted; a Host must be
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { type Policy } from './write-control.js';
4
+ import type { ArgShape } from './arg-normalize.js';
4
5
  export type Cud = 'read' | 'create' | 'update' | 'delete';
5
6
  export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
6
7
  export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
@@ -49,8 +50,9 @@ export declare class ToolRegistry {
49
50
  constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
50
51
  registerMeta: McpServer['registerTool'];
51
52
  services(): string[];
52
- /** Declared input-schema keys for one tool (tools/call arg normalization). */
53
- argShape(name: string): ReadonlySet<string> | undefined;
53
+ /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
54
+ * normalization; the kind drives value coercion on renamed keys). */
55
+ argShape(name: string): ArgShape | undefined;
54
56
  catalog(service: string, query?: string): CatalogOperation[];
55
57
  reveal(service: string): boolean;
56
58
  /** discover_all: advertise the full curated set at once. Idempotent. */
package/dist/registry.js CHANGED
@@ -38,6 +38,23 @@ const SERVICE_OVERRIDES = {
38
38
  };
39
39
  // read tools that write local files — same savePath fanned across accounts would clobber
40
40
  const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
41
+ /** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
42
+ function scalarKindOf(field) {
43
+ let cur = field;
44
+ for (let i = 0; i < 4 && cur?._zod?.def; i++) {
45
+ const def = cur._zod.def;
46
+ if (def.type === 'number')
47
+ return 'number';
48
+ if (def.type === 'boolean')
49
+ return 'boolean';
50
+ if (def.type === 'optional' || def.type === 'default' || def.type === 'nullable') {
51
+ cur = def.innerType;
52
+ continue;
53
+ }
54
+ return 'other';
55
+ }
56
+ return 'other';
57
+ }
41
58
  function isAccountEnum(field) {
42
59
  const def = field?._zod?.def;
43
60
  if (!def)
@@ -193,7 +210,8 @@ export class ToolRegistry {
193
210
  services() {
194
211
  return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
195
212
  }
196
- /** Declared input-schema keys for one tool (tools/call arg normalization). */
213
+ /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
214
+ * normalization; the kind drives value coercion on renamed keys). */
197
215
  argShape(name) {
198
216
  const cached = this.argShapeCache.get(name);
199
217
  if (cached)
@@ -201,9 +219,11 @@ export class ToolRegistry {
201
219
  const entry = this.tools.find((t) => t.name === name);
202
220
  if (!entry)
203
221
  return undefined;
204
- const keys = new Set(Object.keys(entry.inputShape));
205
- this.argShapeCache.set(name, keys);
206
- return keys;
222
+ const shape = new Map();
223
+ for (const [key, field] of Object.entries(entry.inputShape))
224
+ shape.set(key, scalarKindOf(field));
225
+ this.argShapeCache.set(name, shape);
226
+ return shape;
207
227
  }
208
228
  catalog(service, query) {
209
229
  const q = query?.trim().toLowerCase();
@@ -108,6 +108,13 @@ export const BUNDLE_CATALOG = {
108
108
  description: 'Read Gmail Postmaster Tools deliverability data.',
109
109
  risk: 'low',
110
110
  },
111
+ // Read-only by design: GA4 admin writes need analytics.edit, which ships as
112
+ // a separate opt-in bundle only if real demand appears (plan 6.0.0 GA-1).
113
+ analytics: {
114
+ scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
115
+ description: 'Read Google Analytics (GA4): run reports and inspect accounts, properties and their configuration.',
116
+ risk: 'low',
117
+ },
111
118
  groupssettings: {
112
119
  scopes: ['https://www.googleapis.com/auth/apps.groups.settings'],
113
120
  description: 'Change Google Groups settings for the domain.',
package/dist/services.js CHANGED
@@ -34,6 +34,7 @@ const bundleGate = (name) => ({
34
34
  hint: `add "${name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`,
35
35
  });
36
36
  export const GENERATED_GATES = {
37
+ analytics: bundleGate('analytics'),
37
38
  appsmarket: bundleGate('appsmarket'),
38
39
  classroom: bundleGate('classroom'),
39
40
  cloudidentity: bundleGate('cloudidentity'),
@@ -0,0 +1,2 @@
1
+ import type { ToolRegistry } from '../../registry.js';
2
+ export declare function registerAnalyticsGeneratedTools(registry: ToolRegistry): void;