mcp-google-multi 6.0.0-alpha.1 → 6.0.0-alpha.11

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
@@ -12,45 +12,46 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
12
12
 
13
13
  ## Quick setup
14
14
 
15
- You don't need to know anything about MCP or OAuth five steps, all copy-paste:
15
+ New to all this? It's written for someone who just installed Claude Code and has never made an API key. Copy-paste each step; it says what you'll see. (Already technical? The [Configuration reference](./docs/configuration.md) is the terse version.)
16
16
 
17
- 1. **Install [Node.js](https://nodejs.org) 22 or newer**, then install the server:
17
+ 1. **Install it.** Get [Node.js](https://nodejs.org) (the green "LTS" button, version 22 or newer), then run:
18
18
 
19
19
  ```bash
20
20
  npm install -g mcp-google-multi
21
21
  ```
22
22
 
23
- 2. **Create your (free) Google app** so the server can sign in as you one-time, ~2 minutes: follow [Google Cloud setup](./docs/google-cloud-setup.md). You come back with a **Client ID** and **Client Secret**.
23
+ 2. **Make your Google key** (the one manual part, a few minutes, because Google has no way to script it). Follow the step-by-step [Google Cloud setup](./docs/google-cloud-setup.md), or just ask Claude Code: *"walk me through creating a Google OAuth Desktop client for mcp-google-multi."* You finish with two values, a **Client ID** and a **Client Secret**. It's free and private to you.
24
24
 
25
- 3. **Create a file named `.env`** in the folder you'll run from, and fill in your values:
25
+ 3. **Put them in a file.** In the folder you'll run from, make a file named `.env` and paste this, filling in your values:
26
26
 
27
27
  ```bash
28
28
  GOOGLE_CLIENT_ID=paste-your-client-id
29
29
  GOOGLE_CLIENT_SECRET=paste-your-client-secret
30
- # name each Google account with a short alias:
31
- GOOGLE_ACCOUNTS=work:you@company.com,personal:you@gmail.com
32
- # encryption key for stored tokens — generate one with: openssl rand -base64 32
33
- MASTER_KEY=paste-the-generated-key
30
+ # any short nickname, then your Gmail address:
31
+ GOOGLE_ACCOUNTS=me:you@gmail.com
34
32
  ```
35
33
 
36
- 4. **Sign in each account** (a browser window opens; approve the permissions):
34
+ No encryption key to make: the server generates and stores one for you.
35
+
36
+ 4. **Sign in.** A browser opens; pick your account and click Allow:
37
37
 
38
38
  ```bash
39
- mcp-google-multi auth --account work
40
- mcp-google-multi auth --account personal
39
+ mcp-google-multi auth --account me
41
40
  ```
42
41
 
43
- 5. **Connect it to Claude Code** (any MCP client works the same way):
42
+ 5. **Add it to Claude Code, then restart Claude Code:**
44
43
 
45
44
  ```bash
46
45
  claude mcp add google-multi -s user -- npx -y mcp-google-multi
47
46
  ```
48
47
 
49
- Restart your client and the tools appear. Check everything with `mcp-google-multi config check`.
48
+ **Stuck at any point? Run `mcp-google-multi doctor`.** It inspects every part and prints the exact fix for anything wrong (a missing sign-in, a Google API you still need to switch on, and so on). Once it reads all-green, just talk to Claude: *"summarize my unread email."*
49
+
50
+ *Got more than one Google account?* Add them together, like `GOOGLE_ACCOUNTS=me:you@gmail.com,work:you@company.com`, and run step 4 once per nickname.
50
51
 
51
- **Running it remotely?** To reach the server from [claude.ai](https://claude.ai) as a custom connector or from another machine, run it over HTTP — it ships its own OAuth 2.1 server, so no bearer tokens to paste. Follow [Remote HTTP setup](./docs/http-setup.md) (Cloudflare named tunnel, Docker, or one-click Render/Railway). Coming from v5? See the [v6 migration guide](./MIGRATION-v6.md).
52
+ *On a server or from claude.ai?* Advanced path: [Remote / HTTP setup](./docs/http-setup.md). *Coming from v5?* [v6 migration guide](./MIGRATION-v6.md).
52
53
 
53
- **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Remote / HTTP setup](./docs/http-setup.md) · [Secrets in a vault](./docs/secrets.md) · [Migrating to v6](./MIGRATION-v6.md) · [Upgrading from v4](./docs/upgrading-v4.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
54
+ **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Remote / HTTP setup](./docs/http-setup.md) · [Secrets in a vault](./docs/secrets.md) · [Migrating to v6](./MIGRATION-v6.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
54
55
 
55
56
  ## Maintainer & credits
56
57
 
@@ -0,0 +1,18 @@
1
+ import type { ApiProbeResult } from './doctor.js';
2
+ export interface ApiProbeSpec {
3
+ service: string;
4
+ /** console library id for the enable deep-link, e.g. "calendar-json". */
5
+ api: string;
6
+ url: string;
7
+ scopePrefixes: string[];
8
+ /** id-required APIs have no no-arg read; a 404 on a nonexistent id still
9
+ * proves the API is enabled (accessNotConfigured wins before routing). */
10
+ notFoundMeansEnabled?: boolean;
11
+ }
12
+ export declare const API_PROBES: ApiProbeSpec[];
13
+ export declare function planProbes(granted: string[], probes?: ApiProbeSpec[]): ApiProbeSpec[];
14
+ export interface ApiProbeDeps {
15
+ grantedScopes: (alias: string) => string[];
16
+ request: (alias: string, url: string) => Promise<void>;
17
+ }
18
+ export declare function probeApiEnablement(alias: string, deps?: ApiProbeDeps): Promise<ApiProbeResult[]>;
@@ -0,0 +1,65 @@
1
+ import { getClient } from './client.js';
2
+ import { readToken } from './token-store.js';
3
+ import { mapGoogleError } from './tools/_errors.js';
4
+ const P = 'https://www.googleapis.com/auth/';
5
+ const BOGUS_ID = 'mcp-google-multi-probe-nonexistent';
6
+ export const API_PROBES = [
7
+ { service: 'gmail', api: 'gmail', url: 'https://gmail.googleapis.com/gmail/v1/users/me/profile', scopePrefixes: [`${P}gmail.`] },
8
+ { service: 'drive', api: 'drive', url: 'https://www.googleapis.com/drive/v3/about?fields=user', scopePrefixes: [`${P}drive`] },
9
+ { service: 'calendar', api: 'calendar-json', url: 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1', scopePrefixes: [`${P}calendar`] },
10
+ // people/me needs profile scopes, not contacts; connections is the read the
11
+ // contacts grant actually authorizes.
12
+ { service: 'contacts', api: 'people', url: 'https://people.googleapis.com/v1/people/me/connections?personFields=names&pageSize=1', scopePrefixes: [`${P}contacts`] },
13
+ { service: 'sheets', api: 'sheets', url: `https://sheets.googleapis.com/v4/spreadsheets/${BOGUS_ID}`, scopePrefixes: [`${P}spreadsheets`], notFoundMeansEnabled: true },
14
+ { service: 'docs', api: 'docs', url: `https://docs.googleapis.com/v1/documents/${BOGUS_ID}`, scopePrefixes: [`${P}documents`], notFoundMeansEnabled: true },
15
+ { service: 'searchconsole', api: 'searchconsole', url: 'https://www.googleapis.com/webmasters/v3/sites', scopePrefixes: [`${P}webmasters`] },
16
+ { service: 'tasks', api: 'tasks', url: 'https://tasks.googleapis.com/tasks/v1/users/@me/lists?maxResults=1', scopePrefixes: [`${P}tasks`] },
17
+ { service: 'chat', api: 'chat', url: 'https://chat.googleapis.com/v1/spaces?pageSize=1', scopePrefixes: [`${P}chat.`] },
18
+ { service: 'meet', api: 'meet', url: 'https://meet.googleapis.com/v2/conferenceRecords?pageSize=1', scopePrefixes: [`${P}meetings.`] },
19
+ { service: 'forms', api: 'forms', url: `https://forms.googleapis.com/v1/forms/${BOGUS_ID}`, scopePrefixes: [`${P}forms.`], notFoundMeansEnabled: true },
20
+ ];
21
+ export function planProbes(granted, probes = API_PROBES) {
22
+ return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
23
+ }
24
+ const DEFAULT_DEPS = {
25
+ grantedScopes: (alias) => {
26
+ try {
27
+ const scope = readToken(alias)?.scope;
28
+ return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ },
34
+ request: async (alias, url) => {
35
+ const auth = await getClient(alias);
36
+ await auth.request({ url, timeout: 10_000 });
37
+ },
38
+ };
39
+ export async function probeApiEnablement(alias, deps = DEFAULT_DEPS) {
40
+ const results = [];
41
+ for (const spec of planProbes(deps.grantedScopes(alias))) {
42
+ try {
43
+ await deps.request(alias, spec.url);
44
+ results.push({ service: spec.service, api: spec.api, ok: true });
45
+ }
46
+ catch (error) {
47
+ const envelope = mapGoogleError(error, alias);
48
+ if (envelope.error === 'network_error') {
49
+ // One connect failure means they will all fail: abort so section 6
50
+ // reports a single WARN "Probe could not complete" with the code.
51
+ throw new Error(envelope.message, { cause: error });
52
+ }
53
+ if (envelope.error === 'api_not_enabled') {
54
+ results.push({ service: spec.service, api: spec.api, ok: false, notEnabled: true, message: envelope.message });
55
+ }
56
+ else if (spec.notFoundMeansEnabled && envelope.error === 'not_found') {
57
+ results.push({ service: spec.service, api: spec.api, ok: true });
58
+ }
59
+ else {
60
+ results.push({ service: spec.service, api: spec.api, ok: false, message: envelope.error });
61
+ }
62
+ }
63
+ }
64
+ return results;
65
+ }
@@ -0,0 +1,19 @@
1
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
+ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
3
+ export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
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>): {
11
+ args: Record<string, unknown>;
12
+ renamed: [string, string][];
13
+ };
14
+ export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void): JSONRPCMessage;
15
+ /** Wrap a server-side transport so tools/call argument keys are normalized
16
+ * before the SDK validates them. The Protocol assigns `onmessage` during
17
+ * connect(); the interceptor lives in that setter, so the wrapper works
18
+ * identically for stdio and (per-request, stateless) HTTP transports. */
19
+ export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void): Transport;
@@ -0,0 +1,90 @@
1
+ // Wire-level tools/call argument normalization. Clients (LLMs) recurringly
2
+ // snake_case a camelCase parameter (thread_id for threadId) and burn a retry
3
+ // on the -32602. A schema-level fix is off the table: SDK 1.x advertises an
4
+ // EMPTY input schema for any non-object wrapper (pipe/preprocess), so the
5
+ // only seam that keeps tools/list intact is the JSON-RPC message itself —
6
+ // which is versioned MCP spec, stabler than any SDK internal. The rename is
7
+ // provably lossless: it fires only when the sent key is NOT in the tool's
8
+ // schema, its camelCase twin IS, and that twin was not also sent.
9
+ export function argNormalizationEnabled(env = process.env) {
10
+ return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
11
+ }
12
+ const snakeToCamel = (key) => key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
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) {
24
+ const renamed = [];
25
+ let out;
26
+ for (const key of Object.keys(args)) {
27
+ if (shape.has(key) || !key.includes('_'))
28
+ continue;
29
+ const camel = snakeToCamel(key);
30
+ if (camel !== key && shape.has(camel) && !(camel in args)) {
31
+ out ??= { ...args };
32
+ out[camel] = coerceRenamedValue(out[key], shape.get(camel));
33
+ delete out[key];
34
+ renamed.push([key, camel]);
35
+ }
36
+ }
37
+ return { args: out ?? args, renamed };
38
+ }
39
+ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`)) {
40
+ const m = msg;
41
+ if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
42
+ return msg;
43
+ const args = m.params.arguments;
44
+ if (!args || typeof args !== 'object' || Array.isArray(args))
45
+ return msg;
46
+ const shape = shapeFor(m.params.name);
47
+ if (!shape)
48
+ return msg;
49
+ const { args: normalized, renamed } = normalizeCallArguments(shape, args);
50
+ if (renamed.length === 0)
51
+ return msg;
52
+ // Key names only — argument VALUES never reach the log.
53
+ log(`[args] ${m.params.name}: ${renamed.map(([f, t]) => `${f} -> ${t}`).join(', ')}`);
54
+ return {
55
+ ...msg,
56
+ params: { ...m.params, arguments: normalized },
57
+ };
58
+ }
59
+ /** Wrap a server-side transport so tools/call argument keys are normalized
60
+ * before the SDK validates them. The Protocol assigns `onmessage` during
61
+ * connect(); the interceptor lives in that setter, so the wrapper works
62
+ * identically for stdio and (per-request, stateless) HTTP transports. */
63
+ export function withArgNormalization(transport, shapeFor, log) {
64
+ const wrapper = {
65
+ start: () => transport.start(),
66
+ send: (message, options) => transport.send(message, options),
67
+ close: () => transport.close(),
68
+ };
69
+ Object.defineProperty(wrapper, 'onmessage', {
70
+ get: () => transport.onmessage,
71
+ set: (handler) => {
72
+ transport.onmessage = handler
73
+ ? (message, extra) => handler(normalizeMessage(message, shapeFor, log), extra)
74
+ : undefined;
75
+ },
76
+ });
77
+ for (const prop of ['onclose', 'onerror']) {
78
+ Object.defineProperty(wrapper, prop, {
79
+ get: () => transport[prop],
80
+ set: (v) => {
81
+ transport[prop] = v;
82
+ },
83
+ });
84
+ }
85
+ Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
86
+ if (transport.setProtocolVersion) {
87
+ wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
88
+ }
89
+ return wrapper;
90
+ }
package/dist/doctor.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { ToolRegistry } from './registry.js';
2
2
  import { getAccountSet } from './accounts.js';
3
3
  import { type AccountHealth } from './tools/accounts-tool.js';
4
4
  import { peekMasterKeyProvenance } from './master-key.js';
5
+ import { type HttpConfig } from './http-config.js';
5
6
  export type Verdict = 'ok' | 'warn' | 'fail' | 'unknown';
6
7
  export interface DiagnosticSection {
7
8
  id: number;
@@ -38,6 +39,17 @@ export interface DiagnosticsDeps {
38
39
  /** Optional live section-6 probe; when absent the section reports `unknown`
39
40
  * (spec: a section that cannot run is unknown, not FAIL). */
40
41
  probeApi?: (alias: string) => Promise<ApiProbeResult[]>;
42
+ /** Optional live section-7 endpoint probe (PRM/AS-metadata self-fetch);
43
+ * when absent, section 7 stays on its offline config checks. */
44
+ probeHttp?: (cfg: HttpConfig) => Promise<HttpProbeResult>;
45
+ }
46
+ /** Live §7 probe outcome. `unreachable` = connection-level failure (server not
47
+ * running), reported as `unknown` rather than FAIL; `problem` = a real
48
+ * metadata fault at a reachable server. */
49
+ export interface HttpProbeResult {
50
+ ok: boolean;
51
+ unreachable?: boolean;
52
+ problem?: string;
41
53
  }
42
54
  /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
43
55
  export declare function apiEnableLink(api: string): string;
package/dist/doctor.js CHANGED
@@ -6,6 +6,9 @@ import { deriveAccountHealth } from './tools/accounts-tool.js';
6
6
  import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.js';
7
7
  import { hasToken } from './token-store.js';
8
8
  import { configDir } from './config-file.js';
9
+ import { probeApiEnablement } from './api-probe.js';
10
+ import { resolveHttpConfig, HttpConfigError } from './http-config.js';
11
+ import { parseOwnerEmails } from './http-transport.js';
9
12
  const MIN_NODE_MAJOR = 22;
10
13
  const DEFAULT_DEPS = {
11
14
  nodeVersion: process.versions.node,
@@ -23,17 +26,44 @@ const DEFAULT_DEPS = {
23
26
  masterKeyProvenance: () => peekMasterKeyProvenance(),
24
27
  anyTokensExist: (aliases) => aliases.some((a) => hasToken(a)),
25
28
  fileExists: fs.existsSync,
29
+ probeApi: (alias) => probeApiEnablement(alias),
30
+ probeHttp: (cfg) => probeHttpEndpoints(cfg),
26
31
  };
32
+ /** §7 live check: the advertised OAuth metadata must derive from MCP_PUBLIC_URL
33
+ * exactly — one mismatch between PRM `resource` / AS `issuer` and what clients
34
+ * compute from the public URL is the perpetual-401 interop bug (BR4). */
35
+ async function probeHttpEndpoints(cfg) {
36
+ try {
37
+ const prmRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-protected-resource`, {
38
+ signal: AbortSignal.timeout(2000),
39
+ redirect: 'manual',
40
+ });
41
+ if (!prmRes.ok)
42
+ return { ok: false, problem: `PRM endpoint returned HTTP ${prmRes.status}` };
43
+ const prm = (await prmRes.json());
44
+ if (prm.resource !== cfg.resourceUri) {
45
+ return { ok: false, problem: `PRM resource "${prm.resource}" does not match the expected "${cfg.resourceUri}"` };
46
+ }
47
+ const asRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-authorization-server`, {
48
+ signal: AbortSignal.timeout(2000),
49
+ redirect: 'manual',
50
+ });
51
+ if (!asRes.ok)
52
+ return { ok: false, problem: `AS metadata endpoint returned HTTP ${asRes.status}` };
53
+ const as = (await asRes.json());
54
+ if (as.issuer !== cfg.publicUrl) {
55
+ return { ok: false, problem: `AS metadata issuer "${as.issuer}" does not match the public URL "${cfg.publicUrl}"` };
56
+ }
57
+ return { ok: true };
58
+ }
59
+ catch {
60
+ return { ok: false, unreachable: true };
61
+ }
62
+ }
27
63
  /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
28
64
  export function apiEnableLink(api) {
29
65
  return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
30
66
  }
31
- function transportsFrom(env) {
32
- return (env.MCP_TRANSPORT ?? 'stdio')
33
- .split(',')
34
- .map((s) => s.trim().toLowerCase())
35
- .filter(Boolean);
36
- }
37
67
  const LEGACY_ENV_KEYS = ['GOOGLE_ACCOUNTS', 'GOOGLE_OPTIONAL_SCOPES', 'GOOGLE_ADMIN_ACCOUNTS'];
38
68
  function sectionRuntime(deps) {
39
69
  const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
@@ -163,6 +193,9 @@ async function sectionApiEnablement(deps, aliases) {
163
193
  // Network / transient: WARN with the target, never crash the report.
164
194
  return { id: 6, title: 'API enablement', verdict: 'warn', lines: [`Probe could not complete: ${e?.message ?? e}`] };
165
195
  }
196
+ if (results.length === 0) {
197
+ return { id: 6, title: 'API enablement', verdict: 'unknown', lines: [`No probeable service scopes granted on "${healthy}".`] };
198
+ }
166
199
  const disabled = results.filter((r) => r.notEnabled);
167
200
  const lines = results.map((r) => `${r.service}: ${r.ok ? 'enabled' : r.notEnabled ? 'NOT ENABLED' : `unknown (${r.message ?? 'error'})`}`);
168
201
  if (disabled.length > 0) {
@@ -177,15 +210,65 @@ async function sectionApiEnablement(deps, aliases) {
177
210
  }
178
211
  return { id: 6, title: 'API enablement', verdict: 'ok', lines: lines.length ? lines : ['(probed account, all enabled)'] };
179
212
  }
180
- function sectionHttpDeferred(deps) {
181
- if (!transportsFrom(deps.env).includes('http'))
213
+ async function sectionHttp(deps, aliases) {
214
+ const raw = (deps.env.MCP_TRANSPORT ?? '').trim().toLowerCase();
215
+ if (raw === '' || raw === 'stdio')
182
216
  return null;
183
- return {
184
- id: 7,
185
- title: 'HTTP',
186
- verdict: 'unknown',
187
- lines: ['HTTP diagnostics (MCP_PUBLIC_URL canonicalization, MCP_OWNER_EMAILS, PRM/AS-metadata self-fetch) land with the OAuth authorization server (not yet built).'],
188
- };
217
+ let cfg;
218
+ try {
219
+ cfg = resolveHttpConfig(deps.env);
220
+ }
221
+ catch (err) {
222
+ return {
223
+ id: 7,
224
+ title: 'HTTP',
225
+ verdict: 'fail',
226
+ slug: err instanceof HttpConfigError ? err.slug : 'E_HTTP_CONFIG',
227
+ lines: [err.message],
228
+ hint: 'Fix the MCP_* variable above and re-run doctor.',
229
+ };
230
+ }
231
+ const lines = [`bind ${cfg.host}:${cfg.port}, public URL ${cfg.publicUrl} (resource ${cfg.resourceUri})`];
232
+ let verdict = 'ok';
233
+ let slug;
234
+ const hints = [];
235
+ const owners = parseOwnerEmails(deps.env);
236
+ if (owners.length === 0) {
237
+ verdict = 'fail';
238
+ slug = 'E_OWNER_EMAILS_REQUIRED';
239
+ lines.push('MCP_OWNER_EMAILS is empty — nobody can pass the owner gate.');
240
+ hints.push('Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.');
241
+ }
242
+ else {
243
+ const known = new Set(aliases.map((a) => deps.accountHealth(a).email.toLowerCase()));
244
+ const strangers = known.size > 0 ? owners.filter((o) => !known.has(o)) : [];
245
+ lines.push(`owner gate: ${owners.length} email(s)${strangers.length ? `, ${strangers.length} matching no configured account` : ''}`);
246
+ if (strangers.length > 0) {
247
+ verdict = 'warn';
248
+ slug = 'W_OWNER_EMAIL_UNKNOWN';
249
+ hints.push(`Owner entry ${strangers.join(', ')} is not a configured account email. ` +
250
+ 'If that is a misspelling of your account email, sign-in will be refused — fix MCP_OWNER_EMAILS.');
251
+ }
252
+ }
253
+ if (verdict !== 'fail' && deps.probeHttp) {
254
+ const probe = await deps.probeHttp(cfg);
255
+ if (probe.ok) {
256
+ lines.push('live: PRM + AS metadata verified at the public URL');
257
+ }
258
+ else if (probe.unreachable) {
259
+ if (verdict === 'ok')
260
+ verdict = 'unknown';
261
+ lines.push(`live: ${cfg.publicUrl} not reachable (server not running?)`);
262
+ hints.push('Start the server (MCP_TRANSPORT=http) and re-run doctor for the live endpoint checks.');
263
+ }
264
+ else {
265
+ verdict = 'fail';
266
+ slug = 'E_HTTP_METADATA_MISMATCH';
267
+ lines.push(`live: ${probe.problem}`);
268
+ hints.push('The advertised OAuth metadata must derive from MCP_PUBLIC_URL exactly; restart the server after changing it.');
269
+ }
270
+ }
271
+ return { id: 7, title: 'HTTP', verdict, ...(slug ? { slug } : {}), lines, ...(hints.length ? { hint: hints.join('\n') } : {}) };
189
272
  }
190
273
  const RANK = { ok: 0, unknown: 0, warn: 1, fail: 2 };
191
274
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
@@ -209,7 +292,7 @@ export async function runDiagnostics(deps = DEFAULT_DEPS) {
209
292
  sections.push(tokens, scopes);
210
293
  sections.push(await sectionApiEnablement(deps, aliases));
211
294
  }
212
- const http = sectionHttpDeferred(deps);
295
+ const http = await sectionHttp(deps, aliases);
213
296
  if (http)
214
297
  sections.push(http);
215
298
  return { verdict: overallVerdict(sections), sections };
@@ -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
  } | {
@@ -28,6 +29,8 @@ export interface HttpHostOptions {
28
29
  /** Deadline for a single /mcp dispatch; a hung handler past this releases the
29
30
  * shared lock instead of wedging the transport (default 120s). */
30
31
  dispatchTimeoutMs?: number;
32
+ /** tools/call argument-key normalization lookup (arg-normalize.ts); absent = off. */
33
+ argShapeFor?: (tool: string) => ArgShape | undefined;
31
34
  }
32
35
  export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
33
36
  /** Front guard: an Origin, if present, must be allowlisted; a Host must be
@@ -5,6 +5,7 @@
5
5
  // loopback-owner authenticator so the local-HTTP model works before the AS lands.
6
6
  import { createServer } from 'node:http';
7
7
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
+ import { withArgNormalization } from './arg-normalize.js';
8
9
  // A hung handler that keeps the connection open would otherwise hold the global
9
10
  // serialize() lock forever. Generous by default so slow-but-valid calls (large
10
11
  // Drive exports, fan-out) still finish; the point is only to guarantee release.
@@ -182,7 +183,7 @@ export class HttpTransportHost {
182
183
  timer = setTimeout(() => resolve('timeout'), deadlineMs);
183
184
  timer.unref?.();
184
185
  });
185
- await this.opts.server.connect(transport);
186
+ await this.opts.server.connect(this.opts.argShapeFor ? withArgNormalization(transport, this.opts.argShapeFor, this.opts.log) : transport);
186
187
  // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
187
188
  // race, an orphaned handler settling later must not surface as an unhandled
188
189
  // rejection — but a genuine dispatch error still propagates (rethrown below).
package/dist/index.js CHANGED
@@ -19,6 +19,9 @@ import { getToolsets, toolsetEnabled } from './toolsets.js';
19
19
  import { isAllowed, describePolicy } from './write-control.js';
20
20
  import { buildIdentityContext } from './identity.js';
21
21
  import { registerSetupPrompt } from './setup-prompt.js';
22
+ import { applyNetTuning } from './net-tuning.js';
23
+ import { argNormalizationEnabled, withArgNormalization } from './arg-normalize.js';
24
+ applyNetTuning();
22
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
26
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
24
27
  function buildRegistry(server, ctx, mode) {
@@ -187,7 +190,8 @@ async function main() {
187
190
  const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }));
188
191
  registry.installListHandler();
189
192
  registerSetupPrompt(server);
190
- await server.connect(new StdioServerTransport());
193
+ const stdioTransport = new StdioServerTransport();
194
+ await server.connect(argNormalizationEnabled() ? withArgNormalization(stdioTransport, (n) => registry.argShape(n)) : stdioTransport);
191
195
  }
192
196
  if (wantHttp) {
193
197
  const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
@@ -278,6 +282,7 @@ async function main() {
278
282
  authenticate: authServer.authenticate,
279
283
  routes: authServer.routes,
280
284
  log: (l) => process.stderr.write(`[http] ${l}\n`),
285
+ argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
281
286
  });
282
287
  await host.start();
283
288
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
@@ -0,0 +1,8 @@
1
+ export declare const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
2
+ /** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
3
+ export declare function decideConnectAttemptTimeout(opts: {
4
+ execArgv: readonly string[];
5
+ nodeOptions: string | undefined;
6
+ current: number;
7
+ }): number | null;
8
+ export declare function applyNetTuning(): void;
@@ -0,0 +1,25 @@
1
+ import net from 'node:net';
2
+ // Node's happy-eyeballs gives each address family 250ms per connect attempt on
3
+ // every LTS line (raised to 500ms only in v25.2+); on high-latency or
4
+ // broken-IPv6 links that aborts EVERY Google call while curl works. Raise the
5
+ // process default unless the user tuned it themselves. Why: docs/internals.md.
6
+ export const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
7
+ const USER_FLAGS = ['--network-family-autoselection-attempt-timeout', '--no-network-family-autoselection'];
8
+ /** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
9
+ export function decideConnectAttemptTimeout(opts) {
10
+ const userArgs = [...opts.execArgv, opts.nodeOptions ?? ''].join(' ');
11
+ if (USER_FLAGS.some((f) => userArgs.includes(f)))
12
+ return null;
13
+ if (opts.current >= CONNECT_ATTEMPT_TIMEOUT_MS)
14
+ return null;
15
+ return CONNECT_ATTEMPT_TIMEOUT_MS;
16
+ }
17
+ export function applyNetTuning() {
18
+ const timeout = decideConnectAttemptTimeout({
19
+ execArgv: process.execArgv,
20
+ nodeOptions: process.env.NODE_OPTIONS,
21
+ current: net.getDefaultAutoSelectFamilyAttemptTimeout(),
22
+ });
23
+ if (timeout !== null)
24
+ net.setDefaultAutoSelectFamilyAttemptTimeout(timeout);
25
+ }
@@ -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;
@@ -38,6 +39,7 @@ export declare class ToolRegistry {
38
39
  readonly registerTool: McpServer['registerTool'];
39
40
  private readonly revealed;
40
41
  private readonly jsonSchemaCache;
42
+ private readonly argShapeCache;
41
43
  private readonly compactOutput;
42
44
  private registeringMeta;
43
45
  /** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
@@ -48,6 +50,9 @@ export declare class ToolRegistry {
48
50
  constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
49
51
  registerMeta: McpServer['registerTool'];
50
52
  services(): string[];
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;
51
56
  catalog(service: string, query?: string): CatalogOperation[];
52
57
  reveal(service: string): boolean;
53
58
  /** discover_all: advertise the full curated set at once. Idempotent. */
package/dist/registry.js CHANGED
@@ -4,6 +4,11 @@ import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-cont
4
4
  import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
5
5
  import { compactResult, trimEnabled } from './trim.js';
6
6
  import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
7
+ import { MAX_RESPONSE_CHARS } from './executor.js';
8
+ // Client-side result budget advertised for tools that do not declare their own
9
+ // (fat readers do; see trim.ts). ~50k chars stays well inside a default client
10
+ // context limit while leaving room for real list payloads.
11
+ const DEFAULT_MAX_RESULT_CHARS = 50_000;
7
12
  const DISCOVERY_MODES = ['lazy', 'curated', 'eager'];
8
13
  export function resolveDiscoveryMode(env = process.env) {
9
14
  const raw = (env.GOOGLE_DISCOVERY ?? 'lazy').trim();
@@ -33,6 +38,23 @@ const SERVICE_OVERRIDES = {
33
38
  };
34
39
  // read tools that write local files — same savePath fanned across accounts would clobber
35
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
+ }
36
58
  function isAccountEnum(field) {
37
59
  const def = field?._zod?.def;
38
60
  if (!def)
@@ -64,6 +86,7 @@ export class ToolRegistry {
64
86
  registerTool;
65
87
  revealed = new Set();
66
88
  jsonSchemaCache = new Map();
89
+ argShapeCache = new Map();
67
90
  compactOutput = trimEnabled();
68
91
  registeringMeta = false;
69
92
  /** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
@@ -90,9 +113,15 @@ export class ToolRegistry {
90
113
  // A12: forced per-call human approval on the irreversible set, even in
91
114
  // bypass mode. Client-enforced via the wire _meta (Claude Code reads
92
115
  // anthropic/* ONLY there); the server verdict stays separate.
93
- const clientMeta = IRREVERSIBLE_TOOLS.has(name)
94
- ? { ...config._meta, 'anthropic/requiresUserInteraction': true }
95
- : config._meta;
116
+ // Every tool also advertises a result-size budget: fat readers declare
117
+ // their own, everything else inherits the default, so an uncapped tool
118
+ // can never blow past a client's context limit. Generated tools align
119
+ // with the executor's server-side cap.
120
+ const clientMeta = {
121
+ 'anthropic/maxResultSizeChars': config.cud !== undefined ? MAX_RESPONSE_CHARS : DEFAULT_MAX_RESULT_CHARS,
122
+ ...config._meta,
123
+ ...(IRREVERSIBLE_TOOLS.has(name) ? { 'anthropic/requiresUserInteraction': true } : {}),
124
+ };
96
125
  // never fan out meta tools: google_api_call infers cud=read but executes writes
97
126
  let inputShape = config.inputSchema ?? {};
98
127
  let baseHandler = handler;
@@ -181,6 +210,21 @@ export class ToolRegistry {
181
210
  services() {
182
211
  return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
183
212
  }
213
+ /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
214
+ * normalization; the kind drives value coercion on renamed keys). */
215
+ argShape(name) {
216
+ const cached = this.argShapeCache.get(name);
217
+ if (cached)
218
+ return cached;
219
+ const entry = this.tools.find((t) => t.name === name);
220
+ if (!entry)
221
+ return undefined;
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;
227
+ }
184
228
  catalog(service, query) {
185
229
  const q = query?.trim().toLowerCase();
186
230
  return this.tools
@@ -17,3 +17,4 @@ export declare const BUNDLE_ALIASES: Record<string, string>;
17
17
  export declare function resolveBundleAliases(bundles: string[]): string[];
18
18
  /** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
19
19
  export declare function closestBundle(name: string): string | undefined;
20
+ export declare function editDistance(a: string, b: string): number;
@@ -169,7 +169,7 @@ export function closestBundle(name) {
169
169
  }
170
170
  return best;
171
171
  }
172
- function editDistance(a, b) {
172
+ export function editDistance(a, b) {
173
173
  const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
174
174
  for (let j = 1; j <= b.length; j++)
175
175
  dp[0][j] = j;
@@ -12,6 +12,32 @@ function reasonOf(error) {
12
12
  function messageOf(error) {
13
13
  return error?.response?.data?.error?.message ?? error?.message ?? String(error);
14
14
  }
15
+ // Connect/DNS syscall codes. ENOTFOUND (no such name) is the one non-transient
16
+ // member. node-fetch flattens the happy-eyeballs AggregateError to a bare code
17
+ // with an empty message, so the code is the only surviving signal to surface.
18
+ const RETRIABLE_NET_CODES = new Set([
19
+ 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ENETUNREACH',
20
+ 'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
21
+ ]);
22
+ const NET_CODES = new Set([...RETRIABLE_NET_CODES, 'ENOTFOUND']);
23
+ // Local-filesystem syscall codes from caller-supplied paths (localPath/savePath).
24
+ // String codes, so they never collide with Google's numeric statuses; the
25
+ // network codes above are deliberately excluded.
26
+ const LOCAL_FS_CODES = new Set(['ENOENT', 'EACCES', 'EISDIR', 'ENOTDIR', 'EPERM', 'ELOOP', 'ENAMETOOLONG', 'ENOSPC']);
27
+ /** First known network code on the error or its cause chain (GaxiosError.cause
28
+ * -> FetchError; undici TypeError.cause -> AggregateError.errors). */
29
+ function netCodeOf(error) {
30
+ for (let e = error, depth = 0; e && depth < 5; e = e.cause ?? e.error, depth++) {
31
+ if (typeof e.code === 'string' && NET_CODES.has(e.code))
32
+ return e.code;
33
+ if (Array.isArray(e.errors)) {
34
+ const sub = e.errors.find((x) => typeof x?.code === 'string' && NET_CODES.has(x.code));
35
+ if (sub)
36
+ return sub.code;
37
+ }
38
+ }
39
+ return undefined;
40
+ }
15
41
  /** Console deep-link to enable one API (noob-proofing hint, B10). */
16
42
  function apiEnableLink(api) {
17
43
  return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
@@ -114,6 +140,31 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
114
140
  if (status !== undefined && status >= 500) {
115
141
  return { error: 'upstream_error', message, retriable: true, account };
116
142
  }
143
+ if (status === undefined) {
144
+ const fsCode = typeof error?.code === 'string' && LOCAL_FS_CODES.has(error.code) ? error.code : undefined;
145
+ if (fsCode) {
146
+ const p = typeof error?.path === 'string' ? ` "${error.path}"` : '';
147
+ return {
148
+ error: 'invalid_params',
149
+ message: `Cannot access local path${p}: ${fsCode}`,
150
+ hint: 'The path must exist on the machine running this server and be accessible to it. ' +
151
+ 'When the server runs remotely (HTTP transport), paths on your own machine are not visible to it.',
152
+ retriable: false,
153
+ account,
154
+ };
155
+ }
156
+ const netCode = netCodeOf(error);
157
+ if (netCode) {
158
+ return {
159
+ error: 'network_error',
160
+ message: message.includes(netCode) ? message : message.endsWith('reason: ') ? `${message}${netCode}` : `${message} (${netCode})`,
161
+ hint: `Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
162
+ 'If it persists on a high-latency or broken-IPv6 link, raise the happy-eyeballs budget: NODE_OPTIONS=--network-family-autoselection-attempt-timeout=4000 (server default 2000ms), and check connectivity with curl.',
163
+ retriable: RETRIABLE_NET_CODES.has(netCode),
164
+ account,
165
+ };
166
+ }
167
+ }
117
168
  return { error: 'upstream_error', message, retriable: false, account };
