mcp-google-multi 6.0.0-alpha.1 → 6.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -15
- package/dist/api-probe.d.ts +18 -0
- package/dist/api-probe.js +65 -0
- package/dist/doctor.js +5 -0
- package/dist/index.js +2 -0
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/tools/_errors.js +35 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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. **
|
|
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. **
|
|
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
|
-
#
|
|
31
|
-
GOOGLE_ACCOUNTS=
|
|
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
|
-
|
|
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
|
|
40
|
-
mcp-google-multi auth --account personal
|
|
39
|
+
mcp-google-multi auth --account me
|
|
41
40
|
```
|
|
42
41
|
|
|
43
|
-
5. **
|
|
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
|
-
|
|
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
|
-
|
|
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) · [
|
|
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
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -6,6 +6,7 @@ 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';
|
|
9
10
|
const MIN_NODE_MAJOR = 22;
|
|
10
11
|
const DEFAULT_DEPS = {
|
|
11
12
|
nodeVersion: process.versions.node,
|
|
@@ -23,6 +24,7 @@ const DEFAULT_DEPS = {
|
|
|
23
24
|
masterKeyProvenance: () => peekMasterKeyProvenance(),
|
|
24
25
|
anyTokensExist: (aliases) => aliases.some((a) => hasToken(a)),
|
|
25
26
|
fileExists: fs.existsSync,
|
|
27
|
+
probeApi: (alias) => probeApiEnablement(alias),
|
|
26
28
|
};
|
|
27
29
|
/** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
|
|
28
30
|
export function apiEnableLink(api) {
|
|
@@ -163,6 +165,9 @@ async function sectionApiEnablement(deps, aliases) {
|
|
|
163
165
|
// Network / transient: WARN with the target, never crash the report.
|
|
164
166
|
return { id: 6, title: 'API enablement', verdict: 'warn', lines: [`Probe could not complete: ${e?.message ?? e}`] };
|
|
165
167
|
}
|
|
168
|
+
if (results.length === 0) {
|
|
169
|
+
return { id: 6, title: 'API enablement', verdict: 'unknown', lines: [`No probeable service scopes granted on "${healthy}".`] };
|
|
170
|
+
}
|
|
166
171
|
const disabled = results.filter((r) => r.notEnabled);
|
|
167
172
|
const lines = results.map((r) => `${r.service}: ${r.ok ? 'enabled' : r.notEnabled ? 'NOT ENABLED' : `unknown (${r.message ?? 'error'})`}`);
|
|
168
173
|
if (disabled.length > 0) {
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,8 @@ 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
|
+
applyNetTuning();
|
|
22
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
25
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
24
26
|
function buildRegistry(server, ctx, mode) {
|
|
@@ -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
|
+
}
|
package/dist/tools/_errors.js
CHANGED
|
@@ -12,6 +12,28 @@ 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
|
+
/** First known network code on the error or its cause chain (GaxiosError.cause
|
|
24
|
+
* -> FetchError; undici TypeError.cause -> AggregateError.errors). */
|
|
25
|
+
function netCodeOf(error) {
|
|
26
|
+
for (let e = error, depth = 0; e && depth < 5; e = e.cause ?? e.error, depth++) {
|
|
27
|
+
if (typeof e.code === 'string' && NET_CODES.has(e.code))
|
|
28
|
+
return e.code;
|
|
29
|
+
if (Array.isArray(e.errors)) {
|
|
30
|
+
const sub = e.errors.find((x) => typeof x?.code === 'string' && NET_CODES.has(x.code));
|
|
31
|
+
if (sub)
|
|
32
|
+
return sub.code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
15
37
|
/** Console deep-link to enable one API (noob-proofing hint, B10). */
|
|
16
38
|
function apiEnableLink(api) {
|
|
17
39
|
return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
|
|
@@ -114,6 +136,19 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
|
|
|
114
136
|
if (status !== undefined && status >= 500) {
|
|
115
137
|
return { error: 'upstream_error', message, retriable: true, account };
|
|
116
138
|
}
|
|
139
|
+
if (status === undefined) {
|
|
140
|
+
const netCode = netCodeOf(error);
|
|
141
|
+
if (netCode) {
|
|
142
|
+
return {
|
|
143
|
+
error: 'network_error',
|
|
144
|
+
message: message.includes(netCode) ? message : message.endsWith('reason: ') ? `${message}${netCode}` : `${message} (${netCode})`,
|
|
145
|
+
hint: `Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
|
|
146
|
+
'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.',
|
|
147
|
+
retriable: RETRIABLE_NET_CODES.has(netCode),
|
|
148
|
+
account,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
117
152
|
return { error: 'upstream_error', message, retriable: false, account };
|
|
118
153
|
}
|
|
119
154
|
export function handleGoogleApiError(error, account, forbiddenHint, scopeContext) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.3",
|
|
4
4
|
"description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|