mcp-google-multi 6.0.1 → 6.1.0-alpha.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/dist/compose.js +13 -1
- package/dist/env-load.d.ts +6 -0
- package/dist/env-load.js +8 -0
- package/dist/executor.js +4 -6
- package/dist/identity.js +4 -2
- package/dist/index.js +2 -1
- package/dist/outbound-allowlist.d.ts +36 -3
- package/dist/outbound-allowlist.js +80 -44
- package/dist/registry.d.ts +46 -0
- package/dist/registry.js +67 -1
- package/dist/tools/_escape-hatch.d.ts +23 -0
- package/dist/tools/_escape-hatch.js +239 -0
- package/dist/tools/_local-files.d.ts +94 -1
- package/dist/tools/_local-files.js +269 -9
- package/dist/tools/drive.js +80 -13
- package/dist/tools/generated/_shared.js +22 -0
- package/dist/tools/generated/admin.js +2 -2
- package/dist/tools/generated/method-map.d.ts +3 -0
- package/dist/tools/generated/method-map.js +156 -1
- package/dist/tools/gmail.d.ts +6 -0
- package/dist/tools/gmail.js +71 -47
- package/dist/tools/google-api.d.ts +18 -1
- package/dist/tools/google-api.js +86 -12
- package/dist/usage-metrics.js +1 -1
- package/dist/write-control.d.ts +31 -7
- package/dist/write-control.js +30 -2
- package/package.json +1 -1
- package/skills/google-workspace/SKILL.md +21 -9
- package/skills/google-workspace/reference/drive.md +4 -4
- package/skills/google-workspace/reference/gmail.md +1 -1
package/dist/compose.js
CHANGED
|
@@ -14,6 +14,7 @@ import { registerAccountWizardTools } from './tools/account-wizard.js';
|
|
|
14
14
|
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
15
15
|
import { isOwnerContext } from './identity.js';
|
|
16
16
|
import { resolveScopesForAccount } from './auth.js';
|
|
17
|
+
import { localFilesDisabledByEnv, logLocalFilesRootAtStartup } from './tools/_local-files.js';
|
|
17
18
|
/** The one place the MCP server is constructed, so an embedding host can pass
|
|
18
19
|
* per-session `instructions` without re-deriving the server identity. */
|
|
19
20
|
export function createMcpServer(version, opts = {}) {
|
|
@@ -26,7 +27,18 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
|
|
|
26
27
|
const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
|
|
27
28
|
// ctx.accounts is read per call, never snapshotted here, so a live getter stays live.
|
|
28
29
|
const owner = isOwnerContext(ctx);
|
|
29
|
-
|
|
30
|
+
// GOOGLE_LOCAL_FILES=off removes every host-file tool/param for every
|
|
31
|
+
// context, including the owner, regardless of the transport.
|
|
32
|
+
const localFilesEnabled = owner && !localFilesDisabledByEnv();
|
|
33
|
+
// Log (never create) the effective GOOGLE_LOCAL_FILES_DIR root so an
|
|
34
|
+
// operator sees where uploads/downloads/attachments will be confined
|
|
35
|
+
// without triggering a tool call first; the root itself is created
|
|
36
|
+
// lazily, on first actual use, so a read-only or missing HOME (hardened
|
|
37
|
+
// systemd, an isolated service user) cannot fail server startup over a
|
|
38
|
+
// feature this run might never use. `config check` hits this same path.
|
|
39
|
+
if (localFilesEnabled)
|
|
40
|
+
logLocalFilesRootAtStartup();
|
|
41
|
+
const clientDeps = { getClientFn: ctx.getClient, localFiles: localFilesEnabled };
|
|
30
42
|
const execDeps = {
|
|
31
43
|
getClientFn: ctx.getClient,
|
|
32
44
|
scopeDeps: {
|
package/dist/env-load.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface EnvLoadPaths {
|
|
|
9
9
|
configDir?: string;
|
|
10
10
|
}
|
|
11
11
|
export declare function loadEnvFiles(paths?: EnvLoadPaths): EnvLoadResult;
|
|
12
|
+
/** The .env files this process actually loaded a secret from (candidates
|
|
13
|
+
* that did not exist are not included). Used by the local-file guard to
|
|
14
|
+
* refuse reading or writing a file the server itself sourced secrets from,
|
|
15
|
+
* even though it sits outside the config/token directory (e.g. a project
|
|
16
|
+
* root .env, or MCP_GOOGLE_MULTI_ENV pointing elsewhere entirely). */
|
|
17
|
+
export declare function getLoadedEnvFiles(): string[];
|
|
12
18
|
/**
|
|
13
19
|
* Which layer supplied `key`: the real process env, or the first loaded .env
|
|
14
20
|
* file defining it (autoload never overwrites, so first wins). undefined when
|
package/dist/env-load.js
CHANGED
|
@@ -49,6 +49,14 @@ export function loadEnvFiles(paths = {}) {
|
|
|
49
49
|
return { loaded, searched: candidates };
|
|
50
50
|
}
|
|
51
51
|
let lastLoad;
|
|
52
|
+
/** The .env files this process actually loaded a secret from (candidates
|
|
53
|
+
* that did not exist are not included). Used by the local-file guard to
|
|
54
|
+
* refuse reading or writing a file the server itself sourced secrets from,
|
|
55
|
+
* even though it sits outside the config/token directory (e.g. a project
|
|
56
|
+
* root .env, or MCP_GOOGLE_MULTI_ENV pointing elsewhere entirely). */
|
|
57
|
+
export function getLoadedEnvFiles() {
|
|
58
|
+
return lastLoad?.loaded ?? [];
|
|
59
|
+
}
|
|
52
60
|
/**
|
|
53
61
|
* Which layer supplied `key`: the real process env, or the first loaded .env
|
|
54
62
|
* file defining it (autoload never overwrites, so first wins). undefined when
|
package/dist/executor.js
CHANGED
|
@@ -3,7 +3,6 @@ import { sliceEncoded } from './trim.js';
|
|
|
3
3
|
import { expandPath, isGoogleApiUrl } from './discovery-client.js';
|
|
4
4
|
import { handleGoogleApiError } from './tools/_errors.js';
|
|
5
5
|
import { scopeHintForMethod } from './scope-observability.js';
|
|
6
|
-
import { checkOutboundForMethod } from './outbound-allowlist.js';
|
|
7
6
|
import { hints } from './hint-provider.js';
|
|
8
7
|
export const MAX_RESPONSE_CHARS = 100_000;
|
|
9
8
|
export function jsonResult(payload, isError = false) {
|
|
@@ -31,12 +30,11 @@ export function resolveRequestBody(httpMethod, body) {
|
|
|
31
30
|
return undefined;
|
|
32
31
|
return body ?? undefined;
|
|
33
32
|
}
|
|
33
|
+
// Write-control for a write call (deny-check, full-writes auto-allow, or an
|
|
34
|
+
// elicitation round-trip asking a human) runs in the caller, BEFORE this is
|
|
35
|
+
// invoked (src/tools/_escape-hatch.ts): by the time a write reaches here it
|
|
36
|
+
// has already been cleared.
|
|
34
37
|
export async function executeApiMethod(method, args, deps = {}) {
|
|
35
|
-
// Outbound allowlist: no-op when off; with it active, structured bodies are
|
|
36
|
-
// inspected for recipient fields and raw-compose methods are refused.
|
|
37
|
-
const outbound = checkOutboundForMethod(method.id, args.body, args.account);
|
|
38
|
-
if (outbound)
|
|
39
|
-
return outbound;
|
|
40
38
|
if (args.queryParams?.alt === 'media') {
|
|
41
39
|
return jsonResult({
|
|
42
40
|
error: 'binary_unsupported',
|
package/dist/identity.js
CHANGED
|
@@ -19,8 +19,10 @@ export function buildIdentityContext(env = process.env, opts = {}) {
|
|
|
19
19
|
get accounts() {
|
|
20
20
|
return getAccountSet();
|
|
21
21
|
},
|
|
22
|
-
// The dispatch transport rides into the resolved policy
|
|
23
|
-
//
|
|
22
|
+
// The dispatch transport rides into the resolved policy: it never changes
|
|
23
|
+
// isAllowed's verdict or the profile, but over HTTP it does turn an
|
|
24
|
+
// escape-hatch "ask" into a refusal (see Policy.transport in
|
|
25
|
+
// write-control.ts and docs/internals.md).
|
|
24
26
|
policy: resolvePolicy(env, { transport: opts.transport }),
|
|
25
27
|
getClient: makeGetClient('owner', {
|
|
26
28
|
accounts: getAccountSet,
|
package/dist/index.js
CHANGED
|
@@ -103,7 +103,8 @@ async function main() {
|
|
|
103
103
|
console.log(`Services: ${registry.services().join(', ')}`);
|
|
104
104
|
console.log(`Discovery mode: ${registry.mode}${registry.mode === 'lazy' ? ' (expand at runtime with discover_all)' : ''}`);
|
|
105
105
|
console.log(`Tool surface: ${counts.eager} eager (discover + escape hatch), ${counts.revealed} advertised, ${counts.hidden} deferred`);
|
|
106
|
-
console.log(`Escape hatch: google_api_call
|
|
106
|
+
console.log(`Escape hatch: google_api_call and generated tools ignore GOOGLE_WRITE_ALLOW; GOOGLE_READ_ONLY/GOOGLE_WRITE_DENY still refuse ` +
|
|
107
|
+
`outright, full-writes (no outbound allowlist) still allows outright, everything else asks a human${policy.transport === 'http' ? ' (refused immediately over HTTP: see docs/configuration.md)' : ''}; current profile=${policy.profile}`);
|
|
107
108
|
const { getAccountSet } = await import('./accounts.js');
|
|
108
109
|
const set = getAccountSet();
|
|
109
110
|
console.log(`Default account: ${set.defaultAccount ? `${set.defaultAccount} (${set.defaultAccountSource})` : '(none — "account" is required per call)'}`);
|
|
@@ -23,9 +23,42 @@ export declare function checkOutbound(kind: string, emails: string[], account: s
|
|
|
23
23
|
}[];
|
|
24
24
|
isError: true;
|
|
25
25
|
} | null;
|
|
26
|
-
export
|
|
27
|
-
/**
|
|
28
|
-
|
|
26
|
+
export interface GranteeCheck {
|
|
27
|
+
/** Plain addresses to run through the allowlist. */
|
|
28
|
+
emails: string[];
|
|
29
|
+
/** "@domain" strings that need an exact allowlist entry. */
|
|
30
|
+
domains: string[];
|
|
31
|
+
/** A share-with-anyone / calendar-default grant: always blocked while the
|
|
32
|
+
* allowlist is active, however it is spelled, since there is no address
|
|
33
|
+
* an operator could add to permit it. */
|
|
34
|
+
public: boolean;
|
|
35
|
+
/** False when the body does not match any shape this method is known to
|
|
36
|
+
* take (a required field missing or of the wrong type): the caller fails
|
|
37
|
+
* closed instead of treating an unexamined body as grantee-free. */
|
|
38
|
+
understood: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** Shared by drive_share's own type/domain/emailAddress args and
|
|
41
|
+
* drive_permission_update's permissions.get lookup (same resource
|
|
42
|
+
* shape, fetched instead of trusted from the body: drive.permissions.update
|
|
43
|
+
* only takes role/expirationTime, the grantee is named by the permissionId
|
|
44
|
+
* path parameter, which Google resolves server-side). */
|
|
45
|
+
export declare function driveGranteeFromPermission(perm: {
|
|
46
|
+
type?: string | null;
|
|
47
|
+
domain?: string | null;
|
|
48
|
+
emailAddress?: string | null;
|
|
49
|
+
} | null | undefined): GranteeCheck;
|
|
50
|
+
/** The access proposal's own recipient (the person who gains access if an
|
|
51
|
+
* ACCEPT goes through), for drive_access_proposal_resolve's own
|
|
52
|
+
* accessproposals.get lookup: its own body is just an ACCEPT/DENY action,
|
|
53
|
+
* the requester is a property of the proposal itself. */
|
|
54
|
+
export declare function driveGranteeFromAccessProposal(proposal: {
|
|
55
|
+
recipientEmailAddress?: string | null;
|
|
56
|
+
requesterEmailAddress?: string | null;
|
|
57
|
+
} | null | undefined): GranteeCheck;
|
|
58
|
+
/** Applies an already-resolved GranteeCheck against the allowlist: the
|
|
59
|
+
* entry point curated tools use once they have looked up a grantee
|
|
60
|
+
* themselves (drive_permission_update, drive_access_proposal_resolve). */
|
|
61
|
+
export declare function checkOutboundGrantee(kind: string, grantee: GranteeCheck, account: string, env?: NodeJS.ProcessEnv): {
|
|
29
62
|
content: {
|
|
30
63
|
type: "text";
|
|
31
64
|
text: string;
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// Opt-in outbound recipient allowlist: a prompt-injection blast-radius
|
|
2
2
|
// control (a hijacked agent cannot mail/invite/share outside the operator's list). Off by default.
|
|
3
|
+
//
|
|
4
|
+
// Only serves CURATED tools now (drive_share, drive_permission_update,
|
|
5
|
+
// drive_access_proposal_resolve, drive_revision_update, gmail_send/
|
|
6
|
+
// create_draft/send_draft, gmail_set_vacation, calendar attendees). The
|
|
7
|
+
// escape hatch (google_api_call and every generated per-method tool,
|
|
8
|
+
// including the raw gmail.users.settings.delegates/forwardingAddresses/
|
|
9
|
+
// sendAs/filters methods this file used to gate) no longer has its own
|
|
10
|
+
// content-based reasoning here at all: a write there either fails closed
|
|
11
|
+
// on an explicit deny, is allowed outright under full-writes with this
|
|
12
|
+
// allowlist off, or asks a human via elicitation, who sees the real body
|
|
13
|
+
// and decides (src/tools/_escape-hatch.ts).
|
|
3
14
|
import { hints } from './hint-provider.js';
|
|
4
15
|
/** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
|
|
5
16
|
* suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
|
|
@@ -16,6 +27,13 @@ export function resolveOutboundAllowlist(env = process.env) {
|
|
|
16
27
|
entries,
|
|
17
28
|
allows(email) {
|
|
18
29
|
const a = email.trim().toLowerCase();
|
|
30
|
+
// Reject anything that isn't a single address: a comma/semicolon/
|
|
31
|
+
// whitespace-joined string, or more than one "@", could smuggle an
|
|
32
|
+
// unlisted address alongside one that happens to make the WHOLE
|
|
33
|
+
// string pass an @domain suffix check (e.g. "evil@x.io,ok@corp.com"
|
|
34
|
+
// ends with "@corp.com") even though it names two recipients.
|
|
35
|
+
if (!a || /[,;\s]/.test(a) || a.indexOf('@') !== a.lastIndexOf('@'))
|
|
36
|
+
return false;
|
|
19
37
|
if (exact.has(a))
|
|
20
38
|
return true;
|
|
21
39
|
return domains.some((d) => a.endsWith(d));
|
|
@@ -56,51 +74,69 @@ export function checkOutbound(kind, emails, account, env = process.env) {
|
|
|
56
74
|
const blocked = outboundViolations(emails, env);
|
|
57
75
|
return blocked.length > 0 ? outboundDeniedEnvelope(kind, blocked, account, env) : null;
|
|
58
76
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
function asNonEmptyString(v) {
|
|
78
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
79
|
+
}
|
|
80
|
+
const UNRECOGNIZED = { emails: [], domains: [], public: false, understood: false };
|
|
81
|
+
/** Shared by drive_share's own type/domain/emailAddress args and
|
|
82
|
+
* drive_permission_update's permissions.get lookup (same resource
|
|
83
|
+
* shape, fetched instead of trusted from the body: drive.permissions.update
|
|
84
|
+
* only takes role/expirationTime, the grantee is named by the permissionId
|
|
85
|
+
* path parameter, which Google resolves server-side). */
|
|
86
|
+
export function driveGranteeFromPermission(perm) {
|
|
87
|
+
const type = perm?.type ?? undefined;
|
|
88
|
+
if (type === 'anyone')
|
|
89
|
+
return { emails: [], domains: [], public: true, understood: true };
|
|
90
|
+
if (type === 'domain') {
|
|
91
|
+
const domain = asNonEmptyString(perm?.domain);
|
|
92
|
+
return domain ? { emails: [], domains: [`@${domain.toLowerCase()}`], public: false, understood: true } : UNRECOGNIZED;
|
|
93
|
+
}
|
|
94
|
+
if (type === 'user' || type === 'group') {
|
|
95
|
+
const email = asNonEmptyString(perm?.emailAddress);
|
|
96
|
+
return email ? { emails: [email], domains: [], public: false, understood: true } : UNRECOGNIZED;
|
|
97
|
+
}
|
|
98
|
+
return UNRECOGNIZED;
|
|
99
|
+
}
|
|
100
|
+
/** The access proposal's own recipient (the person who gains access if an
|
|
101
|
+
* ACCEPT goes through), for drive_access_proposal_resolve's own
|
|
102
|
+
* accessproposals.get lookup: its own body is just an ACCEPT/DENY action,
|
|
103
|
+
* the requester is a property of the proposal itself. */
|
|
104
|
+
export function driveGranteeFromAccessProposal(proposal) {
|
|
105
|
+
const email = asNonEmptyString(proposal?.recipientEmailAddress) ?? asNonEmptyString(proposal?.requesterEmailAddress);
|
|
106
|
+
return email ? { emails: [email], domains: [], public: false, understood: true } : UNRECOGNIZED;
|
|
107
|
+
}
|
|
108
|
+
function unrecognizedShapeEnvelope(kind, account) {
|
|
109
|
+
return {
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: 'text',
|
|
113
|
+
text: JSON.stringify({
|
|
114
|
+
error: 'recipient_not_allowed',
|
|
115
|
+
message: `${kind} has a body shape the outbound allowlist does not recognize while it is active.`,
|
|
116
|
+
hint: 'Refused rather than risk an unexamined grantee; check the field names against the Discovery schema for this method.',
|
|
117
|
+
retriable: false,
|
|
118
|
+
account,
|
|
119
|
+
}),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
isError: true,
|
|
80
123
|
};
|
|
81
|
-
walk(body, 0);
|
|
82
|
-
return out;
|
|
83
124
|
}
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
125
|
+
/** Applies an already-resolved GranteeCheck against the allowlist: the
|
|
126
|
+
* entry point curated tools use once they have looked up a grantee
|
|
127
|
+
* themselves (drive_permission_update, drive_access_proposal_resolve). */
|
|
128
|
+
export function checkOutboundGrantee(kind, grantee, account, env = process.env) {
|
|
129
|
+
const list = resolveOutboundAllowlist(env);
|
|
130
|
+
if (!list)
|
|
87
131
|
return null;
|
|
88
|
-
if (
|
|
89
|
-
return
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
account,
|
|
99
|
-
}),
|
|
100
|
-
},
|
|
101
|
-
],
|
|
102
|
-
isError: true,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
return checkOutbound(`${methodId} recipient`, collectBodyRecipients(body), account, env);
|
|
132
|
+
if (!grantee.understood)
|
|
133
|
+
return unrecognizedShapeEnvelope(kind, account);
|
|
134
|
+
if (grantee.public)
|
|
135
|
+
return outboundDeniedEnvelope(`${kind} grantee`, ['anyone (public)'], account, env);
|
|
136
|
+
const blockedDomains = grantee.domains.filter((d) => !list.entries.includes(d));
|
|
137
|
+
if (blockedDomains.length > 0)
|
|
138
|
+
return outboundDeniedEnvelope(`${kind} domain grantee`, blockedDomains, account, env);
|
|
139
|
+
if (grantee.emails.length > 0)
|
|
140
|
+
return checkOutbound(`${kind} grantee`, grantee.emails, account, env);
|
|
141
|
+
return null;
|
|
106
142
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -63,6 +63,52 @@ export declare class ToolRegistry {
|
|
|
63
63
|
private expanded;
|
|
64
64
|
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null, accounts?: () => AccountSet);
|
|
65
65
|
registerMeta: McpServer['registerTool'];
|
|
66
|
+
/**
|
|
67
|
+
* Server-side MCP elicitation: asks the connected human to confirm an
|
|
68
|
+
* escape-hatch write. `unsupported` covers every way asking can fail
|
|
69
|
+
* (no elicitation capability, the transport cannot deliver a mid-call
|
|
70
|
+
* request, the negotiated protocol era replaced the push-style
|
|
71
|
+
* request, a timeout, a closed connection, ...) uniformly, since the
|
|
72
|
+
* caller's only correct response to any of them is the same: refuse
|
|
73
|
+
* rather than silently proceed without an answer. `cause` lets the
|
|
74
|
+
* caller phrase the refusal correctly: `http_transport` covers BOTH a
|
|
75
|
+
* self-hosted HTTP deployment and an EE hosted tenant, which this
|
|
76
|
+
* server cannot tell apart, so the caller must not suggest
|
|
77
|
+
* GOOGLE_PROFILE there (a hosted tenant cannot set it); the other
|
|
78
|
+
* causes are stdio-only, where the caller IS the operator and that
|
|
79
|
+
* suggestion is safe.
|
|
80
|
+
*
|
|
81
|
+
* `policy.transport === 'http'` refuses before ever touching
|
|
82
|
+
* elicitInput, regardless of what the client declared. This server's
|
|
83
|
+
* HTTP (EE) transport dispatches every `/mcp` call through a fresh,
|
|
84
|
+
* disposable transport with `enableJsonResponse: true` (see
|
|
85
|
+
* docs/internals.md); a mid-call server-to-client request has nothing
|
|
86
|
+
* to be delivered over there, so even a client that DID declare the
|
|
87
|
+
* capability would otherwise just hang until elicitInput's own
|
|
88
|
+
* request timeout before this failed closed anyway, not fail fast.
|
|
89
|
+
* stdio keeps one live connection for the whole call, so it is
|
|
90
|
+
* unaffected and still attempts a real elicitation below.
|
|
91
|
+
*
|
|
92
|
+
* The capability check (same pattern account-wizard.ts's form
|
|
93
|
+
* elicitation already uses) covers the remaining case on stdio: a
|
|
94
|
+
* client that never declared the capability is refused immediately
|
|
95
|
+
* too, instead of attempting the call and only finding out once
|
|
96
|
+
* elicitInput's own request timeout elapses. A bare `elicitation: {}`
|
|
97
|
+
* (no nested `form` key) still counts as form support: some clients on
|
|
98
|
+
* the 2025-06-18 protocol revision declare the capability this way,
|
|
99
|
+
* before the form/url sub-modes existed to declare.
|
|
100
|
+
*
|
|
101
|
+
* The confirmation itself gets several minutes, not the SDK's 60s
|
|
102
|
+
* request default: reading a real call and deciding is slower than a
|
|
103
|
+
* typical tool round trip (documented in docs/configuration.md).
|
|
104
|
+
*/
|
|
105
|
+
elicitConfirmation(message: string): Promise<{
|
|
106
|
+
action: 'accept' | 'decline' | 'cancel';
|
|
107
|
+
} | {
|
|
108
|
+
action: 'unsupported';
|
|
109
|
+
reason: string;
|
|
110
|
+
cause: 'http_transport' | 'no_capability' | 'error';
|
|
111
|
+
}>;
|
|
66
112
|
services(): string[];
|
|
67
113
|
/** The registry's OWN alias list: registration-time schema builders (the
|
|
68
114
|
* generated account enums) read this, never the process global. */
|
package/dist/registry.js
CHANGED
|
@@ -13,6 +13,10 @@ const EMPTY_SCOPES = new Set();
|
|
|
13
13
|
// Client-side result budget for tools that do not declare their own (fat
|
|
14
14
|
// readers do; see trim.ts); stays well inside a default client context limit.
|
|
15
15
|
const DEFAULT_MAX_RESULT_CHARS = 50_000;
|
|
16
|
+
// A human reading a real write's confirmation prompt and deciding takes far
|
|
17
|
+
// longer than a typical tool round trip; the SDK's 60s request default would
|
|
18
|
+
// otherwise time the ask out mid-read on a slow or distracted reviewer.
|
|
19
|
+
const ELICIT_TIMEOUT_MS = 5 * 60_000;
|
|
16
20
|
const DISCOVERY_MODES = ['lazy', 'curated', 'eager'];
|
|
17
21
|
export function resolveDiscoveryMode(env = process.env) {
|
|
18
22
|
const raw = (env.GOOGLE_DISCOVERY ?? 'lazy').trim();
|
|
@@ -208,7 +212,7 @@ export class ToolRegistry {
|
|
|
208
212
|
irreversible: IRREVERSIBLE_TOOLS.has(name),
|
|
209
213
|
requiredScopes: config.requiredScopes,
|
|
210
214
|
});
|
|
211
|
-
const guarded = cud === 'read'
|
|
215
|
+
const guarded = cud === 'read' || config.escapeHatch
|
|
212
216
|
? baseHandler
|
|
213
217
|
: (...args) => isAllowed({ name, service, cud, scopes: config.requiredScopes }, policy)
|
|
214
218
|
? baseHandler(...args)
|
|
@@ -277,6 +281,68 @@ export class ToolRegistry {
|
|
|
277
281
|
this.registeringMeta = false;
|
|
278
282
|
}
|
|
279
283
|
});
|
|
284
|
+
/**
|
|
285
|
+
* Server-side MCP elicitation: asks the connected human to confirm an
|
|
286
|
+
* escape-hatch write. `unsupported` covers every way asking can fail
|
|
287
|
+
* (no elicitation capability, the transport cannot deliver a mid-call
|
|
288
|
+
* request, the negotiated protocol era replaced the push-style
|
|
289
|
+
* request, a timeout, a closed connection, ...) uniformly, since the
|
|
290
|
+
* caller's only correct response to any of them is the same: refuse
|
|
291
|
+
* rather than silently proceed without an answer. `cause` lets the
|
|
292
|
+
* caller phrase the refusal correctly: `http_transport` covers BOTH a
|
|
293
|
+
* self-hosted HTTP deployment and an EE hosted tenant, which this
|
|
294
|
+
* server cannot tell apart, so the caller must not suggest
|
|
295
|
+
* GOOGLE_PROFILE there (a hosted tenant cannot set it); the other
|
|
296
|
+
* causes are stdio-only, where the caller IS the operator and that
|
|
297
|
+
* suggestion is safe.
|
|
298
|
+
*
|
|
299
|
+
* `policy.transport === 'http'` refuses before ever touching
|
|
300
|
+
* elicitInput, regardless of what the client declared. This server's
|
|
301
|
+
* HTTP (EE) transport dispatches every `/mcp` call through a fresh,
|
|
302
|
+
* disposable transport with `enableJsonResponse: true` (see
|
|
303
|
+
* docs/internals.md); a mid-call server-to-client request has nothing
|
|
304
|
+
* to be delivered over there, so even a client that DID declare the
|
|
305
|
+
* capability would otherwise just hang until elicitInput's own
|
|
306
|
+
* request timeout before this failed closed anyway, not fail fast.
|
|
307
|
+
* stdio keeps one live connection for the whole call, so it is
|
|
308
|
+
* unaffected and still attempts a real elicitation below.
|
|
309
|
+
*
|
|
310
|
+
* The capability check (same pattern account-wizard.ts's form
|
|
311
|
+
* elicitation already uses) covers the remaining case on stdio: a
|
|
312
|
+
* client that never declared the capability is refused immediately
|
|
313
|
+
* too, instead of attempting the call and only finding out once
|
|
314
|
+
* elicitInput's own request timeout elapses. A bare `elicitation: {}`
|
|
315
|
+
* (no nested `form` key) still counts as form support: some clients on
|
|
316
|
+
* the 2025-06-18 protocol revision declare the capability this way,
|
|
317
|
+
* before the form/url sub-modes existed to declare.
|
|
318
|
+
*
|
|
319
|
+
* The confirmation itself gets several minutes, not the SDK's 60s
|
|
320
|
+
* request default: reading a real call and deciding is slower than a
|
|
321
|
+
* typical tool round trip (documented in docs/configuration.md).
|
|
322
|
+
*/
|
|
323
|
+
async elicitConfirmation(message) {
|
|
324
|
+
if (this.policy.transport === 'http') {
|
|
325
|
+
return {
|
|
326
|
+
action: 'unsupported',
|
|
327
|
+
reason: 'elicitation is not available over this server\'s HTTP transport',
|
|
328
|
+
cause: 'http_transport',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const caps = this.server.server.getClientCapabilities?.();
|
|
332
|
+
if (!caps?.elicitation || typeof caps.elicitation !== 'object') {
|
|
333
|
+
return { action: 'unsupported', reason: 'the connected client has not declared elicitation support', cause: 'no_capability' };
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const result = await this.server.server.elicitInput({ message, requestedSchema: { type: 'object', properties: {} } }, { timeout: ELICIT_TIMEOUT_MS });
|
|
337
|
+
if (result.action === 'accept' || result.action === 'decline' || result.action === 'cancel') {
|
|
338
|
+
return { action: result.action };
|
|
339
|
+
}
|
|
340
|
+
return { action: 'unsupported', reason: `unexpected elicitation result action "${result.action}"`, cause: 'error' };
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
return { action: 'unsupported', reason: err instanceof Error ? err.message : String(err), cause: 'error' };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
280
346
|
services() {
|
|
281
347
|
return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
|
|
282
348
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ToolRegistry, Cud } from '../registry.js';
|
|
2
|
+
import { type Policy } from '../write-control.js';
|
|
3
|
+
import { jsonResult } from '../executor.js';
|
|
4
|
+
export interface EscapeHatchCallInfo {
|
|
5
|
+
account: string;
|
|
6
|
+
accountEmail?: string;
|
|
7
|
+
methodId: string;
|
|
8
|
+
httpMethod: string;
|
|
9
|
+
pathParams?: Record<string, unknown>;
|
|
10
|
+
queryParams?: Record<string, unknown>;
|
|
11
|
+
body?: unknown;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Gate for every escape-hatch write; returns null when the call may
|
|
15
|
+
* proceed (to executeApiMethod), or a ToolResult when it must not (a
|
|
16
|
+
* hard refusal, the body could not be summarized safely, or the human
|
|
17
|
+
* declined/cancelled/could not be asked).
|
|
18
|
+
*/
|
|
19
|
+
export declare function escapeHatchGate(registry: ToolRegistry, denyNames: readonly string[], primaryName: string, base: {
|
|
20
|
+
service: string;
|
|
21
|
+
cud: Cud;
|
|
22
|
+
scopes?: readonly string[];
|
|
23
|
+
}, policy: Policy, info: EscapeHatchCallInfo): Promise<ReturnType<typeof jsonResult> | null>;
|