118
169
  }
119
170
  export function handleGoogleApiError(error, account, forbiddenHint, scopeContext) {
@@ -0,0 +1,3 @@
1
+ import * as fs from 'fs';
2
+ export declare function prepareLocalDest(savePath: string, filename: string): string;
3
+ export declare function openLocalReadStream(localPath: string): Promise<fs.ReadStream>;
@@ -0,0 +1,29 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
4
+ export function prepareLocalDest(savePath, filename) {
5
+ const dest = path.join(savePath, path.basename(filename));
6
+ fs.mkdirSync(savePath, { recursive: true });
7
+ return dest;
8
+ }
9
+ // fs.createReadStream() reports an unopenable path as an async 'error' EVENT;
10
+ // with no listener attached, that single event kills the whole process — fatal
11
+ // for the shared HTTP transport. Opening the fd first turns the open-failure
12
+ // class (ENOENT/EACCES/...) into a normal rejection the caller's try/catch can
13
+ // map to an error envelope.
14
+ export async function openLocalReadStream(localPath) {
15
+ const handle = await fs.promises.open(localPath, 'r');
16
+ // open() succeeds on a directory; fail it here rather than as an async read error.
17
+ if ((await handle.stat()).isDirectory()) {
18
+ await handle.close();
19
+ throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${localPath}'`), {
20
+ code: 'EISDIR',
21
+ path: localPath,
22
+ });
23
+ }
24
+ const stream = handle.createReadStream();
25
+ // Mid-read errors still reach the consumer through its own listeners; this
26
+ // one only closes the unhandled-'error' crash path.
27
+ stream.on('error', () => { });
28
+ return stream;
29
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
- export declare function prepareLocalDest(savePath: string, filename: string): string;
2
+ export declare function isTextualMime(mimeType: string): boolean;
3
+ export declare function resolveConvertTarget(convertTo: string | undefined): string | undefined;
3
4
  export declare const DRIVE_QUERY_HINT: string;
