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 +16 -15
- package/dist/api-probe.d.ts +18 -0
- package/dist/api-probe.js +65 -0
- package/dist/arg-normalize.d.ts +19 -0
- package/dist/arg-normalize.js +90 -0
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +98 -15
- package/dist/http-transport.d.ts +3 -0
- package/dist/http-transport.js +2 -1
- package/dist/index.js +6 -1
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/registry.d.ts +5 -0
- package/dist/registry.js +47 -3
- package/dist/scope-catalog.d.ts +1 -0
- package/dist/scope-catalog.js +1 -1
- package/dist/tools/_errors.js +51 -0
- package/dist/tools/_local-files.d.ts +3 -0
- package/dist/tools/_local-files.js +29 -0
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +75 -33
- package/dist/tools/gmail.js +3 -3
- package/dist/tools/google-api.d.ts +4 -1
- package/dist/tools/google-api.js +27 -2
- package/package.json +16 -16
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
|
+
}
|
|
@@ -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
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 =
|
|
295
|
+
const http = await sectionHttp(deps, aliases);
|
|
213
296
|
if (http)
|
|
214
297
|
sections.push(http);
|
|
215
298
|
return { verdict: overallVerdict(sections), sections };
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -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
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -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;
|
package/dist/scope-catalog.js
CHANGED
|
@@ -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;
|
package/dist/tools/_errors.js
CHANGED
|
@@ -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,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
|
+
}
|
package/dist/tools/drive.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
|
-
export declare function
|
|
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;
|
package/dist/tools/drive.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1485
|
+
body: await openLocalReadStream(tmp),
|
|
1444
1486
|
},
|
|
1445
1487
|
supportsAllDrives: true,
|
|
1446
1488
|
fields: 'id,name,mimeType,webViewLink',
|
package/dist/tools/gmail.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/tools/google-api.js
CHANGED
|
@@ -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
|
-
|
|
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: `
|
|
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.
|
|
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": "^
|
|
65
|
-
"@googleapis/calendar": "^
|
|
66
|
-
"@googleapis/chat": "^
|
|
67
|
-
"@googleapis/docs": "^
|
|
68
|
-
"@googleapis/drive": "^
|
|
69
|
-
"@googleapis/forms": "^
|
|
70
|
-
"@googleapis/gmail": "^
|
|
71
|
-
"@googleapis/meet": "^
|
|
72
|
-
"@googleapis/people": "^
|
|
73
|
-
"@googleapis/searchconsole": "^
|
|
74
|
-
"@googleapis/sheets": "^
|
|
75
|
-
"@googleapis/slides": "^
|
|
76
|
-
"@googleapis/tasks": "^
|
|
77
|
-
"@googleapis/webmasters": "^
|
|
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": "^
|
|
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",
|