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 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
- const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
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: {
@@ -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 as a reserved seam;
23
- // it does not change any write-control verdict.
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 CUD verdicts follow profile=${policy.profile} and your allow/deny globs`);
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 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): {
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
- // RAW compose methods can't be inspected (base64 RFC 822), so with the
60
- // allowlist active they're refused outright in favor of the curated tools that enforce it.
61
- 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']);
62
- export function collectBodyRecipients(body) {
63
- const out = [];
64
- const walk = (v, depth) => {
65
- if (!v || typeof v !== 'object' || depth > 4)
66
- return;
67
- if (Array.isArray(v)) {
68
- for (const x of v)
69
- walk(x, depth + 1);
70
- return;
71
- }
72
- for (const [k, x] of Object.entries(v)) {
73
- if ((k === 'emailAddress' || k === 'email') && typeof x === 'string' && x.includes('@'))
74
- out.push(x);
75
- else if (k === 'attendees' || k === 'permissions')
76
- walk(x, depth + 1);
77
- else if (typeof x === 'object')
78
- walk(x, depth + 1);
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
- /** Gate one escape-hatch/generated dispatch; envelope when blocked, else null. */
85
- export function checkOutboundForMethod(methodId, body, account, env = process.env) {
86
- if (!resolveOutboundAllowlist(env))
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 (RAW_SEND_METHODS.has(methodId)) {
89
- return {
90
- content: [
91
- {
92
- type: 'text',
93
- text: JSON.stringify({
94
- error: 'recipient_not_allowed',
95
- message: `${methodId} carries an uninspectable raw message while GOOGLE_OUTBOUND_ALLOWLIST is active.`,
96
- hint: 'Use gmail_send / gmail_create_draft, which enforce the allowlist on parsed recipients.',
97
- retriable: false,
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
  }
@@ -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>;