4
5
  export declare function normalizeDriveQuery(raw: string): string;
5
6
  export declare function isDriveInvalidQuery(error: any): boolean;
@@ -4,6 +4,7 @@ import { drive as driveClient } from '@googleapis/drive';
4
4
  import { accountAliasSchema, getAccountSet } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError } from './_errors.js';
7
+ import { openLocalReadStream, prepareLocalDest } from './_local-files.js';
7
8
  import { isAllowed, writeDisabledResult } from '../write-control.js';
8
9
  import { capText } from '../trim.js';
9
10
  import * as fs from 'fs';
@@ -24,6 +25,52 @@ const GOOGLE_WORKSPACE_TYPES = new Set([
24
25
  'application/vnd.google-apps.presentation',
25
26
  'application/vnd.google-apps.drawing',
26
27
  ]);
28
+ // drive_read inlines only textual content. Beyond text/*, RFC 6839 structured-
29
+ // syntax suffixes (+json/+xml/...) and a few bare application/* types are text
30
+ // in practice — image/svg+xml was the motivating false "binary" refusal.
31
+ const TEXTUAL_EXACT = new Set([
32
+ 'application/json',
33
+ 'application/xml',
34
+ 'application/javascript',
35
+ 'application/x-ndjson',
36
+ 'application/yaml',
37
+ 'application/x-yaml',
38
+ 'application/sql',
39
+ 'application/x-sh',
40
+ 'application/csv',
41
+ ]);
42
+ export function isTextualMime(mimeType) {
43
+ const bare = mimeType.split(';')[0].trim().toLowerCase();
44
+ if (bare.startsWith('text/'))
45
+ return true;
46
+ if (/\+(json|xml|yaml|toml|csv)$/.test(bare))
47
+ return true;
48
+ return TEXTUAL_EXACT.has(bare);
49
+ }
50
+ const BINARY_READ_HINT = 'Binary content cannot be inlined. Use drive_download to save the file to disk, or drive_export for Google Workspace files.';
51
+ // Accepted alongside the full application/vnd.google-apps.* ids so the obvious
52
+ // short spelling ("document") works; the enum advertises both.
53
+ const CONVERT_SHORTHANDS = {
54
+ document: 'application/vnd.google-apps.document',
55
+ spreadsheet: 'application/vnd.google-apps.spreadsheet',
56
+ presentation: 'application/vnd.google-apps.presentation',
57
+ drawing: 'application/vnd.google-apps.drawing',
58
+ };
59
+ export function resolveConvertTarget(convertTo) {
60
+ if (!convertTo)
61
+ return undefined;
62
+ return CONVERT_SHORTHANDS[convertTo] ?? convertTo;
63
+ }
64
+ const CONVERT_TO_VALUES = [
65
+ 'document',
66
+ 'spreadsheet',
67
+ 'presentation',
68
+ 'drawing',
69
+ 'application/vnd.google-apps.document',
70
+ 'application/vnd.google-apps.spreadsheet',
71
+ 'application/vnd.google-apps.presentation',
72
+ 'application/vnd.google-apps.drawing',
73
+ ];
27
74
  // Comment/Reply fields list — Drive API requires explicit `fields` on every call.
