mcp-google-multi 6.0.0-alpha.9 → 6.0.0-beta.2
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 +6 -4
- package/dist/accounts.d.ts +5 -0
- package/dist/accounts.js +12 -1
- package/dist/api-probe.js +3 -0
- package/dist/arg-normalize.d.ts +76 -0
- package/dist/arg-normalize.js +314 -0
- package/dist/arg-strict.d.ts +54 -0
- package/dist/arg-strict.js +163 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +15 -5
- package/dist/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- package/dist/discover.js +35 -14
- package/dist/discovery-client.d.ts +8 -1
- package/dist/discovery-client.js +96 -17
- package/dist/doctor.d.ts +9 -1
- package/dist/doctor.js +32 -4
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.js +12 -1
- package/dist/fanout.js +43 -8
- package/dist/http-transport.d.ts +13 -1
- package/dist/http-transport.js +10 -4
- package/dist/index.js +113 -10
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +108 -0
- package/dist/registry.d.ts +54 -2
- package/dist/registry.js +180 -7
- package/dist/scope-catalog.js +16 -0
- package/dist/services.d.ts +8 -0
- package/dist/services.js +28 -1
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- package/dist/tools/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +31 -3
- package/dist/tools/_errors.js +307 -43
- package/dist/tools/_local-files.js +7 -2
- package/dist/tools/account-wizard.d.ts +24 -1
- package/dist/tools/account-wizard.js +152 -51
- package/dist/tools/admin.js +7 -6
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +284 -0
- package/dist/tools/calendar.d.ts +7 -0
- package/dist/tools/calendar.js +75 -15
- package/dist/tools/chat.js +7 -4
- package/dist/tools/contacts.js +57 -30
- package/dist/tools/docs.js +56 -54
- package/dist/tools/drive.js +132 -70
- package/dist/tools/forms.js +7 -7
- package/dist/tools/generated/_shared.d.ts +6 -0
- package/dist/tools/generated/_shared.js +11 -1
- package/dist/tools/generated/admin.js +345 -214
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.js +4 -4
- package/dist/tools/generated/calendar.js +28 -28
- package/dist/tools/generated/chat.js +83 -56
- package/dist/tools/generated/classroom.js +258 -216
- package/dist/tools/generated/cloudidentity.js +76 -68
- package/dist/tools/generated/cloudsearch.js +40 -39
- package/dist/tools/generated/contacts.js +20 -11
- package/dist/tools/generated/drive.js +62 -53
- package/dist/tools/generated/drivelabels.js +60 -32
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.js +136 -109
- package/dist/tools/generated/groupsmigration.js +1 -1
- package/dist/tools/generated/groupssettings.js +3 -3
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.js +8 -7
- package/dist/tools/generated/licensing.js +39 -23
- package/dist/tools/generated/meet.js +14 -14
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.js +3 -3
- package/dist/tools/generated/reseller.js +34 -28
- package/dist/tools/generated/script.js +29 -19
- package/dist/tools/generated/searchconsole.js +5 -3
- package/dist/tools/generated/sheets.js +10 -9
- package/dist/tools/generated/tasks.js +10 -4
- package/dist/tools/generated/vault.js +65 -56
- package/dist/tools/generated/workspaceevents.js +17 -16
- package/dist/tools/gmail.d.ts +10 -1
- package/dist/tools/gmail.js +50 -18
- package/dist/tools/google-api.js +48 -19
- package/dist/tools/meet.js +5 -2
- package/dist/tools/searchconsole.js +10 -10
- package/dist/tools/sheets.js +36 -48
- package/dist/tools/slides.js +6 -6
- package/dist/tools/tasks.js +16 -16
- package/dist/trim.d.ts +21 -0
- package/dist/trim.js +47 -0
- package/dist/usage-metrics.d.ts +155 -0
- package/dist/usage-metrics.js +603 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +90 -11
- package/package.json +12 -4
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Opt-in outbound recipient allowlist for unattended deployments: a
|
|
2
|
+
// prompt-injection blast-radius control (a hijacked agent cannot mail, invite
|
|
3
|
+
// or share outside the operator's list), a safety feature first and an
|
|
4
|
+
// enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
|
|
5
|
+
// Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
|
|
6
|
+
/** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
|
|
7
|
+
* suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
|
|
8
|
+
export function resolveOutboundAllowlist(env = process.env) {
|
|
9
|
+
const raw = env.GOOGLE_OUTBOUND_ALLOWLIST;
|
|
10
|
+
if (raw === undefined || raw.trim() === '')
|
|
11
|
+
return null;
|
|
12
|
+
const entries = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
13
|
+
if (entries.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
const exact = new Set(entries.filter((e) => !e.startsWith('@')));
|
|
16
|
+
const domains = entries.filter((e) => e.startsWith('@'));
|
|
17
|
+
return {
|
|
18
|
+
entries,
|
|
19
|
+
allows(email) {
|
|
20
|
+
const a = email.trim().toLowerCase();
|
|
21
|
+
if (exact.has(a))
|
|
22
|
+
return true;
|
|
23
|
+
return domains.some((d) => a.endsWith(d));
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The addresses in `emails` the active allowlist rejects; [] when off. */
|
|
28
|
+
export function outboundViolations(emails, env = process.env) {
|
|
29
|
+
const list = resolveOutboundAllowlist(env);
|
|
30
|
+
if (!list)
|
|
31
|
+
return [];
|
|
32
|
+
return emails.map((e) => e.trim()).filter(Boolean).filter((e) => !list.allows(e));
|
|
33
|
+
}
|
|
34
|
+
/** The standard error envelope for a blocked outbound target. */
|
|
35
|
+
export function outboundDeniedEnvelope(kind, blocked, account, env = process.env) {
|
|
36
|
+
const list = resolveOutboundAllowlist(env);
|
|
37
|
+
return {
|
|
38
|
+
content: [
|
|
39
|
+
{
|
|
40
|
+
type: 'text',
|
|
41
|
+
text: JSON.stringify({
|
|
42
|
+
error: 'recipient_not_allowed',
|
|
43
|
+
message: `${kind} blocked by the outbound allowlist: ${blocked.join(', ')}.`,
|
|
44
|
+
hint: `GOOGLE_OUTBOUND_ALLOWLIST is active (${(list?.entries ?? []).join(', ')}). ` +
|
|
45
|
+
'The operator must add the address (or its @domain) to the list, or unset the variable, to allow this target.',
|
|
46
|
+
retriable: false,
|
|
47
|
+
account,
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
isError: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Convenience: envelope when violations exist, else null. */
|
|
55
|
+
export function checkOutbound(kind, emails, account, env = process.env) {
|
|
56
|
+
const blocked = outboundViolations(emails, env);
|
|
57
|
+
return blocked.length > 0 ? outboundDeniedEnvelope(kind, blocked, account, env) : null;
|
|
58
|
+
}
|
|
59
|
+
// Escape-hatch / generated-tool enforcement. Structured bodies are inspected
|
|
60
|
+
// for the known recipient fields; RAW compose methods cannot be inspected
|
|
61
|
+
// (base64 RFC 822), so with the allowlist active they are refused outright in
|
|
62
|
+
// favor of the curated tools that enforce it.
|
|
63
|
+
const RAW_SEND_METHODS = new Set(['gmail.users.messages.send', 'gmail.users.drafts.send', 'gmail.users.drafts.create', 'gmail.users.messages.insert', 'gmail.users.messages.import']);
|
|
64
|
+
export function collectBodyRecipients(body) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const walk = (v, depth) => {
|
|
67
|
+
if (!v || typeof v !== 'object' || depth > 4)
|
|
68
|
+
return;
|
|
69
|
+
if (Array.isArray(v)) {
|
|
70
|
+
for (const x of v)
|
|
71
|
+
walk(x, depth + 1);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const [k, x] of Object.entries(v)) {
|
|
75
|
+
if ((k === 'emailAddress' || k === 'email') && typeof x === 'string' && x.includes('@'))
|
|
76
|
+
out.push(x);
|
|
77
|
+
else if (k === 'attendees' || k === 'permissions')
|
|
78
|
+
walk(x, depth + 1);
|
|
79
|
+
else if (typeof x === 'object')
|
|
80
|
+
walk(x, depth + 1);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
walk(body, 0);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** Gate one escape-hatch/generated dispatch; envelope when blocked, else null. */
|
|
87
|
+
export function checkOutboundForMethod(methodId, body, account, env = process.env) {
|
|
88
|
+
if (!resolveOutboundAllowlist(env))
|
|
89
|
+
return null;
|
|
90
|
+
if (RAW_SEND_METHODS.has(methodId)) {
|
|
91
|
+
return {
|
|
92
|
+
content: [
|
|
93
|
+
{
|
|
94
|
+
type: 'text',
|
|
95
|
+
text: JSON.stringify({
|
|
96
|
+
error: 'recipient_not_allowed',
|
|
97
|
+
message: `${methodId} carries an uninspectable raw message while GOOGLE_OUTBOUND_ALLOWLIST is active.`,
|
|
98
|
+
hint: 'Use gmail_send / gmail_create_draft, which enforce the allowlist on parsed recipients.',
|
|
99
|
+
retriable: false,
|
|
100
|
+
account,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
isError: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return checkOutbound(`${methodId} recipient`, collectBodyRecipients(body), account, env);
|
|
108
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { McpServer } from
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { type Policy } from './write-control.js';
|
|
4
|
+
import type { ArgShape } from './arg-normalize.js';
|
|
5
|
+
import type { Metrics } from './usage-metrics.js';
|
|
4
6
|
export type Cud = 'read' | 'create' | 'update' | 'delete';
|
|
5
7
|
export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
|
|
6
8
|
export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
|
|
@@ -29,15 +31,25 @@ export interface CatalogOperation {
|
|
|
29
31
|
summary: string;
|
|
30
32
|
args: string[];
|
|
31
33
|
cud: Cud;
|
|
34
|
+
/** Present only when no scope bundle in the catalog can authorize this
|
|
35
|
+
* method, so the call cannot succeed on any account however it is
|
|
36
|
+
* configured. The tool stays callable: graceful dispatch then returns the
|
|
37
|
+
* real scope error rather than a bare "not found". */
|
|
38
|
+
unreachable?: true;
|
|
32
39
|
}
|
|
33
40
|
export declare function inferCud(name: string): Cud;
|
|
34
41
|
export declare class ToolRegistry {
|
|
35
42
|
private readonly server;
|
|
43
|
+
private readonly metrics;
|
|
36
44
|
readonly tools: ToolEntry[];
|
|
37
45
|
readonly policy: Policy;
|
|
38
46
|
readonly registerTool: McpServer['registerTool'];
|
|
39
47
|
private readonly revealed;
|
|
40
48
|
private readonly jsonSchemaCache;
|
|
49
|
+
private readonly argShapeCache;
|
|
50
|
+
/** Memoized isUngrantable verdict per tool: the answer depends on the frozen
|
|
51
|
+
* bundle catalog only, so it never changes within a process. */
|
|
52
|
+
private readonly ungrantable;
|
|
41
53
|
private readonly compactOutput;
|
|
42
54
|
private registeringMeta;
|
|
43
55
|
/** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
|
|
@@ -45,10 +57,41 @@ export declare class ToolRegistry {
|
|
|
45
57
|
/** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
|
|
46
58
|
* lazy surface to curated without touching the configured mode. */
|
|
47
59
|
private expanded;
|
|
48
|
-
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
|
|
60
|
+
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
|
|
49
61
|
registerMeta: McpServer['registerTool'];
|
|
50
62
|
services(): string[];
|
|
63
|
+
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
64
|
+
* normalization; the kind drives value coercion on renamed keys). */
|
|
65
|
+
argShape(name: string): ArgShape | undefined;
|
|
66
|
+
/** Declared argument keys for a tool, in declaration order; undefined when
|
|
67
|
+
* the tool is not registered. Backs unknown-argument screening, which needs
|
|
68
|
+
* the full key list rather than argShape's scalar-kind subset view. */
|
|
69
|
+
declaredKeys(name: string): readonly string[] | undefined;
|
|
70
|
+
/** Keys spelling a similar concept elsewhere in the same service, for the
|
|
71
|
+
* hint on a call whose key matched nothing. Kept only when a key is declared
|
|
72
|
+
* by at least two tools in the service or by a curated one, so one-off
|
|
73
|
+
* generated parameters do not become advice. */
|
|
74
|
+
siblingSpellings(tool: string, unknownKeys: string[]): Array<{
|
|
75
|
+
key: string;
|
|
76
|
+
tools: string[];
|
|
77
|
+
}>;
|
|
51
78
|
catalog(service: string, query?: string): CatalogOperation[];
|
|
79
|
+
/** The metrics recorder, for hook sites (escape hatch); null when off. */
|
|
80
|
+
get usageMetrics(): Metrics | null;
|
|
81
|
+
/** Every registered tool name, hidden ones included: they stay callable, so
|
|
82
|
+
* a did-you-mean may legitimately point at one. */
|
|
83
|
+
toolNames(): string[];
|
|
84
|
+
/** Membership test against the REGISTERED tool set (hidden tools included:
|
|
85
|
+
* they stay callable). The metrics tap uses this so a client-supplied name
|
|
86
|
+
* can never enter the closed vocabulary. */
|
|
87
|
+
hasTool(name: string): boolean;
|
|
88
|
+
/** Op-name vocabulary for a service, split by provenance so the capped
|
|
89
|
+
* discover descriptions can list curated ops and only summarize the
|
|
90
|
+
* generated long tail. */
|
|
91
|
+
opNames(service: string): {
|
|
92
|
+
curated: string[];
|
|
93
|
+
generated: string[];
|
|
94
|
+
};
|
|
52
95
|
reveal(service: string): boolean;
|
|
53
96
|
/** discover_all: advertise the full curated set at once. Idempotent. */
|
|
54
97
|
expand(): boolean;
|
|
@@ -58,6 +101,15 @@ export declare class ToolRegistry {
|
|
|
58
101
|
* dispatch), zero correctness impact. */
|
|
59
102
|
collapse(): boolean;
|
|
60
103
|
private effectiveMode;
|
|
104
|
+
/**
|
|
105
|
+
* True when NO bundle in the catalog grants any of the method's alternative
|
|
106
|
+
* scopes, so the call cannot succeed on any account under any configuration.
|
|
107
|
+
* Static: it asks the catalog, not an account, so it is safe to cache.
|
|
108
|
+
* `classifyScope` with nothing granted and no profile reports `add_bundle`
|
|
109
|
+
* for a scope some bundle could supply and `unknown_scope` only when none
|
|
110
|
+
* can, which is exactly the distinction wanted here.
|
|
111
|
+
*/
|
|
112
|
+
isUngrantable(tool: ToolEntry): boolean;
|
|
61
113
|
isVisible(tool: ToolEntry): boolean;
|
|
62
114
|
visibleCount(): {
|
|
63
115
|
eager: number;
|
package/dist/registry.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
2
1
|
import { z } from 'zod';
|
|
3
2
|
import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-control.js';
|
|
4
3
|
import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
|
|
5
4
|
import { compactResult, trimEnabled } from './trim.js';
|
|
6
5
|
import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
|
|
7
6
|
import { MAX_RESPONSE_CHARS } from './executor.js';
|
|
7
|
+
import { suggestKeys } from './arg-strict.js';
|
|
8
|
+
import { classifyScope } from './scope-observability.js';
|
|
9
|
+
/** Nothing granted, no profile: asks the BUNDLE CATALOG whether a scope is
|
|
10
|
+
* reachable at all, independently of any account. */
|
|
11
|
+
const EMPTY_SCOPES = new Set();
|
|
8
12
|
// Client-side result budget advertised for tools that do not declare their own
|
|
9
13
|
// (fat readers do; see trim.ts). ~50k chars stays well inside a default client
|
|
10
14
|
// context limit while leaving room for real list payloads.
|
|
@@ -38,6 +42,28 @@ const SERVICE_OVERRIDES = {
|
|
|
38
42
|
};
|
|
39
43
|
// read tools that write local files — same savePath fanned across accounts would clobber
|
|
40
44
|
const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
|
|
45
|
+
/** Account-management tools: the `account` they take is the SUBJECT of the
|
|
46
|
+
* operation, not the identity it runs as, so injecting the configured default
|
|
47
|
+
* would silently retarget them. `account_add` would adopt the default as the
|
|
48
|
+
* new alias, and `account_reauth` would re-authenticate the wrong account. */
|
|
49
|
+
const DEFAULT_ACCOUNT_EXCLUDE = new Set(['account_add', 'account_reauth']);
|
|
50
|
+
/** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
|
|
51
|
+
function scalarKindOf(field) {
|
|
52
|
+
let cur = field;
|
|
53
|
+
for (let i = 0; i < 4 && cur?._zod?.def; i++) {
|
|
54
|
+
const def = cur._zod.def;
|
|
55
|
+
if (def.type === 'number')
|
|
56
|
+
return 'number';
|
|
57
|
+
if (def.type === 'boolean')
|
|
58
|
+
return 'boolean';
|
|
59
|
+
if (def.type === 'optional' || def.type === 'default' || def.type === 'nullable') {
|
|
60
|
+
cur = def.innerType;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
return 'other';
|
|
64
|
+
}
|
|
65
|
+
return 'other';
|
|
66
|
+
}
|
|
41
67
|
function isAccountEnum(field) {
|
|
42
68
|
const def = field?._zod?.def;
|
|
43
69
|
if (!def)
|
|
@@ -64,11 +90,16 @@ export function inferCud(name) {
|
|
|
64
90
|
}
|
|
65
91
|
export class ToolRegistry {
|
|
66
92
|
server;
|
|
93
|
+
metrics;
|
|
67
94
|
tools = [];
|
|
68
95
|
policy;
|
|
69
96
|
registerTool;
|
|
70
97
|
revealed = new Set();
|
|
71
98
|
jsonSchemaCache = new Map();
|
|
99
|
+
argShapeCache = new Map();
|
|
100
|
+
/** Memoized isUngrantable verdict per tool: the answer depends on the frozen
|
|
101
|
+
* bundle catalog only, so it never changes within a process. */
|
|
102
|
+
ungrantable = new Map();
|
|
72
103
|
compactOutput = trimEnabled();
|
|
73
104
|
registeringMeta = false;
|
|
74
105
|
/** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
|
|
@@ -76,8 +107,9 @@ export class ToolRegistry {
|
|
|
76
107
|
/** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
|
|
77
108
|
* lazy surface to curated without touching the configured mode. */
|
|
78
109
|
expanded = false;
|
|
79
|
-
constructor(server, policy, mode = resolveDiscoveryMode()) {
|
|
110
|
+
constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null) {
|
|
80
111
|
this.server = server;
|
|
112
|
+
this.metrics = metrics;
|
|
81
113
|
this.policy = policy;
|
|
82
114
|
this.mode = mode;
|
|
83
115
|
this.registerTool = ((name, config, handler) => {
|
|
@@ -107,7 +139,7 @@ export class ToolRegistry {
|
|
|
107
139
|
// never fan out meta tools: google_api_call infers cud=read but executes writes
|
|
108
140
|
let inputShape = config.inputSchema ?? {};
|
|
109
141
|
let baseHandler = handler;
|
|
110
|
-
const hasAccountField = 'account' in inputShape;
|
|
142
|
+
const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
|
|
111
143
|
if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
|
|
112
144
|
const description = inputShape.account.description ?? 'Google account alias';
|
|
113
145
|
inputShape = { ...inputShape, account: fanoutAccountField(description) };
|
|
@@ -136,9 +168,11 @@ export class ToolRegistry {
|
|
|
136
168
|
});
|
|
137
169
|
const guarded = cud === 'read'
|
|
138
170
|
? baseHandler
|
|
139
|
-
: (...args) => isAllowed({ name, service, cud }, policy)
|
|
171
|
+
: (...args) => isAllowed({ name, service, cud, scopes: config.requiredScopes }, policy)
|
|
140
172
|
? baseHandler(...args)
|
|
141
|
-
: writeDisabledResult({ name, service, cud }, policy
|
|
173
|
+
: writeDisabledResult({ name, service, cud, scopes: config.requiredScopes }, policy,
|
|
174
|
+
// Concrete by here: default-account injection wraps this.
|
|
175
|
+
args[0]?.account);
|
|
142
176
|
// A2: the ONE default-account injection site — outside the CUD gate and
|
|
143
177
|
// the fan-out parse so both observe a concrete alias; NOT meta-skipped
|
|
144
178
|
// (that is what covers google_api_call with zero bespoke code). Explicit
|
|
@@ -176,8 +210,21 @@ export class ToolRegistry {
|
|
|
176
210
|
const finalHandler = this.compactOutput
|
|
177
211
|
? async (...args) => compactResult(await withDefault(...args))
|
|
178
212
|
: withDefault;
|
|
213
|
+
// Usage metrics wrap OUTERMOST and only when enabled: off means no
|
|
214
|
+
// wrapper exists and the chain is byte-identical to the pre-metrics
|
|
215
|
+
// chain. Fan-out width is derived here (bucketed in the module) so the
|
|
216
|
+
// metrics module never imports fanout.
|
|
217
|
+
const instrumented = this.metrics
|
|
218
|
+
? this.metrics.wrap({ name, service, meta: this.registeringMeta, generated: config.cud !== undefined }, finalHandler, (a) => {
|
|
219
|
+
const v = a?.account;
|
|
220
|
+
if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
|
|
221
|
+
return 1;
|
|
222
|
+
const sel = parseAccountSelector(v);
|
|
223
|
+
return sel.ok ? sel.aliases.length : 1;
|
|
224
|
+
})
|
|
225
|
+
: finalHandler;
|
|
179
226
|
const { cud: _cud, ...sdkConfig } = config;
|
|
180
|
-
return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations },
|
|
227
|
+
return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, instrumented);
|
|
181
228
|
});
|
|
182
229
|
}
|
|
183
230
|
registerMeta = ((name, config, handler) => {
|
|
@@ -192,6 +239,66 @@ export class ToolRegistry {
|
|
|
192
239
|
services() {
|
|
193
240
|
return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
|
|
194
241
|
}
|
|
242
|
+
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
243
|
+
* normalization; the kind drives value coercion on renamed keys). */
|
|
244
|
+
argShape(name) {
|
|
245
|
+
const cached = this.argShapeCache.get(name);
|
|
246
|
+
if (cached)
|
|
247
|
+
return cached;
|
|
248
|
+
const entry = this.tools.find((t) => t.name === name);
|
|
249
|
+
if (!entry)
|
|
250
|
+
return undefined;
|
|
251
|
+
const shape = new Map();
|
|
252
|
+
for (const [key, field] of Object.entries(entry.inputShape))
|
|
253
|
+
shape.set(key, scalarKindOf(field));
|
|
254
|
+
this.argShapeCache.set(name, shape);
|
|
255
|
+
return shape;
|
|
256
|
+
}
|
|
257
|
+
/** Declared argument keys for a tool, in declaration order; undefined when
|
|
258
|
+
* the tool is not registered. Backs unknown-argument screening, which needs
|
|
259
|
+
* the full key list rather than argShape's scalar-kind subset view. */
|
|
260
|
+
declaredKeys(name) {
|
|
261
|
+
const entry = this.tools.find((t) => t.name === name);
|
|
262
|
+
return entry ? Object.keys(entry.inputShape) : undefined;
|
|
263
|
+
}
|
|
264
|
+
/** Keys spelling a similar concept elsewhere in the same service, for the
|
|
265
|
+
* hint on a call whose key matched nothing. Kept only when a key is declared
|
|
266
|
+
* by at least two tools in the service or by a curated one, so one-off
|
|
267
|
+
* generated parameters do not become advice. */
|
|
268
|
+
siblingSpellings(tool, unknownKeys) {
|
|
269
|
+
const self = this.tools.find((t) => t.name === tool);
|
|
270
|
+
if (!self)
|
|
271
|
+
return [];
|
|
272
|
+
const declared = new Set(Object.keys(self.inputShape));
|
|
273
|
+
const byKey = new Map();
|
|
274
|
+
for (const t of this.tools) {
|
|
275
|
+
// Only tools the agent can actually see in tools/list. A hint naming a
|
|
276
|
+
// registered-but-unadvertised generated tool turns a recoverable error
|
|
277
|
+
// into a dead end: in curated mode 353 tools are registered and 181 are
|
|
278
|
+
// advertised, and the hint used to reach for any of them.
|
|
279
|
+
if (t.service !== self.service || t.name === tool || !this.isVisible(t))
|
|
280
|
+
continue;
|
|
281
|
+
for (const key of Object.keys(t.inputShape)) {
|
|
282
|
+
if (declared.has(key))
|
|
283
|
+
continue;
|
|
284
|
+
const e = byKey.get(key) ?? { tools: [], curated: false };
|
|
285
|
+
e.tools.push(t.name);
|
|
286
|
+
if (!t.generated)
|
|
287
|
+
e.curated = true;
|
|
288
|
+
byKey.set(key, e);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const candidates = [...byKey.entries()].filter(([, e]) => e.tools.length >= 2 || e.curated);
|
|
292
|
+
// Reuse the tiered matcher rather than a substring test: the motivating
|
|
293
|
+
// case (parentId against parentFolderId) fails containment and edit
|
|
294
|
+
// distance alike, which is the whole reason that matcher exists.
|
|
295
|
+
const keys = candidates.map(([key]) => key);
|
|
296
|
+
const hits = new Set(unknownKeys.flatMap((k) => suggestKeys(k, keys)));
|
|
297
|
+
return candidates
|
|
298
|
+
.filter(([key]) => hits.has(key))
|
|
299
|
+
.slice(0, 2)
|
|
300
|
+
.map(([key, e]) => ({ key, tools: e.tools }));
|
|
301
|
+
}
|
|
195
302
|
catalog(service, query) {
|
|
196
303
|
const q = query?.trim().toLowerCase();
|
|
197
304
|
return this.tools
|
|
@@ -202,8 +309,38 @@ export class ToolRegistry {
|
|
|
202
309
|
summary: t.description,
|
|
203
310
|
args: Object.keys(t.inputShape),
|
|
204
311
|
cud: t.cud,
|
|
312
|
+
...(this.isUngrantable(t) ? { unreachable: true } : {}),
|
|
205
313
|
}));
|
|
206
314
|
}
|
|
315
|
+
/** The metrics recorder, for hook sites (escape hatch); null when off. */
|
|
316
|
+
get usageMetrics() {
|
|
317
|
+
return this.metrics;
|
|
318
|
+
}
|
|
319
|
+
/** Every registered tool name, hidden ones included: they stay callable, so
|
|
320
|
+
* a did-you-mean may legitimately point at one. */
|
|
321
|
+
toolNames() {
|
|
322
|
+
return this.tools.map((t) => t.name);
|
|
323
|
+
}
|
|
324
|
+
/** Membership test against the REGISTERED tool set (hidden tools included:
|
|
325
|
+
* they stay callable). The metrics tap uses this so a client-supplied name
|
|
326
|
+
* can never enter the closed vocabulary. */
|
|
327
|
+
hasTool(name) {
|
|
328
|
+
return this.tools.some((t) => t.name === name);
|
|
329
|
+
}
|
|
330
|
+
/** Op-name vocabulary for a service, split by provenance so the capped
|
|
331
|
+
* discover descriptions can list curated ops and only summarize the
|
|
332
|
+
* generated long tail. */
|
|
333
|
+
opNames(service) {
|
|
334
|
+
const strip = (n) => (n.startsWith(`${service}_`) ? n.slice(service.length + 1) : n);
|
|
335
|
+
const curated = [];
|
|
336
|
+
const generated = [];
|
|
337
|
+
for (const t of this.tools) {
|
|
338
|
+
if (t.meta || t.service !== service)
|
|
339
|
+
continue;
|
|
340
|
+
(t.generated ? generated : curated).push(strip(t.name));
|
|
341
|
+
}
|
|
342
|
+
return { curated: [...new Set(curated)], generated: [...new Set(generated)] };
|
|
343
|
+
}
|
|
207
344
|
reveal(service) {
|
|
208
345
|
if (this.revealed.has(service))
|
|
209
346
|
return false;
|
|
@@ -236,8 +373,40 @@ export class ToolRegistry {
|
|
|
236
373
|
return this.mode;
|
|
237
374
|
return this.expanded ? 'curated' : 'lazy';
|
|
238
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* True when NO bundle in the catalog grants any of the method's alternative
|
|
378
|
+
* scopes, so the call cannot succeed on any account under any configuration.
|
|
379
|
+
* Static: it asks the catalog, not an account, so it is safe to cache.
|
|
380
|
+
* `classifyScope` with nothing granted and no profile reports `add_bundle`
|
|
381
|
+
* for a scope some bundle could supply and `unknown_scope` only when none
|
|
382
|
+
* can, which is exactly the distinction wanted here.
|
|
383
|
+
*/
|
|
384
|
+
isUngrantable(tool) {
|
|
385
|
+
if (!tool.requiredScopes || tool.requiredScopes.length === 0)
|
|
386
|
+
return false;
|
|
387
|
+
const cached = this.ungrantable.get(tool.name);
|
|
388
|
+
if (cached !== undefined)
|
|
389
|
+
return cached;
|
|
390
|
+
// EVERY alternative must be ungrantable. `classifyMethodScopes` cannot
|
|
391
|
+
// answer this: `add_bundle` and `unknown_scope` share the
|
|
392
|
+
// `not_requestable` rank, so its first-best match hides a grantable
|
|
393
|
+
// alternative that appears later in the list. Discovery scope lists are
|
|
394
|
+
// ANY-OF, so one grantable alternative makes the method reachable.
|
|
395
|
+
const verdict = tool.requiredScopes.every((scope) => {
|
|
396
|
+
const c = classifyScope(scope, EMPTY_SCOPES, EMPTY_SCOPES);
|
|
397
|
+
return c.state === 'not_requestable' && c.reason === 'unknown_scope';
|
|
398
|
+
});
|
|
399
|
+
this.ungrantable.set(tool.name, verdict);
|
|
400
|
+
return verdict;
|
|
401
|
+
}
|
|
239
402
|
isVisible(tool) {
|
|
240
403
|
const mode = this.effectiveMode();
|
|
404
|
+
// Advertising a tool that can never succeed spends context budget to hand
|
|
405
|
+
// the agent a dead end. It stays registered and callable, so calling it by
|
|
406
|
+
// name still produces the real scope error plus its remediation, and
|
|
407
|
+
// {service}_discover still lists it, marked.
|
|
408
|
+
if (!tool.meta && this.isUngrantable(tool))
|
|
409
|
+
return false;
|
|
241
410
|
return (tool.meta ||
|
|
242
411
|
mode === 'eager' ||
|
|
243
412
|
this.revealed.has(tool.service) ||
|
|
@@ -254,7 +423,11 @@ export class ToolRegistry {
|
|
|
254
423
|
if (this.tools.length === 0) {
|
|
255
424
|
throw new Error('installListHandler() requires at least one registered tool');
|
|
256
425
|
}
|
|
257
|
-
this.server.server.setRequestHandler(
|
|
426
|
+
this.server.server.setRequestHandler('tools/list', async () => ({
|
|
427
|
+
// The wire Tool is hand-built because the SDK's own types drop the
|
|
428
|
+
// anthropic/* _meta keys honoring clients read. z.toJSONSchema emits a
|
|
429
|
+
// valid draft-7 object schema by construction, which the SDK's recursive
|
|
430
|
+
// JSON-Schema type cannot infer from our cached `unknown`.
|
|
258
431
|
tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
|
|
259
432
|
}));
|
|
260
433
|
}
|
package/dist/scope-catalog.js
CHANGED
|
@@ -108,6 +108,22 @@ export const BUNDLE_CATALOG = {
|
|
|
108
108
|
description: 'Read Gmail Postmaster Tools deliverability data.',
|
|
109
109
|
risk: 'low',
|
|
110
110
|
},
|
|
111
|
+
analytics: {
|
|
112
|
+
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
|
|
113
|
+
description: 'Read Google Analytics (GA4): run reports and inspect accounts, properties and their configuration.',
|
|
114
|
+
risk: 'low',
|
|
115
|
+
},
|
|
116
|
+
// Includes readonly so it is self-sufficient: analytics.edit alone does not
|
|
117
|
+
// authorize Data API reads. The smaller `analytics` bundle stays the hint
|
|
118
|
+
// target for read scopes (scope-observability sorts bundles by size).
|
|
119
|
+
analytics_write: {
|
|
120
|
+
scopes: [
|
|
121
|
+
'https://www.googleapis.com/auth/analytics.readonly',
|
|
122
|
+
'https://www.googleapis.com/auth/analytics.edit',
|
|
123
|
+
],
|
|
124
|
+
description: 'Edit Google Analytics (GA4) configuration: properties, data streams, key events, custom dimensions and metrics. Includes read access.',
|
|
125
|
+
risk: 'medium',
|
|
126
|
+
},
|
|
111
127
|
groupssettings: {
|
|
112
128
|
scopes: ['https://www.googleapis.com/auth/apps.groups.settings'],
|
|
113
129
|
description: 'Change Google Groups settings for the domain.',
|
package/dist/services.d.ts
CHANGED
|
@@ -9,3 +9,11 @@ export declare const GENERATED_GATES: Record<string, {
|
|
|
9
9
|
enabled: () => boolean;
|
|
10
10
|
hint: string;
|
|
11
11
|
}>;
|
|
12
|
+
/**
|
|
13
|
+
* Message for a `tools/call` naming a tool that is not registered. The SDK's
|
|
14
|
+
* own answer is a bare "Tool X not found", which reads identically for a typo,
|
|
15
|
+
* for a service gated behind a scope bundle, and for an API this server has
|
|
16
|
+
* never heard of. Those need different next steps, and the server knows which
|
|
17
|
+
* is which: it composed the enabled-service list at boot.
|
|
18
|
+
*/
|
|
19
|
+
export declare function unknownToolMessage(registry: ToolRegistry, name: string): string;
|
package/dist/services.js
CHANGED
|
@@ -11,7 +11,10 @@ import { registerSlidesTools } from './tools/slides.js';
|
|
|
11
11
|
import { registerFormsTools } from './tools/forms.js';
|
|
12
12
|
import { registerChatTools } from './tools/chat.js';
|
|
13
13
|
import { registerAdminTools } from './tools/admin.js';
|
|
14
|
+
import { registerAnalyticsTools } from './tools/analytics.js';
|
|
14
15
|
import { getOptionalBundles, getAdminAccounts } from './auth.js';
|
|
16
|
+
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
17
|
+
import { suggestKeys } from './arg-strict.js';
|
|
15
18
|
export const SERVICES = [
|
|
16
19
|
{ name: 'gmail', register: registerGmailTools },
|
|
17
20
|
{ name: 'drive', register: registerDriveTools },
|
|
@@ -25,9 +28,10 @@ export const SERVICES = [
|
|
|
25
28
|
{ name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
|
|
26
29
|
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
27
30
|
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
31
|
+
{ name: 'analytics', register: registerAnalyticsTools, enabled: () => { const b = new Set(getOptionalBundles()); return b.has('analytics') || b.has('analytics_write'); } },
|
|
28
32
|
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
29
33
|
];
|
|
30
|
-
// Generated-only services with opt-in scopes; admin/forms/chat reuse their curated gate in buildRegistry,
|
|
34
|
+
// Generated-only services with opt-in scopes; admin/forms/chat/analytics reuse their curated gate in buildRegistry,
|
|
31
35
|
// and workspaceevents is deliberately absent — no dedicated scope (subscriptions use resource scopes).
|
|
32
36
|
const bundleGate = (name) => ({
|
|
33
37
|
enabled: () => new Set(getOptionalBundles()).has(name),
|
|
@@ -49,3 +53,26 @@ export const GENERATED_GATES = {
|
|
|
49
53
|
script: bundleGate('script'),
|
|
50
54
|
vault: bundleGate('vault'),
|
|
51
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* Message for a `tools/call` naming a tool that is not registered. The SDK's
|
|
58
|
+
* own answer is a bare "Tool X not found", which reads identically for a typo,
|
|
59
|
+
* for a service gated behind a scope bundle, and for an API this server has
|
|
60
|
+
* never heard of. Those need different next steps, and the server knows which
|
|
61
|
+
* is which: it composed the enabled-service list at boot.
|
|
62
|
+
*/
|
|
63
|
+
export function unknownToolMessage(registry, name) {
|
|
64
|
+
const near = suggestKeys(name, registry.toolNames(), 3);
|
|
65
|
+
if (near.length > 0)
|
|
66
|
+
return `Tool ${name} not found. Did you mean: ${near.join(', ')}?`;
|
|
67
|
+
// A service that EXISTS in the build but registered nothing is gated, not
|
|
68
|
+
// misspelled, and the fix is a scope bundle rather than a different name.
|
|
69
|
+
const service = name.split('_')[0];
|
|
70
|
+
const known = SERVICES.some((s) => s.name === service) || GENERATED_SERVICES.some((s) => s.name === service);
|
|
71
|
+
if (known && !registry.services().includes(service)) {
|
|
72
|
+
const hint = service === 'admin'
|
|
73
|
+
? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS), then re-auth'
|
|
74
|
+
: (GENERATED_GATES[service]?.hint ?? `add "${service}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES), then re-auth`);
|
|
75
|
+
return `Tool ${name} not found: the "${service}" service is not enabled in this deployment. To enable it, ${hint}. Until then, google_api_call can reach the same API.`;
|
|
76
|
+
}
|
|
77
|
+
return `Tool ${name} not found. Call {service}_discover to list a service's tools, or google_api_search to find a method on any Google API.`;
|
|
78
|
+
}
|
package/dist/setup-prompt.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpServer } from
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
export interface SetupOptions {
|
|
3
3
|
/** Public base URL when serving over HTTP; used for the Web client redirect. */
|
|
4
4
|
publicUrl?: string;
|
package/dist/setup-prompt.js
CHANGED
|
@@ -47,7 +47,7 @@ export function registerSetupPrompt(server) {
|
|
|
47
47
|
server.registerPrompt('setup', {
|
|
48
48
|
title: 'Set up mcp-google-multi',
|
|
49
49
|
description: 'Guided Google Cloud Console prelude: project, APIs, consent screen, OAuth client, and credentials. The one-time browser setup that has no API.',
|
|
50
|
-
argsSchema: { publicUrl: z.string().optional().describe('Public base URL when serving over HTTP (for the Web OAuth client redirect). Omit for stdio.') },
|
|
50
|
+
argsSchema: z.object({ publicUrl: z.string().optional().describe('Public base URL when serving over HTTP (for the Web OAuth client redirect). Omit for stdio.') }),
|
|
51
51
|
}, (args) => ({
|
|
52
52
|
messages: [
|
|
53
53
|
{
|
package/dist/tools/_coerce.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<T>>;
|
|
3
3
|
export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
|
|
4
|
+
/** Numeric args arrive string-encoded from some clients. Arg normalization
|
|
5
|
+
* runs on BOTH transports, but it only coerces keys it RENAMED, so a value
|
|
6
|
+
* sent under the correct key never passes through it and the coercion has to
|
|
7
|
+
* live in the schema. */
|
|
8
|
+
export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
|
|
4
9
|
export declare const coerceBoolean: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodBoolean>;
|
package/dist/tools/_coerce.js
CHANGED
|
@@ -22,6 +22,17 @@ export function coerceArray(element) {
|
|
|
22
22
|
export function coerceJson(schema) {
|
|
23
23
|
return z.preprocess((val) => (typeof val === 'string' ? parseJsonLoose(val) : val), schema);
|
|
24
24
|
}
|
|
25
|
+
/** Numeric args arrive string-encoded from some clients. Arg normalization
|
|
26
|
+
* runs on BOTH transports, but it only coerces keys it RENAMED, so a value
|
|
27
|
+
* sent under the correct key never passes through it and the coercion has to
|
|
28
|
+
* live in the schema. */
|
|
29
|
+
export function coerceNumber(schema) {
|
|
30
|
+
return z.preprocess((val) => {
|
|
31
|
+
if (typeof val === 'string' && val.trim() !== '' && !Number.isNaN(Number(val)))
|
|
32
|
+
return Number(val);
|
|
33
|
+
return val;
|
|
34
|
+
}, schema);
|
|
35
|
+
}
|
|
25
36
|
export const coerceBoolean = z.preprocess((val) => {
|
|
26
37
|
if (typeof val === 'boolean')
|
|
27
38
|
return val;
|