mcp-google-multi 6.0.0-alpha.9 → 6.0.0-beta.1
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 +1 -1
- package/dist/arg-normalize.d.ts +19 -0
- package/dist/arg-normalize.js +90 -0
- package/dist/http-transport.d.ts +3 -0
- package/dist/http-transport.js +2 -1
- package/dist/index.js +4 -1
- package/dist/registry.d.ts +5 -0
- package/dist/registry.js +33 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
|
|
|
7
7
|
- 🧰 **Exhaustive** — 874 tools across 28 services + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
|
|
8
8
|
- 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
|
|
9
9
|
- 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
|
|
10
|
-
- 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI) → [remote setup](./docs/http-setup.md)
|
|
10
|
+
- 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI). Pull-and-up Docker Compose with optional automatic HTTPS → [remote setup](./docs/http-setup.md)
|
|
11
11
|
- ✉️ **Built for real work** — send and read email in Markdown with attachments and one-call replies, an interactive setup wizard with a `doctor` self-check, and per-account scope profiles → [features tour](./docs/features.md)
|
|
12
12
|
|
|
13
13
|
## Quick setup
|
|
@@ -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/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
|
@@ -20,6 +20,7 @@ import { isAllowed, describePolicy } from './write-control.js';
|
|
|
20
20
|
import { buildIdentityContext } from './identity.js';
|
|
21
21
|
import { registerSetupPrompt } from './setup-prompt.js';
|
|
22
22
|
import { applyNetTuning } from './net-tuning.js';
|
|
23
|
+
import { argNormalizationEnabled, withArgNormalization } from './arg-normalize.js';
|
|
23
24
|
applyNetTuning();
|
|
24
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
@@ -189,7 +190,8 @@ async function main() {
|
|
|
189
190
|
const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }));
|
|
190
191
|
registry.installListHandler();
|
|
191
192
|
registerSetupPrompt(server);
|
|
192
|
-
|
|
193
|
+
const stdioTransport = new StdioServerTransport();
|
|
194
|
+
await server.connect(argNormalizationEnabled() ? withArgNormalization(stdioTransport, (n) => registry.argShape(n)) : stdioTransport);
|
|
193
195
|
}
|
|
194
196
|
if (wantHttp) {
|
|
195
197
|
const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
|
|
@@ -280,6 +282,7 @@ async function main() {
|
|
|
280
282
|
authenticate: authServer.authenticate,
|
|
281
283
|
routes: authServer.routes,
|
|
282
284
|
log: (l) => process.stderr.write(`[http] ${l}\n`),
|
|
285
|
+
argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
|
|
283
286
|
});
|
|
284
287
|
await host.start();
|
|
285
288
|
process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
|
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
|
@@ -38,6 +38,23 @@ const SERVICE_OVERRIDES = {
|
|
|
38
38
|
};
|
|
39
39
|
// read tools that write local files — same savePath fanned across accounts would clobber
|
|
40
40
|
const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
|
|
41
|
+
/** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
|
|
42
|
+
function scalarKindOf(field) {
|
|
43
|
+
let cur = field;
|
|
44
|
+
for (let i = 0; i < 4 && cur?._zod?.def; i++) {
|
|
45
|
+
const def = cur._zod.def;
|
|
46
|
+
if (def.type === 'number')
|
|
47
|
+
return 'number';
|
|
48
|
+
if (def.type === 'boolean')
|
|
49
|
+
return 'boolean';
|
|
50
|
+
if (def.type === 'optional' || def.type === 'default' || def.type === 'nullable') {
|
|
51
|
+
cur = def.innerType;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
return 'other';
|
|
55
|
+
}
|
|
56
|
+
return 'other';
|
|
57
|
+
}
|
|
41
58
|
function isAccountEnum(field) {
|
|
42
59
|
const def = field?._zod?.def;
|
|
43
60
|
if (!def)
|
|
@@ -69,6 +86,7 @@ export class ToolRegistry {
|
|
|
69
86
|
registerTool;
|
|
70
87
|
revealed = new Set();
|
|
71
88
|
jsonSchemaCache = new Map();
|
|
89
|
+
argShapeCache = new Map();
|
|
72
90
|
compactOutput = trimEnabled();
|
|
73
91
|
registeringMeta = false;
|
|
74
92
|
/** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
|
|
@@ -192,6 +210,21 @@ export class ToolRegistry {
|
|
|
192
210
|
services() {
|
|
193
211
|
return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
|
|
194
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
|
+
}
|
|
195
228
|
catalog(service, query) {
|
|
196
229
|
const q = query?.trim().toLowerCase();
|
|
197
230
|
return this.tools
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-
|
|
3
|
+
"version": "6.0.0-beta.1",
|
|
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",
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"gen:discovery": "tsx scripts/fetch-discovery.ts",
|
|
59
59
|
"gen:tools": "tsx scripts/gen-tools.ts",
|
|
60
60
|
"gen:coverage": "tsx scripts/gen-coverage.ts",
|
|
61
|
-
"pack:mcpb": "mcpb pack . mcp-google-multi.mcpb"
|
|
61
|
+
"pack:mcpb": "mcpb pack . mcp-google-multi.mcpb",
|
|
62
|
+
"bundle": "esbuild dist/index.js --bundle --platform=node --format=esm --outfile=bundle/index.js --external:@napi-rs/keyring --banner:js=\"import{createRequire as __dockerCR}from'node:module';var require=__dockerCR(import.meta.url);\" --log-level=warning"
|
|
62
63
|
},
|
|
63
64
|
"dependencies": {
|
|
64
65
|
"@googleapis/admin": "^37.0.0",
|
|
@@ -88,12 +89,14 @@
|
|
|
88
89
|
"devDependencies": {
|
|
89
90
|
"@anthropic-ai/mcpb": "^2.1.2",
|
|
90
91
|
"@eslint/js": "^10.0.1",
|
|
92
|
+
"@semantic-release/exec": "^7.1.0",
|
|
91
93
|
"@types/js-yaml": "^4.0.9",
|
|
92
94
|
"@types/markdown-it": "^14.1.2",
|
|
93
95
|
"@types/mime-types": "^3.0.1",
|
|
94
96
|
"@types/node": "^22.20.1",
|
|
95
97
|
"@types/nodemailer": "^8.0.1",
|
|
96
98
|
"@types/turndown": "^5.0.6",
|
|
99
|
+
"esbuild": "^0.28.2",
|
|
97
100
|
"eslint": "^10.2.1",
|
|
98
101
|
"js-yaml": "^4.3.2",
|
|
99
102
|
"semantic-release": "^25.0.3",
|