28
75
  const COMMENT_BASE_FIELDS = 'id,kind,content,htmlContent,createdTime,modifiedTime,resolved,anchor,author,deleted,quotedFileContent';
29
76
  const REPLY_SUBFIELDS = 'id,content,action,createdTime,modifiedTime,author,deleted';
@@ -31,12 +78,6 @@ const COMMENT_FIELDS = `${COMMENT_BASE_FIELDS},replies(${REPLY_SUBFIELDS})`;
31
78
  const COMMENT_LIST_FIELDS = `nextPageToken,comments(${COMMENT_BASE_FIELDS},replies(${REPLY_SUBFIELDS}))`;
32
79
  const REPLY_FIELDS = `kind,htmlContent,${REPLY_SUBFIELDS}`;
33
80
  const REPLY_LIST_FIELDS = `nextPageToken,replies(${REPLY_FIELDS})`;
34
- // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
35
- export function prepareLocalDest(savePath, filename) {
36
- const dest = path.join(savePath, path.basename(filename));
37
- fs.mkdirSync(savePath, { recursive: true });
38
- return dest;
39
- }
40
81
  export const DRIVE_QUERY_HINT = "Drive search syntax: a plain keyword is treated as a full-text search, but a " +
41
82
  "structured query needs an operator, e.g. \"name contains 'report'\", " +
