mcp-google-multi 6.0.0-beta.1 → 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 +5 -3
- 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 +61 -4
- package/dist/arg-normalize.js +228 -4
- 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 +11 -2
- package/dist/http-transport.js +10 -5
- package/dist/index.js +112 -12
- 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 +49 -2
- package/dist/registry.js +147 -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 +8 -3
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isSchemaValidationText } from './arg-normalize.js';
|
|
2
|
+
const PENDING_CAP = 1_000;
|
|
3
|
+
// The detector lives next to the rewriter that consumes the same frames, so
|
|
4
|
+
// the two can never drift apart. This tap sees the ORIGINAL prose:
|
|
5
|
+
// withValidationEnvelope sits BELOW it and converts that same frame into an
|
|
6
|
+
// envelope for the client, so classification here and the contract on the wire
|
|
7
|
+
// stay independent.
|
|
8
|
+
export { isSchemaValidationText };
|
|
9
|
+
/**
|
|
10
|
+
* @param isKnownTool membership test against the REGISTERED tool set. The
|
|
11
|
+
* name on a `tools/call` frame is client-supplied, so a shape check alone
|
|
12
|
+
* would let a shape-valid but invented name reach disk and break the
|
|
13
|
+
* closed-vocabulary guarantee. A validation failure against a name the server
|
|
14
|
+
* never registered is a not-found, and is counted as one.
|
|
15
|
+
*/
|
|
16
|
+
export function tapUsageMetrics(transport, metrics, isKnownTool = () => false) {
|
|
17
|
+
const pending = new Map();
|
|
18
|
+
const recordValidation = (tool) => {
|
|
19
|
+
if (tool !== undefined && isKnownTool(tool))
|
|
20
|
+
metrics.recordRpc('schema_validation', tool);
|
|
21
|
+
else
|
|
22
|
+
metrics.recordRpc('tool_not_found');
|
|
23
|
+
};
|
|
24
|
+
const wrapper = {
|
|
25
|
+
start: () => transport.start(),
|
|
26
|
+
send: (message, options) => {
|
|
27
|
+
try {
|
|
28
|
+
const m = message;
|
|
29
|
+
// Responses only: a server-initiated REQUEST carries an id from a
|
|
30
|
+
// different id space, and evicting on it would drop a real pending call.
|
|
31
|
+
if (m.id !== undefined && message.method === undefined) {
|
|
32
|
+
const tool = pending.get(m.id);
|
|
33
|
+
pending.delete(m.id);
|
|
34
|
+
if (m.error && typeof m.error.code === 'number') {
|
|
35
|
+
if (m.error.code === -32602) {
|
|
36
|
+
if (/unknown tool|not found/i.test(m.error.message ?? ''))
|
|
37
|
+
metrics.recordRpc('tool_not_found');
|
|
38
|
+
else
|
|
39
|
+
recordValidation(tool);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
metrics.recordRpc(m.error.code);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (m.result?.isError === true) {
|
|
46
|
+
const first = m.result.content?.[0]?.text;
|
|
47
|
+
if (typeof first === 'string' && isSchemaValidationText(first)) {
|
|
48
|
+
recordValidation(tool);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { /* the tap may never break the wire */ }
|
|
54
|
+
return transport.send(message, options);
|
|
55
|
+
},
|
|
56
|
+
close: () => transport.close(),
|
|
57
|
+
};
|
|
58
|
+
Object.defineProperty(wrapper, 'onmessage', {
|
|
59
|
+
get: () => transport.onmessage,
|
|
60
|
+
set: (handler) => {
|
|
61
|
+
transport.onmessage = handler
|
|
62
|
+
? (message, extra) => {
|
|
63
|
+
try {
|
|
64
|
+
const m = message;
|
|
65
|
+
if (m.id !== undefined && m.method === 'tools/call' && typeof m.params?.name === 'string') {
|
|
66
|
+
if (pending.size >= PENDING_CAP)
|
|
67
|
+
pending.clear();
|
|
68
|
+
pending.set(m.id, m.params.name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* never break the wire */ }
|
|
72
|
+
handler(message, extra);
|
|
73
|
+
}
|
|
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
|
+
// v2-only; see the same forward in arg-normalize.ts. Both proxies compose,
|
|
90
|
+
// so a member dropped by either layer never reaches the real transport.
|
|
91
|
+
if (transport.setSupportedProtocolVersions) {
|
|
92
|
+
wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
|
|
93
|
+
}
|
|
94
|
+
return wrapper;
|
|
95
|
+
}
|
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
-
export declare const LOOPBACK_PORT = 4242;
|
|
3
|
-
export declare const LOOPBACK_REDIRECT = "http://localhost:4242/oauth2callback";
|
|
4
2
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
5
3
|
export declare class ClientCredentialsMissingError extends Error {
|
|
6
4
|
constructor();
|
|
@@ -14,15 +12,32 @@ export declare class ConsentTimeoutError extends Error {
|
|
|
14
12
|
export declare class ConsentDeniedError extends Error {
|
|
15
13
|
constructor(reason: string);
|
|
16
14
|
}
|
|
15
|
+
export declare const TESTING_MODE_WARNING: string;
|
|
17
16
|
export declare function hasClientCredentials(): boolean;
|
|
18
|
-
/** Build the loopback OAuth2 client from env credentials (throws if unset). */
|
|
19
|
-
export declare function buildConsentClient(): OAuth2Client;
|
|
20
17
|
/**
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
* times out so a stalled consent can't wedge a tool call.
|
|
18
|
+
* Build the loopback OAuth2 client from env credentials (throws if unset).
|
|
19
|
+
* `redirect` comes from openLoopbackConsent(): the port is only known once the
|
|
20
|
+
* listener is bound.
|
|
25
21
|
*/
|
|
26
|
-
export declare function
|
|
22
|
+
export declare function buildConsentClient(redirect: string): OAuth2Client;
|
|
23
|
+
export interface LoopbackConsent {
|
|
24
|
+
/** `http://localhost:<ephemeral port>/oauth2callback` — build the auth URL from this. */
|
|
25
|
+
redirect: string;
|
|
26
|
+
/**
|
|
27
|
+
* Await the OAuth redirect, validate the CSRF `state` (RFC 6749 §10.12), and
|
|
28
|
+
* exchange the code. Returns the token set for the caller to persist.
|
|
29
|
+
*/
|
|
30
|
+
finish(client: OAuth2Client, expectedState: string): Promise<Record<string, unknown>>;
|
|
31
|
+
/**
|
|
32
|
+
* Abandon the consent: shuts the listener down WITHOUT settling finish(), so
|
|
33
|
+
* an abandoned flow can never surface as an unhandled rejection later.
|
|
34
|
+
*/
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bind the loopback listener first (ephemeral port), so the redirect URI is
|
|
39
|
+
* known before the auth URL is built and the callback can't race the browser.
|
|
40
|
+
*/
|
|
41
|
+
export declare function openLoopbackConsent(opts?: {
|
|
27
42
|
timeoutMs?: number;
|
|
28
|
-
}): Promise<
|
|
43
|
+
}): Promise<LoopbackConsent>;
|
package/dist/oauth-consent.js
CHANGED
|
@@ -2,13 +2,11 @@ import { OAuth2Client } from 'googleapis-common';
|
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
4
|
// Shared loopback OAuth consent (leg C of cc-auth), used by the account wizard
|
|
5
|
-
// (B7)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
export const LOOPBACK_PORT = 4242;
|
|
11
|
-
export const LOOPBACK_REDIRECT = `http://localhost:${LOOPBACK_PORT}/oauth2callback`;
|
|
5
|
+
// (B7) and the `auth --account` CLI. Live-server-safe: typed errors instead of
|
|
6
|
+
// process.exit, and a timeout so a never-completed consent can't wedge a tool
|
|
7
|
+
// call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
|
|
8
|
+
// Google Desktop clients accept any http://localhost:<port> redirect), so a
|
|
9
|
+
// second local process on a fixed port can never break auth.
|
|
12
10
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
13
11
|
export class ClientCredentialsMissingError extends Error {
|
|
14
12
|
constructor() {
|
|
@@ -17,7 +15,7 @@ export class ClientCredentialsMissingError extends Error {
|
|
|
17
15
|
}
|
|
18
16
|
export class LoopbackPortInUseError extends Error {
|
|
19
17
|
constructor() {
|
|
20
|
-
super(
|
|
18
|
+
super('E_LOOPBACK_PORT_IN_USE: the loopback consent listener could not bind a port; retry.');
|
|
21
19
|
}
|
|
22
20
|
}
|
|
23
21
|
export class ConsentTimeoutError extends Error {
|
|
@@ -30,80 +28,128 @@ export class ConsentDeniedError extends Error {
|
|
|
30
28
|
super(`E_CONSENT_DENIED: ${reason}`);
|
|
31
29
|
}
|
|
32
30
|
}
|
|
31
|
+
// Surfaced after every successful consent: the expiry is invisible until the
|
|
32
|
+
// token dies a week later as reauth_required, so the moment of success is the
|
|
33
|
+
// one place the warning is guaranteed to be seen. Full walkthrough in
|
|
34
|
+
// docs/google-cloud-setup.md.
|
|
35
|
+
export const TESTING_MODE_WARNING = 'Heads-up: while your OAuth client\'s Publishing status is "Testing", Google expires refresh tokens after 7 days (weekly re-auth for every account). ' +
|
|
36
|
+
'When your setup works, set it to "In production" at https://console.cloud.google.com/auth/audience. ' +
|
|
37
|
+
'No verification review is needed for personal use; see docs/google-cloud-setup.md.';
|
|
33
38
|
export function hasClientCredentials() {
|
|
34
39
|
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
|
35
40
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Build the loopback OAuth2 client from env credentials (throws if unset).
|
|
43
|
+
* `redirect` comes from openLoopbackConsent(): the port is only known once the
|
|
44
|
+
* listener is bound.
|
|
45
|
+
*/
|
|
46
|
+
export function buildConsentClient(redirect) {
|
|
38
47
|
if (!hasClientCredentials())
|
|
39
48
|
throw new ClientCredentialsMissingError();
|
|
40
|
-
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET,
|
|
49
|
+
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, redirect);
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* the caller to persist. Never process.exit's (safe inside a live server) and
|
|
46
|
-
* times out so a stalled consent can't wedge a tool call.
|
|
52
|
+
* Bind the loopback listener first (ephemeral port), so the redirect URI is
|
|
53
|
+
* known before the auth URL is built and the callback can't race the browser.
|
|
47
54
|
*/
|
|
48
|
-
export function
|
|
55
|
+
export function openLoopbackConsent(opts = {}) {
|
|
49
56
|
const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
|
|
50
|
-
return new Promise((
|
|
57
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
58
|
+
let redirect = '';
|
|
51
59
|
let timer;
|
|
60
|
+
let closed = false;
|
|
61
|
+
let pending;
|
|
62
|
+
const shutdown = () => {
|
|
63
|
+
if (closed)
|
|
64
|
+
return;
|
|
65
|
+
closed = true;
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
server.close();
|
|
69
|
+
server.closeAllConnections();
|
|
70
|
+
};
|
|
71
|
+
const fail = (err) => {
|
|
72
|
+
const p = pending;
|
|
73
|
+
pending = undefined;
|
|
74
|
+
shutdown();
|
|
75
|
+
p?.reject(err);
|
|
76
|
+
};
|
|
52
77
|
const server = http.createServer(async (req, res) => {
|
|
53
78
|
if (!req.url || !req.url.startsWith('/oauth2callback')) {
|
|
54
79
|
res.writeHead(404).end();
|
|
55
80
|
return;
|
|
56
81
|
}
|
|
82
|
+
if (!pending) {
|
|
83
|
+
// Only reachable if something other than our own browser launch hit
|
|
84
|
+
// the port before finish() armed the exchange; nothing to do with it.
|
|
85
|
+
res.writeHead(503).end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const p = pending;
|
|
57
89
|
const done = (code, body) => {
|
|
58
90
|
res.writeHead(code, { 'Content-Type': 'text/html' });
|
|
59
91
|
res.end(body);
|
|
60
|
-
if (timer)
|
|
61
|
-
clearTimeout(timer);
|
|
62
|
-
server.close();
|
|
63
|
-
server.closeAllConnections();
|
|
64
92
|
};
|
|
65
93
|
try {
|
|
66
|
-
const qs = new URL(req.url,
|
|
94
|
+
const qs = new URL(req.url, redirect).searchParams;
|
|
67
95
|
const error = qs.get('error');
|
|
68
96
|
if (error) {
|
|
69
97
|
done(400, `<p>Authorization denied: ${error}</p>`);
|
|
70
|
-
|
|
98
|
+
fail(new ConsentDeniedError(error));
|
|
71
99
|
return;
|
|
72
100
|
}
|
|
73
101
|
const returnedState = qs.get('state');
|
|
74
|
-
if (returnedState !== expectedState) {
|
|
75
|
-
done(400, '<p>State mismatch
|
|
76
|
-
|
|
102
|
+
if (returnedState !== p.expectedState) {
|
|
103
|
+
done(400, '<p>State mismatch: possible CSRF attempt. Aborting.</p>');
|
|
104
|
+
fail(new Error('E_OAUTH_STATE_MISMATCH: OAuth state token mismatch'));
|
|
77
105
|
return;
|
|
78
106
|
}
|
|
79
107
|
const code = qs.get('code');
|
|
80
108
|
if (!code) {
|
|
81
109
|
done(400, '<p>No authorization code received.</p>');
|
|
82
|
-
|
|
110
|
+
fail(new ConsentDeniedError('no authorization code received'));
|
|
83
111
|
return;
|
|
84
112
|
}
|
|
85
|
-
const { tokens } = await client.getToken(code);
|
|
113
|
+
const { tokens } = await p.client.getToken(code);
|
|
86
114
|
done(200, '<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
87
|
-
|
|
115
|
+
pending = undefined;
|
|
116
|
+
shutdown();
|
|
117
|
+
p.resolve(tokens);
|
|
88
118
|
}
|
|
89
119
|
catch (e) {
|
|
90
120
|
done(500, '<p>Internal error during authentication.</p>');
|
|
91
|
-
|
|
121
|
+
fail(e);
|
|
92
122
|
}
|
|
93
123
|
});
|
|
94
124
|
server.on('error', (err) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
125
|
+
const mapped = err.code === 'EADDRINUSE' ? new LoopbackPortInUseError() : err;
|
|
126
|
+
if (pending) {
|
|
127
|
+
fail(mapped);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
shutdown();
|
|
131
|
+
rejectOpen(mapped);
|
|
132
|
+
}
|
|
98
133
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
reject(new ConsentTimeoutError());
|
|
104
|
-
}, timeoutMs);
|
|
134
|
+
// Bind to loopback only — never expose the OAuth callback to the local network.
|
|
135
|
+
server.listen(0, '127.0.0.1', () => {
|
|
136
|
+
redirect = `http://localhost:${server.address().port}/oauth2callback`;
|
|
137
|
+
timer = setTimeout(() => fail(new ConsentTimeoutError()), timeoutMs);
|
|
105
138
|
// unref so a pending consent never keeps the process alive on its own.
|
|
106
139
|
timer.unref?.();
|
|
140
|
+
resolveOpen({
|
|
141
|
+
redirect,
|
|
142
|
+
finish(client, expectedState) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
if (closed) {
|
|
145
|
+
reject(new ConsentTimeoutError());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pending = { client, expectedState, resolve, reject };
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
close: shutdown,
|
|
152
|
+
});
|
|
107
153
|
});
|
|
108
154
|
});
|
|
109
155
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface OutboundAllowlist {
|
|
2
|
+
entries: string[];
|
|
3
|
+
allows(email: string): boolean;
|
|
4
|
+
}
|
|
5
|
+
/** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
|
|
6
|
+
* suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
|
|
7
|
+
export declare function resolveOutboundAllowlist(env?: NodeJS.ProcessEnv): OutboundAllowlist | null;
|
|
8
|
+
/** The addresses in `emails` the active allowlist rejects; [] when off. */
|
|
9
|
+
export declare function outboundViolations(emails: string[], env?: NodeJS.ProcessEnv): string[];
|
|
10
|
+
/** The standard error envelope for a blocked outbound target. */
|
|
11
|
+
export declare function outboundDeniedEnvelope(kind: string, blocked: string[], account: string, env?: NodeJS.ProcessEnv): {
|
|
12
|
+
content: {
|
|
13
|
+
type: "text";
|
|
14
|
+
text: string;
|
|
15
|
+
}[];
|
|
16
|
+
isError: true;
|
|
17
|
+
};
|
|
18
|
+
/** Convenience: envelope when violations exist, else null. */
|
|
19
|
+
export declare function checkOutbound(kind: string, emails: string[], account: string, env?: NodeJS.ProcessEnv): {
|
|
20
|
+
content: {
|
|
21
|
+
type: "text";
|
|
22
|
+
text: string;
|
|
23
|
+
}[];
|
|
24
|
+
isError: true;
|
|
25
|
+
} | null;
|
|
26
|
+
export declare function collectBodyRecipients(body: unknown): string[];
|
|
27
|
+
/** Gate one escape-hatch/generated dispatch; envelope when blocked, else null. */
|
|
28
|
+
export declare function checkOutboundForMethod(methodId: string, body: unknown, account: string, env?: NodeJS.ProcessEnv): {
|
|
29
|
+
content: {
|
|
30
|
+
type: "text";
|
|
31
|
+
text: string;
|
|
32
|
+
}[];
|
|
33
|
+
isError: true;
|
|
34
|
+
} | null;
|
|
@@ -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,7 +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
4
|
import type { ArgShape } from './arg-normalize.js';
|
|
5
|
+
import type { Metrics } from './usage-metrics.js';
|
|
5
6
|
export type Cud = 'read' | 'create' | 'update' | 'delete';
|
|
6
7
|
export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
|
|
7
8
|
export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
|
|
@@ -30,16 +31,25 @@ export interface CatalogOperation {
|
|
|
30
31
|
summary: string;
|
|
31
32
|
args: string[];
|
|
32
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;
|
|
33
39
|
}
|
|
34
40
|
export declare function inferCud(name: string): Cud;
|
|
35
41
|
export declare class ToolRegistry {
|
|
36
42
|
private readonly server;
|
|
43
|
+
private readonly metrics;
|
|
37
44
|
readonly tools: ToolEntry[];
|
|
38
45
|
readonly policy: Policy;
|
|
39
46
|
readonly registerTool: McpServer['registerTool'];
|
|
40
47
|
private readonly revealed;
|
|
41
48
|
private readonly jsonSchemaCache;
|
|
42
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;
|
|
43
53
|
private readonly compactOutput;
|
|
44
54
|
private registeringMeta;
|
|
45
55
|
/** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
|
|
@@ -47,13 +57,41 @@ export declare class ToolRegistry {
|
|
|
47
57
|
/** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
|
|
48
58
|
* lazy surface to curated without touching the configured mode. */
|
|
49
59
|
private expanded;
|
|
50
|
-
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
|
|
60
|
+
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
|
|
51
61
|
registerMeta: McpServer['registerTool'];
|
|
52
62
|
services(): string[];
|
|
53
63
|
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
54
64
|
* normalization; the kind drives value coercion on renamed keys). */
|
|
55
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
|
+
}>;
|
|
56
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
|
+
};
|
|
57
95
|
reveal(service: string): boolean;
|
|
58
96
|
/** discover_all: advertise the full curated set at once. Idempotent. */
|
|
59
97
|
expand(): boolean;
|
|
@@ -63,6 +101,15 @@ export declare class ToolRegistry {
|
|
|
63
101
|
* dispatch), zero correctness impact. */
|
|
64
102
|
collapse(): boolean;
|
|
65
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;
|
|
66
113
|
isVisible(tool: ToolEntry): boolean;
|
|
67
114
|
visibleCount(): {
|
|
68
115
|
eager: number;
|