42
83
  "\"mimeType = 'application/pdf'\", or \"'me' in owners\". " +
@@ -131,7 +172,7 @@ export function registerDriveTools(server) {
131
172
  });
132
173
  server.registerTool('drive_read', {
133
174
  _meta: { 'anthropic/maxResultSizeChars': 100_000 },
134
- 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)',
175
+ description: 'Read the content of a Google Drive file: Workspace docs and textual types (text/*, JSON/XML/SVG and similar) inline; other binaries return error:binary (returns up to maxChars characters per call; non-Google-native files over 2MB return too_large)',
135
176
  inputSchema: {
136
177
  account: accountEnum.describe('Google account alias'),
137
178
  fileId: z.string().describe('Google Drive file ID'),
@@ -184,6 +225,7 @@ export function registerDriveTools(server) {
184
225
  name,
185
226
  mimeType,
186
227
  error: 'binary',
228
+ hint: BINARY_READ_HINT,
187
229
  webViewLink,
188
230
  }, null, 2),
189
231
  }],
@@ -204,7 +246,7 @@ export function registerDriveTools(server) {
204
246
  }],
205
247
  };
206
248
  }
207
- if (mimeType?.startsWith('text/')) {
249
+ if (mimeType && isTextualMime(mimeType)) {
208
250
  const downloaded = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'text' });
209
251
  return respond(String(downloaded.data));
210
252
  }
@@ -216,6 +258,7 @@ export function registerDriveTools(server) {
216
258
  name,
217
259
  mimeType,
218
260
  error: 'binary',
261
+ hint: BINARY_READ_HINT,
219
262
  webViewLink,
220
263
  }, null, 2),
221
264
  }],
@@ -259,15 +302,10 @@ export function registerDriveTools(server) {
259
302
  description: 'Upload a local file to Google Drive. Pass `convertTo` to import it as a native, editable Google Doc/Sheet/Slides/Drawing instead of storing the raw bytes.',
260
303
  inputSchema: {
261
304
  account: accountEnum.describe('Google account alias'),
262
- localPath: z.string().describe('Absolute path to file on disk'),
305
+ localPath: z.string().describe('Absolute path of the SOURCE file on disk to upload (on the machine running the server; this is not savePath)'),
263
306
  filename: z.string().describe('Name as it appears in Drive'),
264
307
  mimeType: z.string().optional().describe('Source MIME type of the local file (inferred from extension if omitted). With `convertTo`, this is the format Drive imports from.'),
265
- convertTo: z.enum([
266
- 'application/vnd.google-apps.document',
267
- 'application/vnd.google-apps.spreadsheet',
268
- 'application/vnd.google-apps.presentation',
269
- 'application/vnd.google-apps.drawing',
270
- ]).optional().describe('Convert the upload into this native Google Workspace type on import (e.g. upload .md/.html/.docx/.txt with convertTo=...google-apps.document to get a real Google Doc). Source must be an importable format. Omit to store the file as-is.'),
308
+ convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('Convert the upload into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted). E.g. upload .md/.html/.docx/.txt with convertTo=document to get a real Google Doc. Source must be an importable format. Omit to store the file as-is.'),
271
309
  parentFolderId: z.string().optional().describe('Parent folder ID (defaults to My Drive root)'),
272
310
  },
273
311
  }, async ({ account, localPath, filename, mimeType: mimeTypeArg, convertTo, parentFolderId }) => {
@@ -275,13 +313,13 @@ export function registerDriveTools(server) {
275
313
  const auth = await getClient(account);
276
314
  const drive = driveClient({ version: 'v3', auth });
277
315
  const resolvedMime = mimeTypeArg ?? (mime.lookup(localPath) || 'application/octet-stream');
278
- const fileStream = fs.createReadStream(localPath);
316
+ const fileStream = await openLocalReadStream(localPath);
279
317
  const res = await drive.files.create({
280
318
  requestBody: {
281
319
  name: filename,
282
320
  parents: parentFolderId ? [parentFolderId] : undefined,
283
321
  // Setting a google-apps target type makes Drive convert the media on import.
284
- ...(convertTo ? { mimeType: convertTo } : {}),
322
+ ...(convertTo ? { mimeType: resolveConvertTarget(convertTo) } : {}),
285
323
  },
286
324
  media: {
287
325
  mimeType: resolvedMime,
@@ -303,14 +341,17 @@ export function registerDriveTools(server) {
303
341
  inputSchema: {
304
342
  account: accountEnum.describe('Google account alias'),
305
343
  fileId: z.string().describe('Google Drive file ID'),
306
- savePath: z.string().describe('Absolute directory path to save into'),
307
- filename: z.string().describe('Filename to save as'),
344
+ savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server); the file name comes from `filename`'),
345
+ filename: z.string().optional().describe('Filename to save as (defaults to the file name in Drive)'),
308
346
  },
309
347
  }, async ({ account, fileId, savePath, filename }) => {
310
348
  try {
311
349
  const auth = await getClient(account);
312
350
  const drive = driveClient({ version: 'v3', auth });
313
- const dest = prepareLocalDest(savePath, filename);
351
+ const name = filename
352
+ ?? (await drive.files.get({ fileId, fields: 'name', supportsAllDrives: true })).data.name
353
+ ?? fileId;
354
+ const dest = prepareLocalDest(savePath, name);
314
355
  const res = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'stream' });
315
356
  // pipeline destroys both streams on source/sink error; raw .pipe leaks the partial file.
316
357
  await pipeline(res.data, fs.createWriteStream(dest, { mode: 0o600 }));
@@ -329,14 +370,20 @@ export function registerDriveTools(server) {
329
370
  account: accountEnum.describe('Google account alias'),
330
371
  fileId: z.string().describe('Google Drive file ID'),
331
372
  mimeType: z.string().describe('Target export MIME type (e.g. "application/pdf", "text/markdown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")'),
332
- savePath: z.string().describe('Absolute directory path to save into'),
333
- filename: z.string().describe('Filename to save as'),
373
+ savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server); the file name comes from `filename`'),
374
+ filename: z.string().optional().describe('Filename to save as (defaults to the Drive name plus the extension implied by mimeType)'),
334
375
  },
335
376
  }, async ({ account, fileId, mimeType: exportMime, savePath, filename }) => {
336
377
  try {
337
378
  const auth = await getClient(account);
338
379
  const drive = driveClient({ version: 'v3', auth });
339
- const dest = prepareLocalDest(savePath, filename);
380
+ let name = filename;
381
+ if (!name) {
382
+ const meta = await drive.files.get({ fileId, fields: 'name', supportsAllDrives: true });
383
+ const ext = mime.extension(exportMime);
384
+ name = `${meta.data.name ?? fileId}${ext ? `.${ext}` : ''}`;
385
+ }
386
+ const dest = prepareLocalDest(savePath, name);
340
387
  const res = await drive.files.export({ fileId, mimeType: exportMime }, { responseType: 'stream' });
341
388
  // pipeline destroys both streams on source/sink error; raw .pipe leaks the partial file.
342
389
  await pipeline(res.data, fs.createWriteStream(dest, { mode: 0o600 }));
@@ -384,14 +431,9 @@ export function registerDriveTools(server) {
384
431
  fileId: z.string().describe('Google Drive file ID'),
385
432
  newName: z.string().optional().describe('New filename'),
386
433
  newParentFolderId: z.string().optional().describe('Move to this folder'),
387
- localPath: z.string().optional().describe('Replace file content with this local file'),
434
+ localPath: z.string().optional().describe('Replace file content with this local file (path on the machine running the server)'),
388
435
  mimeType: z.string().optional().describe('MIME type of the replacement file (required if localPath is provided)'),
389
- convertTo: z.enum([
390
- 'application/vnd.google-apps.document',
391
- 'application/vnd.google-apps.spreadsheet',
392
- 'application/vnd.google-apps.presentation',
393
- 'application/vnd.google-apps.drawing',
394
- ]).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import (e.g. replace a Google Doc body from a local .docx). Source must be an importable format.'),
436
+ convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted).'),
395
437
  },
396
438
  }, async ({ account, fileId, newName, newParentFolderId, localPath: localPathArg, mimeType: mimeTypeArg, convertTo }) => {
397
439
  try {
@@ -414,10 +456,10 @@ export function registerDriveTools(server) {
414
456
  if (localPathArg) {
415
457
  params.media = {
416
458
  mimeType: mimeTypeArg ?? (mime.lookup(localPathArg) || 'application/octet-stream'),
417
- body: fs.createReadStream(localPathArg),
459
+ body: await openLocalReadStream(localPathArg),
418
460
  };
419
461
  if (convertTo)
420
- requestBody.mimeType = convertTo;
462
+ requestBody.mimeType = resolveConvertTarget(convertTo);
421
463
  }
422
464
  const res = await drive.files.update(params);
423
465
  return {
@@ -1440,7 +1482,7 @@ async function downloadAndUpload(sourceDrive, targetDrive, fileId, sourceMime, p
1440
1482
  },
1441
1483
  media: {
1442
1484
  mimeType: plan.kind === 'native' ? plan.exportMime : (sourceMime ?? 'application/octet-stream'),
1443
- body: fs.createReadStream(tmp),
1485
+ body: await openLocalReadStream(tmp),
1444
1486
  },
1445
1487
  supportsAllDrives: true,
1446
1488
  fields: 'id,name,mimeType,webViewLink',
@@ -5,6 +5,7 @@ import { accountAliasSchema } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, mapGoogleError } from './_errors.js';
7
7
  import { buildReplyHeaders, composeRaw, renderMarkdown, htmlToMarkdown, HeaderInjectionError } from './gmail-mime.js';
8
+ import { prepareLocalDest } from './_local-files.js';
8
9
  import addressparser from 'nodemailer/lib/addressparser/index.js';
9
10
  import { lookup as lookupMime } from 'mime-types';
10
11
  import { configDir } from '../config-file.js';
@@ -664,7 +665,7 @@ export function registerGmailTools(server) {
664
665
  messageId: z.string().describe('The Gmail message ID'),
665
666
  attachmentId: z.string().describe('The attachment ID from gmail_read response'),
666
667
  filename: z.string().describe('Filename to save as (e.g. report.xlsx)'),
667
- savePath: z.string().describe('Absolute directory path to save into, e.g. /home/user/Downloads'),
668
+ savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server), e.g. /home/user/Downloads; the file name comes from `filename`'),
668
669
  },
669
670
  }, async ({ account, messageId, attachmentId, filename, savePath }) => {
670
671
  try {
@@ -679,8 +680,7 @@ export function registerGmailTools(server) {
679
680
  if (!data)
680
681
  throw new Error('No attachment data returned');
681
682
  const buffer = Buffer.from(data, 'base64url');
682
- // Strip path components so callers can't escape savePath via "../".
683
- const fullPath = path.join(savePath, path.basename(filename));
683
+ const fullPath = prepareLocalDest(savePath, filename);
684
684
  await fs.promises.writeFile(fullPath, buffer, { mode: 0o600 });
685
685
  return {
686
686
  content: [{ type: 'text', text: `Saved to ${fullPath} (${buffer.length} bytes)` }],
@@ -2,9 +2,12 @@ import type { ToolRegistry } from '../registry.js';
2
2
  import { type Policy } from '../write-control.js';
3
3
  import { getClient } from '../client.js';
4
4
  import { type Toolsets } from '../toolsets.js';
5
- import { type DiscoveryDeps } from '../discovery-client.js';
5
+ import { type DiscoveryDeps, type DiscoveryMethod } from '../discovery-client.js';
6
6
  export interface EscapeDeps extends DiscoveryDeps {
7
7
  getClientFn?: typeof getClient;
8
8
  toolsets?: Toolsets;
9
9
  }
10
+ /** Up to three closest known ids for the unknown_method did-you-mean hint;
11
+ * bounded distance so unrelated ids never masquerade as suggestions. */
12
+ export declare function nearestMethodIds(methodId: string, index: DiscoveryMethod[]): string[];
10
13
  export declare function registerEscapeTools(registry: ToolRegistry, policy: Policy, deps?: EscapeDeps): void;
@@ -4,6 +4,7 @@ import { accountAliasSchema } from '../accounts.js';
4
4
  import { getClient } from '../client.js';
5
5
  import { coerceJson } from './_coerce.js';
6
6
  import { getToolsets, toolsetEnabled } from '../toolsets.js';
7
+ import { editDistance } from '../scope-catalog.js';
7
8
  import { executeApiMethod, jsonResult } from '../executor.js';
8
9
  import { WORKSPACE_APIS, cudFromMethod, loadMethodIndex, searchMethods, } from '../discovery-client.js';
9
10
  const accountEnum = accountAliasSchema.optional();
@@ -29,6 +30,17 @@ const SERVICE_FOR_ALIAS = {
29
30
  admin_datatransfer: 'admin',
30
31
  groupssettings: 'groupssettings',
31
32
  };
33
+ /** Up to three closest known ids for the unknown_method did-you-mean hint;
34
+ * bounded distance so unrelated ids never masquerade as suggestions. */
35
+ export function nearestMethodIds(methodId, index) {
36
+ const maxDist = Math.max(3, Math.floor(methodId.length / 3));
37
+ return index
38
+ .map((m) => ({ id: m.id, d: editDistance(methodId.toLowerCase(), m.id.toLowerCase()) }))
39
+ .filter((x) => x.d <= maxDist)
40
+ .sort((a, b) => a.d - b.d)
41
+ .slice(0, 3)
42
+ .map((x) => x.id);
43
+ }
32
44
  function describeMethod(m) {
33
45
  return {
34
46
  api: m.api,
@@ -127,12 +139,25 @@ export function registerEscapeTools(registry, policy, deps = {}) {
127
139
  catch (err) {
128
140
  return jsonResult({ error: 'discovery_unavailable', message: err.message, retriable: true, account }, true);
129
141
  }
130
- const method = index.find((m) => m.id === methodId);
142
+ let method = index.find((m) => m.id === methodId);
143
+ if (!method) {
144
+ // Some discovery docs keep a legacy id prefix (the searchconsole doc's
145
+ // methods are webmasters.*): when the caller prefixed with our api
146
+ // alias, retry under the doc's own prefix before failing.
147
+ const docPrefix = index[0]?.id.split('.')[0];
148
+ const [head, ...rest] = String(methodId).split('.');
149
+ if (docPrefix && head === api && head !== docPrefix && rest.length > 0) {
150
+ const swapped = [docPrefix, ...rest].join('.');
151
+ method = index.find((m) => m.id === swapped);
152
+ }
153
+ }
131
154
  if (!method) {
155
+ const near = nearestMethodIds(String(methodId), index);
132
156
  return jsonResult({
133
157
  error: 'unknown_method',
134
158
  message: `No method "${methodId}" in ${api}.`,
135
- hint: `Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
159
+ hint: `${near.length ? `Did you mean: ${near.join(', ')}? ` : ''}` +
160
+ `Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
136
161
  retriable: false,
137
162
  account,
138
163
  }, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.1",
3
+ "version": "6.0.0-alpha.11",
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",
@@ -61,22 +61,22 @@
61
61
  "pack:mcpb": "mcpb pack . mcp-google-multi.mcpb"
62
62
  },
63
63
  "dependencies": {
64
- "@googleapis/admin": "^33.0.0",
65
- "@googleapis/calendar": "^16.0.0",
66
- "@googleapis/chat": "^47.0.0",
67
- "@googleapis/docs": "^10.0.0",
68
- "@googleapis/drive": "^22.0.0",
69
- "@googleapis/forms": "^7.0.0",
70
- "@googleapis/gmail": "^18.0.0",
71
- "@googleapis/meet": "^5.0.0",
72
- "@googleapis/people": "^8.0.0",
73
- "@googleapis/searchconsole": "^7.0.0",
74
- "@googleapis/sheets": "^14.0.0",
75
- "@googleapis/slides": "^6.0.0",
76
- "@googleapis/tasks": "^13.0.0",
77
- "@googleapis/webmasters": "^4.0.0",
64
+ "@googleapis/admin": "^37.0.0",
65
+ "@googleapis/calendar": "^20.0.0",
66
+ "@googleapis/chat": "^51.0.0",
67
+ "@googleapis/docs": "^14.0.0",
68
+ "@googleapis/drive": "^26.0.0",
69
+ "@googleapis/forms": "^11.0.0",
70
+ "@googleapis/gmail": "^22.0.0",
71
+ "@googleapis/meet": "^9.0.0",
72
+ "@googleapis/people": "^12.0.0",
73
+ "@googleapis/searchconsole": "^11.0.0",
74
+ "@googleapis/sheets": "^18.0.0",
75
+ "@googleapis/slides": "^10.0.0",
76
+ "@googleapis/tasks": "^17.0.0",
77
+ "@googleapis/webmasters": "^9.0.0",
78
78
  "@modelcontextprotocol/sdk": "^1.30.0",
79
- "googleapis-common": "^8.0.3",
79
+ "googleapis-common": "^9.0.0",
80
80
  "jose": "^6.2.8",
81
81
  "markdown-it": "15.0.0",
82
82
  "mime-types": "^3.0.2",