mcp-google-multi 6.0.0-beta.4 → 6.0.0-beta.5
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 +15 -1
- package/dist/discover.js +2 -3
- package/dist/fs-atomic.js +98 -12
- package/dist/hint-provider.d.ts +4 -1
- package/dist/mcp-token.d.ts +37 -3
- package/dist/mcp-token.js +105 -12
- package/dist/oauth-as.d.ts +5 -0
- package/dist/oauth-as.js +13 -5
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +59 -10
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +17 -6
- package/dist/tools/admin.js +6 -1
- package/dist/tools/analytics.js +28 -11
- package/dist/tools/calendar.d.ts +5 -0
- package/dist/tools/calendar.js +59 -0
- package/dist/tools/contacts.js +11 -5
- package/dist/tools/docs.js +41 -12
- package/dist/tools/drive.js +54 -12
- package/dist/tools/generated/gmail.js +0 -14
- package/dist/tools/generated/method-map.js +1 -1
- package/dist/tools/gmail.d.ts +1 -1
- package/dist/tools/gmail.js +109 -12
- package/dist/tools/sheets.d.ts +10 -0
- package/dist/tools/sheets.js +109 -2
- package/dist/write-control.js +15 -2
- package/package.json +4 -2
- package/skills/google-workspace/SKILL.md +162 -0
- package/skills/google-workspace/reference/admin.md +105 -0
- package/skills/google-workspace/reference/analytics.md +70 -0
- package/skills/google-workspace/reference/appsmarket.md +5 -0
- package/skills/google-workspace/reference/calendar.md +175 -0
- package/skills/google-workspace/reference/chat.md +59 -0
- package/skills/google-workspace/reference/classroom.md +5 -0
- package/skills/google-workspace/reference/cloudidentity.md +5 -0
- package/skills/google-workspace/reference/cloudsearch.md +5 -0
- package/skills/google-workspace/reference/contacts.md +138 -0
- package/skills/google-workspace/reference/docs.md +395 -0
- package/skills/google-workspace/reference/drive.md +485 -0
- package/skills/google-workspace/reference/driveactivity.md +5 -0
- package/skills/google-workspace/reference/drivelabels.md +5 -0
- package/skills/google-workspace/reference/forms.md +92 -0
- package/skills/google-workspace/reference/gmail.md +305 -0
- package/skills/google-workspace/reference/groupsmigration.md +5 -0
- package/skills/google-workspace/reference/groupssettings.md +5 -0
- package/skills/google-workspace/reference/keep.md +5 -0
- package/skills/google-workspace/reference/licensing.md +5 -0
- package/skills/google-workspace/reference/meet.md +68 -0
- package/skills/google-workspace/reference/postmaster.md +5 -0
- package/skills/google-workspace/reference/reseller.md +5 -0
- package/skills/google-workspace/reference/script.md +5 -0
- package/skills/google-workspace/reference/searchconsole.md +128 -0
- package/skills/google-workspace/reference/sheets.md +430 -0
- package/skills/google-workspace/reference/slides.md +67 -0
- package/skills/google-workspace/reference/tasks.md +167 -0
- package/skills/google-workspace/reference/vault.md +5 -0
- package/skills/google-workspace/reference/workspaceevents.md +5 -0
package/dist/registry.js
CHANGED
|
@@ -23,11 +23,44 @@ export function resolveDiscoveryMode(env = process.env) {
|
|
|
23
23
|
}
|
|
24
24
|
return DISCOVERY_MODES.includes(raw) ? raw : 'lazy';
|
|
25
25
|
}
|
|
26
|
+
/** The leading sentence of a (possibly multi-sentence) tool description. */
|
|
27
|
+
export function firstSentence(text) {
|
|
28
|
+
let openParens = 0;
|
|
29
|
+
let i = 0;
|
|
30
|
+
const abbrevs = /^(e\.g|i\.e|etc|vs)\.$/i;
|
|
31
|
+
while (i < text.length) {
|
|
32
|
+
const char = text[i];
|
|
33
|
+
if (char === '(') {
|
|
34
|
+
openParens++;
|
|
35
|
+
}
|
|
36
|
+
else if (char === ')') {
|
|
37
|
+
openParens = Math.max(0, openParens - 1);
|
|
38
|
+
}
|
|
39
|
+
else if (char === '.' && i + 1 < text.length && text[i + 1] === ' ' && openParens === 0) {
|
|
40
|
+
// Found a period-space outside parentheses
|
|
41
|
+
// Check if it's preceded by an abbreviation (e.g., i.e., etc, vs)
|
|
42
|
+
// Find the word before this period
|
|
43
|
+
let wordStart = i - 1;
|
|
44
|
+
while (wordStart > 0 && /[a-zA-Z.]/.test(text[wordStart - 1])) {
|
|
45
|
+
wordStart--;
|
|
46
|
+
}
|
|
47
|
+
const word = text.slice(wordStart, i + 1);
|
|
48
|
+
if (!abbrevs.test(word)) {
|
|
49
|
+
// Not an abbreviation, so this is a valid sentence boundary
|
|
50
|
+
return text.slice(0, i + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
26
57
|
const CUD_OVERRIDES = {
|
|
27
58
|
drive_untrash: 'update',
|
|
28
59
|
drive_transfer: 'create',
|
|
29
60
|
// read-only resolver; the name's "resolve" verb would otherwise infer update.
|
|
30
61
|
contacts_resolve: 'read',
|
|
62
|
+
// "respond" matches no write verb; it patches the event (a write).
|
|
63
|
+
calendar_respond_event: 'update',
|
|
31
64
|
// Gated by anthropic/requiresUserInteraction, not write-control, so
|
|
32
65
|
// onboarding works under any profile; cud=read also keeps them off fan-out.
|
|
33
66
|
account_add: 'read',
|
|
@@ -196,8 +229,10 @@ export class ToolRegistry {
|
|
|
196
229
|
refreshAccountSetIfStale();
|
|
197
230
|
const def = this.accounts().defaultAccount;
|
|
198
231
|
if (!def) {
|
|
199
|
-
const
|
|
200
|
-
const
|
|
232
|
+
const accountSet = this.accounts();
|
|
233
|
+
const accounts = accountSet.aliases.map((alias) => ({ alias, email: accountSet.configs[alias]?.email ?? '' }));
|
|
234
|
+
const rendered = hints()?.render({ kind: 'no_default_account', accounts });
|
|
235
|
+
const pairs = accounts.map((a) => `${a.alias}=${a.email}`).join(', ');
|
|
201
236
|
return {
|
|
202
237
|
content: [
|
|
203
238
|
{
|
|
@@ -205,7 +240,7 @@ export class ToolRegistry {
|
|
|
205
240
|
text: JSON.stringify({
|
|
206
241
|
error: 'E_NO_DEFAULT_ACCOUNT',
|
|
207
242
|
message: 'No "account" given and no default account is configured.',
|
|
208
|
-
hint: rendered?.hint ?? `Pass account explicitly (valid: ${
|
|
243
|
+
hint: rendered?.hint ?? `Pass account explicitly (valid: ${pairs}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
|
|
209
244
|
retriable: false,
|
|
210
245
|
}),
|
|
211
246
|
},
|
|
@@ -313,17 +348,31 @@ export class ToolRegistry {
|
|
|
313
348
|
.map(([key, e]) => ({ key, tools: e.tools }));
|
|
314
349
|
}
|
|
315
350
|
catalog(service, query) {
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
.filter((t) => !t.meta && t.service === service)
|
|
319
|
-
.filter((t) => !q || t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q))
|
|
320
|
-
.map((t) => ({
|
|
351
|
+
const candidates = this.tools.filter((t) => !t.meta && t.service === service);
|
|
352
|
+
const toOp = (t, summary = t.description) => ({
|
|
321
353
|
tool: t.name,
|
|
322
|
-
summary
|
|
354
|
+
summary,
|
|
323
355
|
args: Object.keys(t.inputShape),
|
|
324
356
|
cud: t.cud,
|
|
325
357
|
...(this.isUngrantable(t) ? { unreachable: true } : {}),
|
|
326
|
-
})
|
|
358
|
+
});
|
|
359
|
+
const q = query?.trim().toLowerCase();
|
|
360
|
+
if (!q)
|
|
361
|
+
return candidates.map((t) => toOp(t));
|
|
362
|
+
// Tokenized OR-match (a strict superset of a plain substring test: a full-string
|
|
363
|
+
// hit implies every one of its tokens also hits) ranked by how many tokens matched.
|
|
364
|
+
const ranked = candidates
|
|
365
|
+
.map((t) => {
|
|
366
|
+
const hay = `${t.name} ${t.description}`.toLowerCase();
|
|
367
|
+
return { t, matched: q.split(/\s+/).filter(Boolean).filter((tok) => hay.includes(tok)).length };
|
|
368
|
+
})
|
|
369
|
+
.filter((r) => r.matched > 0)
|
|
370
|
+
.sort((a, b) => b.matched - a.matched);
|
|
371
|
+
if (ranked.length > 0)
|
|
372
|
+
return ranked.map((r) => toOp(r.t));
|
|
373
|
+
// Nothing matched: list the whole service so the agent can still browse,
|
|
374
|
+
// one-line summaries only so a wide miss doesn't blow the response budget.
|
|
375
|
+
return candidates.map((t) => toOp(t, firstSentence(t.description)));
|
|
327
376
|
}
|
|
328
377
|
/** The metrics recorder, for hook sites (escape hatch); null when off. */
|
|
329
378
|
get usageMetrics() {
|
|
@@ -36,4 +36,11 @@ export interface AccountHealth {
|
|
|
36
36
|
scopes: ScopesReport;
|
|
37
37
|
}
|
|
38
38
|
export declare function deriveAccountHealth(alias: string, deps?: AccountHealthDeps): AccountHealth;
|
|
39
|
+
export interface AccountSummary {
|
|
40
|
+
alias: string;
|
|
41
|
+
email: string;
|
|
42
|
+
admin: boolean;
|
|
43
|
+
tokenStatus: TokenStatus;
|
|
44
|
+
default: boolean;
|
|
45
|
+
}
|
|
39
46
|
export declare function registerAccountTools(registry: ToolRegistry, deps?: AccountHealthDeps): void;
|
|
@@ -5,6 +5,7 @@ import { buildScopesReport } from '../scope-observability.js';
|
|
|
5
5
|
import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
|
|
6
6
|
import { hasToken, readToken } from '../token-store.js';
|
|
7
7
|
import { hints } from '../hint-provider.js';
|
|
8
|
+
import { coerceBoolean } from './_coerce.js';
|
|
8
9
|
/** Health deps over one context's token store (and, optionally, its account
|
|
9
10
|
* view); the module-level functions are the single owner's store. */
|
|
10
11
|
export function accountHealthDepsFor(store, accounts) {
|
|
@@ -91,18 +92,28 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
|
|
|
91
92
|
scopes: report,
|
|
92
93
|
};
|
|
93
94
|
}
|
|
95
|
+
function summarizeAccountHealth(health, isDefault) {
|
|
96
|
+
return {
|
|
97
|
+
alias: health.alias,
|
|
98
|
+
email: health.email,
|
|
99
|
+
admin: health.admin,
|
|
100
|
+
tokenStatus: health.token.status,
|
|
101
|
+
default: isDefault,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
94
104
|
export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
|
|
95
105
|
const registerMeta = registry.registerMeta;
|
|
96
106
|
registerMeta('account_list', {
|
|
97
|
-
description: 'List the configured Google accounts: alias, email, admin flag, token
|
|
98
|
-
'(ok / expired_refreshable / needs_reauth / missing / decrypt_error), and
|
|
99
|
-
'
|
|
100
|
-
inputSchema: {},
|
|
107
|
+
description: 'List the configured Google accounts: alias, email, admin flag, token status ' +
|
|
108
|
+
'(ok / expired_refreshable / needs_reauth / missing / decrypt_error), and which one is the default. ' +
|
|
109
|
+
'Pass verbose: true for the full per-account report (granted vs configured scopes).',
|
|
110
|
+
inputSchema: { verbose: coerceBoolean.optional().describe('Return the full scope report instead of the terse default') },
|
|
101
111
|
_meta: { 'anthropic/alwaysLoad': true },
|
|
102
|
-
}, async () => {
|
|
112
|
+
}, async ({ verbose }) => {
|
|
103
113
|
refreshAccountSetIfStale();
|
|
104
114
|
const set = registry.accountSet();
|
|
105
115
|
const health = { ...deps, accounts: () => set };
|
|
116
|
+
const reports = set.aliases.map((alias) => deriveAccountHealth(alias, health));
|
|
106
117
|
return {
|
|
107
118
|
content: [
|
|
108
119
|
{
|
|
@@ -110,7 +121,7 @@ export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
|
|
|
110
121
|
text: JSON.stringify({
|
|
111
122
|
defaultAccount: set.defaultAccount ?? null,
|
|
112
123
|
defaultAccountSource: set.defaultAccountSource ?? null,
|
|
113
|
-
accounts:
|
|
124
|
+
accounts: verbose ? reports : reports.map((r) => summarizeAccountHealth(r, r.alias === set.defaultAccount)),
|
|
114
125
|
}),
|
|
115
126
|
},
|
|
116
127
|
],
|
package/dist/tools/admin.js
CHANGED
|
@@ -68,7 +68,12 @@ export function registerAdminTools(server, deps = {}) {
|
|
|
68
68
|
account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
|
|
69
69
|
customer: z.string().default('my_customer').optional(),
|
|
70
70
|
domain: z.string().optional(),
|
|
71
|
-
query: z.string().optional().describe('
|
|
71
|
+
query: z.string().optional().describe('Directory search query: space-separated clauses of field+operator+value, ANDed together. ' +
|
|
72
|
+
'"=" is exact match, ":" is a contains/startsWith match. Searchable fields include email, name ' +
|
|
73
|
+
'(matches the concatenated givenName+familyName), givenName, familyName, isAdmin, isDelegatedAdmin, ' +
|
|
74
|
+
'orgName, orgTitle, orgDepartment, manager, directManager, addressCountry. Prefix/wildcard matching ' +
|
|
75
|
+
'is a trailing "*" on a ":" clause, e.g. "email:admin*" for every address starting with "admin". ' +
|
|
76
|
+
'Examples: \'name:John\', \'isAdmin=true\', \'orgTitle:Manager orgName=Engineering\'.'),
|
|
72
77
|
maxResults: z.number().min(1).max(500).optional(),
|
|
73
78
|
pageToken: z.string().optional(),
|
|
74
79
|
orderBy: z.enum(['email', 'familyName', 'givenName']).optional(),
|
package/dist/tools/analytics.js
CHANGED
|
@@ -4,7 +4,7 @@ import { analyticsdata as analyticsdataClient } from '@googleapis/analyticsdata'
|
|
|
4
4
|
import { analyticsadmin as analyticsadminClient } from '@googleapis/analyticsadmin';
|
|
5
5
|
import { accountArgLive } from '../accounts.js';
|
|
6
6
|
import { getClient } from '../client.js';
|
|
7
|
-
import { handleGoogleApiError } from './_errors.js';
|
|
7
|
+
import { handleGoogleApiError, invalidParams } from './_errors.js';
|
|
8
8
|
/** Accepts "213025502" or "properties/213025502"; rejects the identifiers
|
|
9
9
|
* people paste by mistake (G-… measurement IDs, UA-… properties) with a
|
|
10
10
|
* pointer to the right one. */
|
|
@@ -104,6 +104,11 @@ export function shapeMetadata(data) {
|
|
|
104
104
|
const propertySchema = z
|
|
105
105
|
.string()
|
|
106
106
|
.describe('GA4 property: numeric ID like "213025502" or "properties/213025502" — NOT a "G-..." measurement ID and not "UA-...". Find yours with analytics_account_summaries.');
|
|
107
|
+
// Accepts this server's plain string form and the native GA4 API shape ({name}),
|
|
108
|
+
// so a request built against GA4's own docs works unmodified.
|
|
109
|
+
const metricOrDimensionRef = z.union([z.string(), z.object({ name: z.string() })]);
|
|
110
|
+
const nameOf = (ref) => (typeof ref === 'string' ? ref : ref.name);
|
|
111
|
+
const dateRangeRef = z.object({ startDate: z.string(), endDate: z.string() });
|
|
107
112
|
export function registerAnalyticsTools(server, deps = {}) {
|
|
108
113
|
// Per-registry, live account enum + injectable client: the schema follows
|
|
109
114
|
// the registry's account view at parse time, not a process-global default.
|
|
@@ -130,16 +135,19 @@ export function registerAnalyticsTools(server, deps = {}) {
|
|
|
130
135
|
}
|
|
131
136
|
});
|
|
132
137
|
server.registerTool('analytics_run_report', {
|
|
133
|
-
description: 'Run a Google Analytics (GA4) report: metrics over a date range, optionally grouped by dimensions — the workhorse for questions like "how many users last week, by country". Dates accept YYYY-MM-DD or relative forms ("today", "yesterday", "28daysAgo"). Unsure which dimension/metric names are valid? Call analytics_get_metadata first.',
|
|
138
|
+
description: 'Run a Google Analytics (GA4) report: metrics over a date range, optionally grouped by dimensions — the workhorse for questions like "how many users last week, by country". Dates accept YYYY-MM-DD or relative forms ("today", "yesterday", "28daysAgo"). Also accepts the native GA4 request shapes directly: a single dateRanges entry in place of startDate/endDate, and metrics/dimensions as {name} objects. Unsure which dimension/metric names are valid? Call analytics_get_metadata first.',
|
|
134
139
|
inputSchema: {
|
|
135
140
|
account: accountEnum.describe('Google account alias'),
|
|
136
141
|
property: propertySchema,
|
|
137
|
-
startDate: z.string().describe('Start date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo" e.g. "28daysAgo")'),
|
|
138
|
-
endDate: z.string().describe('End date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo")'),
|
|
139
|
-
|
|
140
|
-
dimensions: coerceArray(z.string())
|
|
142
|
+
startDate: z.string().optional().describe('Start date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo" e.g. "28daysAgo"). Required unless dateRanges is given.'),
|
|
143
|
+
endDate: z.string().optional().describe('End date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo"). Required unless dateRanges is given.'),
|
|
144
|
+
dateRanges: coerceJson(z.array(dateRangeRef))
|
|
141
145
|
.optional()
|
|
142
|
-
.describe('
|
|
146
|
+
.describe('Native GA4 shape, as an alternative to startDate/endDate: exactly one {startDate, endDate} entry (this tool runs one range per call).'),
|
|
147
|
+
metrics: coerceArray(metricOrDimensionRef).describe('Metric API names, e.g. ["activeUsers","sessions","screenPageViews"], or the native GA4 [{"name":"activeUsers"}] form. Max 10. Valid names (including custom ones) come from analytics_get_metadata.'),
|
|
148
|
+
dimensions: coerceArray(metricOrDimensionRef)
|
|
149
|
+
.optional()
|
|
150
|
+
.describe('Dimension API names to group by, e.g. ["date"] or ["country","deviceCategory"], or the native GA4 {"name":...} form. Max 9. Omit for a single total row.'),
|
|
143
151
|
dimensionFilter: coerceJson(z.record(z.string(), z.unknown()))
|
|
144
152
|
.optional()
|
|
145
153
|
.describe('FilterExpression on dimensions (applies independently of metricFilter). Simple: {"filter":{"fieldName":"country","stringFilter":{"matchType":"EXACT","value":"France"}}}. AND of two: {"andGroup":{"expressions":[{"filter":{"fieldName":"country","stringFilter":{"matchType":"EXACT","value":"France"}}},{"filter":{"fieldName":"deviceCategory","stringFilter":{"matchType":"EXACT","value":"mobile"}}}]}}. matchType: EXACT | BEGINS_WITH | ENDS_WITH | CONTAINS | FULL_REGEXP (add "caseSensitive":true for case-sensitive). Also available: inListFilter, notExpression, orGroup.'),
|
|
@@ -157,19 +165,28 @@ export function registerAnalyticsTools(server, deps = {}) {
|
|
|
157
165
|
keepEmptyRows: coerceBoolean.optional().describe('Include rows whose metrics are all zero (default false)'),
|
|
158
166
|
returnPropertyQuota: coerceBoolean.optional().describe("Include this property's remaining quota tokens in the response"),
|
|
159
167
|
},
|
|
160
|
-
}, async ({ account, property, startDate, endDate, metrics, dimensions, dimensionFilter, metricFilter, orderBys, limit, offset, metricAggregations, keepEmptyRows, returnPropertyQuota }) => {
|
|
168
|
+
}, async ({ account, property, startDate, endDate, dateRanges, metrics, dimensions, dimensionFilter, metricFilter, orderBys, limit, offset, metricAggregations, keepEmptyRows, returnPropertyQuota }) => {
|
|
161
169
|
const prop = normalizeProperty(property);
|
|
162
170
|
if ('hint' in prop)
|
|
163
171
|
return invalidProperty(prop.hint, account);
|
|
172
|
+
if (dateRanges !== undefined && dateRanges.length > 1) {
|
|
173
|
+
return invalidParams(account, `dateRanges has ${dateRanges.length} entries, but analytics_run_report runs a single range per call.`, 'Pass exactly one {startDate, endDate} entry, or call the tool once per range.');
|
|
174
|
+
}
|
|
175
|
+
const range = dateRanges?.[0];
|
|
176
|
+
const resolvedStart = range?.startDate ?? startDate;
|
|
177
|
+
const resolvedEnd = range?.endDate ?? endDate;
|
|
178
|
+
if (resolvedStart === undefined || resolvedEnd === undefined) {
|
|
179
|
+
return invalidParams(account, 'Missing a date range: pass startDate and endDate, or a single-entry dateRanges.', 'e.g. startDate: "28daysAgo", endDate: "today", or dateRanges: [{"startDate":"28daysAgo","endDate":"today"}].');
|
|
180
|
+
}
|
|
164
181
|
try {
|
|
165
182
|
const auth = await getClientFn(account);
|
|
166
183
|
const dataApi = analyticsdataClient({ version: 'v1beta', auth });
|
|
167
184
|
const requestBody = {
|
|
168
|
-
dateRanges: [{ startDate, endDate }],
|
|
169
|
-
metrics: metrics.map((
|
|
185
|
+
dateRanges: [{ startDate: resolvedStart, endDate: resolvedEnd }],
|
|
186
|
+
metrics: metrics.map((ref) => ({ name: nameOf(ref) })),
|
|
170
187
|
};
|
|
171
188
|
if (dimensions?.length)
|
|
172
|
-
requestBody.dimensions = dimensions.map((
|
|
189
|
+
requestBody.dimensions = dimensions.map((ref) => ({ name: nameOf(ref) }));
|
|
173
190
|
if (dimensionFilter)
|
|
174
191
|
requestBody.dimensionFilter = dimensionFilter;
|
|
175
192
|
if (metricFilter)
|
package/dist/tools/calendar.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ export declare function registerCalendarTools(server: ToolRegistry, deps?: Curat
|
|
|
8
8
|
* Date.parse is lenient, so it is used only to SKIP the check, never to accept. */
|
|
9
9
|
export declare function timeRangeError(timeMin: string | undefined, timeMax: string | undefined): string | null;
|
|
10
10
|
export declare const TIME_RANGE_HINT: string;
|
|
11
|
+
/** The Calendar API requires timeMin/timeMax as RFC3339 with a mandatory
|
|
12
|
+
* offset; a bare local datetime is silently ambiguous rather than rejected,
|
|
13
|
+
* so catch it here with a message that names the field. */
|
|
14
|
+
export declare function missingTimeZoneError(field: 'timeMin' | 'timeMax', value: string | undefined): string | null;
|
|
15
|
+
export declare const MISSING_TIME_ZONE_HINT = "Add \"Z\" for UTC or an explicit offset, e.g. \"2026-04-04T00:00:00Z\" or \"2026-04-04T00:00:00+01:00\".";
|
|
11
16
|
export declare function formatEvent(event: any, opts?: {
|
|
12
17
|
full?: boolean;
|
|
13
18
|
}): {
|
package/dist/tools/calendar.js
CHANGED
|
@@ -54,6 +54,9 @@ export function registerCalendarTools(server, deps = {}) {
|
|
|
54
54
|
.describe('Continuation token from a previous call\'s nextPageToken'),
|
|
55
55
|
},
|
|
56
56
|
}, async ({ account, calendarId, query, timeMin, timeMax, maxResults, pageToken }) => {
|
|
57
|
+
const tzError = missingTimeZoneError('timeMin', timeMin) ?? missingTimeZoneError('timeMax', timeMax);
|
|
58
|
+
if (tzError)
|
|
59
|
+
return invalidParams(account, tzError, MISSING_TIME_ZONE_HINT);
|
|
57
60
|
const rangeError = timeRangeError(timeMin, timeMax);
|
|
58
61
|
if (rangeError)
|
|
59
62
|
return invalidParams(account, rangeError, TIME_RANGE_HINT);
|
|
@@ -236,6 +239,46 @@ export function registerCalendarTools(server, deps = {}) {
|
|
|
236
239
|
return handleCalendarError(error, account);
|
|
237
240
|
}
|
|
238
241
|
});
|
|
242
|
+
// attendeesOmitted plus a single-entry attendees array patches only the caller's own RSVP.
|
|
243
|
+
server.registerTool('calendar_respond_event', {
|
|
244
|
+
description: 'RSVP to a calendar event as this account (accepted/declined/tentative) without touching any other attendee',
|
|
245
|
+
inputSchema: {
|
|
246
|
+
account: accountEnum.describe('Google account alias'),
|
|
247
|
+
eventId: z.string().min(1).describe('Calendar event ID'),
|
|
248
|
+
calendarId: z.string().default('primary').optional()
|
|
249
|
+
.describe('Calendar ID (default: primary)'),
|
|
250
|
+
response: z.enum(['accepted', 'declined', 'tentative']).describe('RSVP response'),
|
|
251
|
+
comment: z.string().optional().describe('Optional comment attached to the response'),
|
|
252
|
+
},
|
|
253
|
+
}, async ({ account, eventId, calendarId, response, comment }) => {
|
|
254
|
+
try {
|
|
255
|
+
const auth = await getClientFn(account);
|
|
256
|
+
const cal = calendarClient({ version: 'v3', auth });
|
|
257
|
+
const cid = calendarId ?? 'primary';
|
|
258
|
+
const current = await cal.events.get({ calendarId: cid, eventId });
|
|
259
|
+
const attendees = current.data.attendees ?? [];
|
|
260
|
+
const accountEmail = server.accountSet().configs[account]?.email;
|
|
261
|
+
const self = attendees.find((a) => a.self)
|
|
262
|
+
?? attendees.find((a) => a.email?.toLowerCase() === accountEmail?.toLowerCase());
|
|
263
|
+
if (!self?.email) {
|
|
264
|
+
return invalidParams(account, `Event "${eventId}" has no attendee entry for this account, so there is nothing to RSVP to.`, 'RSVP only applies to events where this account is invited as a guest; check calendar_get_event to see the attendee list.');
|
|
265
|
+
}
|
|
266
|
+
const res = await cal.events.patch({
|
|
267
|
+
calendarId: cid,
|
|
268
|
+
eventId,
|
|
269
|
+
requestBody: {
|
|
270
|
+
attendeesOmitted: true,
|
|
271
|
+
attendees: [{ email: self.email, responseStatus: response, ...(comment !== undefined ? { comment } : {}) }],
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: 'text', text: JSON.stringify(formatEvent(res.data), null, 2) }],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
return handleCalendarError(error, account);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
239
282
|
server.registerTool('calendar_delete_event', {
|
|
240
283
|
description: 'Delete a Google Calendar event',
|
|
241
284
|
inputSchema: {
|
|
@@ -328,6 +371,9 @@ export function registerCalendarTools(server, deps = {}) {
|
|
|
328
371
|
.describe('Continuation token from a previous call\'s nextPageToken'),
|
|
329
372
|
},
|
|
330
373
|
}, async ({ account, calendarId, eventId, timeMin, timeMax, maxResults, pageToken }) => {
|
|
374
|
+
const tzError = missingTimeZoneError('timeMin', timeMin) ?? missingTimeZoneError('timeMax', timeMax);
|
|
375
|
+
if (tzError)
|
|
376
|
+
return invalidParams(account, tzError, MISSING_TIME_ZONE_HINT);
|
|
331
377
|
const rangeError = timeRangeError(timeMin, timeMax);
|
|
332
378
|
if (rangeError)
|
|
333
379
|
return invalidParams(account, rangeError, TIME_RANGE_HINT);
|
|
@@ -367,6 +413,9 @@ export function registerCalendarTools(server, deps = {}) {
|
|
|
367
413
|
if (calendarIds.length === 0) {
|
|
368
414
|
return invalidParams(account, '`calendarIds` is empty, so there is nothing to check.', 'Pass at least one calendar ID, e.g. ["primary"]. Use calendar_list_calendars to see the IDs this account can read.');
|
|
369
415
|
}
|
|
416
|
+
const tzError = missingTimeZoneError('timeMin', timeMin) ?? missingTimeZoneError('timeMax', timeMax);
|
|
417
|
+
if (tzError)
|
|
418
|
+
return invalidParams(account, tzError, MISSING_TIME_ZONE_HINT);
|
|
370
419
|
const rangeError = timeRangeError(timeMin, timeMax);
|
|
371
420
|
if (rangeError)
|
|
372
421
|
return invalidParams(account, rangeError, TIME_RANGE_HINT);
|
|
@@ -440,6 +489,16 @@ export function timeRangeError(timeMin, timeMax) {
|
|
|
440
489
|
}
|
|
441
490
|
export const TIME_RANGE_HINT = 'Swap the two values or widen the window: timeMin must be strictly earlier than timeMax. ' +
|
|
442
491
|
'An empty result from this tool then always means "nothing scheduled", never "bad range".';
|
|
492
|
+
const TZ_SUFFIX_RE = /(Z|[+-]\d{2}:?\d{2})$/;
|
|
493
|
+
/** The Calendar API requires timeMin/timeMax as RFC3339 with a mandatory
|
|
494
|
+
* offset; a bare local datetime is silently ambiguous rather than rejected,
|
|
495
|
+
* so catch it here with a message that names the field. */
|
|
496
|
+
export function missingTimeZoneError(field, value) {
|
|
497
|
+
if (value === undefined || TZ_SUFFIX_RE.test(value))
|
|
498
|
+
return null;
|
|
499
|
+
return `${field} ("${value}") has no "Z" or UTC offset, so its timezone is ambiguous.`;
|
|
500
|
+
}
|
|
501
|
+
export const MISSING_TIME_ZONE_HINT = 'Add "Z" for UTC or an explicit offset, e.g. "2026-04-04T00:00:00Z" or "2026-04-04T00:00:00+01:00".';
|
|
443
502
|
export function formatEvent(event, opts = {}) {
|
|
444
503
|
const full = opts.full ?? true;
|
|
445
504
|
const description = event.description ?? '';
|
package/dist/tools/contacts.js
CHANGED
|
@@ -234,8 +234,8 @@ export function registerContactsTools(server, deps = {}) {
|
|
|
234
234
|
description: 'List all contacts (paginated)',
|
|
235
235
|
inputSchema: {
|
|
236
236
|
account: accountEnum.describe('Google account alias'),
|
|
237
|
-
pageSize: z.number().min(1).max(1000).default(
|
|
238
|
-
.describe('Number of contacts per page (default:
|
|
237
|
+
pageSize: z.number().min(1).max(1000).default(50).optional()
|
|
238
|
+
.describe('Number of contacts per page (default: 50)'),
|
|
239
239
|
pageToken: z.string().optional().describe('Page token for pagination'),
|
|
240
240
|
sortOrder: z.enum([
|
|
241
241
|
'LAST_MODIFIED_ASCENDING',
|
|
@@ -244,19 +244,25 @@ export function registerContactsTools(server, deps = {}) {
|
|
|
244
244
|
'LAST_NAME_ASCENDING',
|
|
245
245
|
]).default('FIRST_NAME_ASCENDING').optional()
|
|
246
246
|
.describe('Sort order (default: FIRST_NAME_ASCENDING)'),
|
|
247
|
+
requireEmail: coerceBoolean.optional().describe('Only return contacts with at least one email address. Filtering happens after the page is fetched, so the returned page can hold fewer than pageSize contacts; nextPageToken still continues the underlying, unfiltered listing'),
|
|
248
|
+
requireName: coerceBoolean.optional().describe('Only return contacts with a name. Filtering happens after the page is fetched, so the returned page can hold fewer than pageSize contacts; nextPageToken still continues the underlying, unfiltered listing'),
|
|
247
249
|
},
|
|
248
|
-
}, async ({ account, pageSize, pageToken, sortOrder }) => {
|
|
250
|
+
}, async ({ account, pageSize, pageToken, sortOrder, requireEmail, requireName }) => {
|
|
249
251
|
try {
|
|
250
252
|
const auth = await getClientFn(account);
|
|
251
253
|
const people = peopleClient({ version: 'v1', auth });
|
|
252
254
|
const res = await people.people.connections.list({
|
|
253
255
|
resourceName: 'people/me',
|
|
254
256
|
personFields: PERSON_FIELDS,
|
|
255
|
-
pageSize: pageSize ??
|
|
257
|
+
pageSize: pageSize ?? 50,
|
|
256
258
|
pageToken: pageToken ?? undefined,
|
|
257
259
|
sortOrder: sortOrder ?? 'FIRST_NAME_ASCENDING',
|
|
258
260
|
});
|
|
259
|
-
|
|
261
|
+
let contacts = (res.data.connections ?? []).map(formatContact);
|
|
262
|
+
if (requireEmail)
|
|
263
|
+
contacts = contacts.filter((c) => c.emails.length > 0);
|
|
264
|
+
if (requireName)
|
|
265
|
+
contacts = contacts.filter((c) => c.name !== '');
|
|
260
266
|
return {
|
|
261
267
|
content: [{ type: 'text', text: JSON.stringify({
|
|
262
268
|
contacts,
|
package/dist/tools/docs.js
CHANGED
|
@@ -380,35 +380,64 @@ export function registerDocsTools(server, deps = {}) {
|
|
|
380
380
|
server.registerTool('docs_replace_text', {
|
|
381
381
|
// Insert/append-capable or non-convergent: a retry duplicates content.
|
|
382
382
|
annotations: { idempotentHint: false },
|
|
383
|
-
description: 'Find and replace
|
|
383
|
+
description: 'Find and replace text in a document. Pass findText/replaceText for a single pair, or replacements for several pairs applied in one batchUpdate.',
|
|
384
384
|
inputSchema: {
|
|
385
385
|
account: accountEnum.describe('Google account alias'),
|
|
386
386
|
documentId: z.string().min(1).describe('Google Docs document ID'),
|
|
387
|
-
findText: z.string().describe('Text to search for'),
|
|
388
|
-
replaceText: z.string().describe('Replacement text'),
|
|
387
|
+
findText: z.string().optional().describe('Text to search for. Use with replaceText; exactly one of this pair or `replacements` is required.'),
|
|
388
|
+
replaceText: z.string().optional().describe('Replacement text for findText'),
|
|
389
389
|
matchCase: coerceBoolean.default(true).optional()
|
|
390
390
|
.describe('Case-sensitive match (default: true)'),
|
|
391
|
+
replacements: coerceJson(z.array(z.object({
|
|
392
|
+
findText: z.string(),
|
|
393
|
+
replaceText: z.string(),
|
|
394
|
+
matchCase: coerceBoolean.optional(),
|
|
395
|
+
})).min(1)).optional().describe('Several find/replace pairs applied in one batchUpdate, each returning its own count. Exactly one of this or findText/replaceText is required.'),
|
|
391
396
|
},
|
|
392
|
-
}, async ({ account, documentId, findText, replaceText, matchCase }) => {
|
|
397
|
+
}, async ({ account, documentId, findText, replaceText, matchCase, replacements }) => {
|
|
398
|
+
const singlePair = findText !== undefined || replaceText !== undefined;
|
|
399
|
+
if (singlePair && replacements) {
|
|
400
|
+
return invalidParams(account, 'Pass either findText/replaceText or replacements, not both.', 'Use findText and replaceText for one pair, or replacements for several pairs in one call.');
|
|
401
|
+
}
|
|
402
|
+
if (!singlePair && !replacements) {
|
|
403
|
+
return invalidParams(account, 'Pass findText and replaceText, or a replacements array.', 'Use findText and replaceText for one pair, or replacements for several pairs in one call.');
|
|
404
|
+
}
|
|
405
|
+
if (singlePair && (findText === undefined || replaceText === undefined)) {
|
|
406
|
+
return invalidParams(account, 'Both findText and replaceText are required together.', 'Pass both, or use replacements for several pairs.');
|
|
407
|
+
}
|
|
408
|
+
const pairs = replacements ?? [{ findText: findText, replaceText: replaceText, matchCase }];
|
|
393
409
|
try {
|
|
394
410
|
const auth = await getClientFn(account);
|
|
395
411
|
const docs = docsClient({ version: 'v1', auth });
|
|
396
412
|
const res = await docs.documents.batchUpdate({
|
|
397
413
|
documentId,
|
|
398
414
|
requestBody: {
|
|
399
|
-
requests:
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
415
|
+
requests: pairs.map((p) => ({
|
|
416
|
+
replaceAllText: {
|
|
417
|
+
containsText: { text: p.findText, matchCase: p.matchCase ?? true },
|
|
418
|
+
replaceText: p.replaceText,
|
|
419
|
+
},
|
|
420
|
+
})),
|
|
405
421
|
},
|
|
406
422
|
});
|
|
407
|
-
const
|
|
423
|
+
const counts = (res.data.replies ?? []).map((r) => r?.replaceAllText?.occurrencesChanged ?? 0);
|
|
424
|
+
const warning = counts.every((c) => c === 0)
|
|
425
|
+
? 'No occurrences were replaced; check spelling and matchCase.'
|
|
426
|
+
: undefined;
|
|
427
|
+
if (replacements) {
|
|
428
|
+
return {
|
|
429
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
430
|
+
documentId: res.data.documentId,
|
|
431
|
+
replacements: pairs.map((p, i) => ({ findText: p.findText, replaceText: p.replaceText, occurrencesReplaced: counts[i] })),
|
|
432
|
+
...(warning ? { warning } : {}),
|
|
433
|
+
}, null, 2) }],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
408
436
|
return {
|
|
409
437
|
content: [{ type: 'text', text: JSON.stringify({
|
|
410
438
|
documentId: res.data.documentId,
|
|
411
|
-
occurrencesReplaced:
|
|
439
|
+
occurrencesReplaced: counts[0],
|
|
440
|
+
...(warning ? { warning } : {}),
|
|
412
441
|
}, null, 2) }],
|
|
413
442
|
};
|
|
414
443
|
}
|
package/dist/tools/drive.js
CHANGED
|
@@ -13,11 +13,13 @@ import * as path from 'path';
|
|
|
13
13
|
import * as os from 'os';
|
|
14
14
|
import * as crypto from 'crypto';
|
|
15
15
|
import { pipeline } from 'node:stream/promises';
|
|
16
|
+
import { Readable } from 'node:stream';
|
|
16
17
|
import mime from 'mime-types';
|
|
17
18
|
// drive_transfer's two-account form is the sanctioned schema exception and
|
|
18
19
|
// stays REQUIRED: omission must fail at the schema, not as a runtime riddle.
|
|
19
20
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
|
20
21
|
const FALLBACK_MAX_BYTES = 1024 * 1024 * 1024; // 1GB
|
|
22
|
+
const MAX_UPLOAD_BASE64_BYTES = 10 * 1024 * 1024; // 10MB decoded
|
|
21
23
|
const GOOGLE_WORKSPACE_TYPES = new Set([
|
|
22
24
|
'application/vnd.google-apps.document',
|
|
23
25
|
'application/vnd.google-apps.spreadsheet',
|
|
@@ -135,8 +137,10 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
135
137
|
driveId: z.string().optional().describe('Optional shared drive ID'),
|
|
136
138
|
pageToken: z.string().min(1).optional()
|
|
137
139
|
.describe('Continuation token from a previous call\'s nextPageToken'),
|
|
140
|
+
orderBy: z.string().optional()
|
|
141
|
+
.describe('Sort key(s) for files.list, e.g. "modifiedTime desc" or "folder,name". Default: Drive\'s relevance ranking for the query, an arbitrary stable order otherwise.'),
|
|
138
142
|
},
|
|
139
|
-
}, async ({ account, query, maxResults, driveId, pageToken }) => {
|
|
143
|
+
}, async ({ account, query, maxResults, driveId, pageToken, orderBy }) => {
|
|
140
144
|
try {
|
|
141
145
|
// normalizeDriveQuery trims to '' and Drive reads that as "everything", so
|
|
142
146
|
// a blank query would list the whole drive as a "search" result.
|
|
@@ -160,6 +164,8 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
160
164
|
}
|
|
161
165
|
if (pageToken)
|
|
162
166
|
params.pageToken = pageToken;
|
|
167
|
+
if (orderBy)
|
|
168
|
+
params.orderBy = orderBy;
|
|
163
169
|
const res = await drive.files.list(params);
|
|
164
170
|
const incomplete = res.data.incompleteSearch === true;
|
|
165
171
|
return {
|
|
@@ -337,22 +343,55 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
337
343
|
}
|
|
338
344
|
});
|
|
339
345
|
// ─── Write / upload / download ─────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
description: 'Upload a
|
|
346
|
+
server.registerTool('drive_upload', {
|
|
347
|
+
description: 'Upload a file to Google Drive, from inline base64 content or (when this server exposes the host filesystem) a local path. Pass `convertTo` to import it as a native, editable Google Doc/Sheet/Slides/Drawing instead of storing the raw bytes.',
|
|
342
348
|
inputSchema: {
|
|
343
349
|
account: accountEnum.describe('Google account alias'),
|
|
344
|
-
localPath: z.string().min(1).describe('Absolute path of the SOURCE file on disk to upload (on the machine running the server; this is not savePath)'),
|
|
345
350
|
filename: z.string().describe('Name as it appears in Drive'),
|
|
346
|
-
|
|
351
|
+
contentBase64: z.string().optional().describe(`Base64-encoded file content. Exactly one of contentBase64${localFiles ? ' or localPath' : ''} is required. Decoded size is capped at ${MAX_UPLOAD_BASE64_BYTES / (1024 * 1024)} MB.`),
|
|
352
|
+
...(localFiles
|
|
353
|
+
? {
|
|
354
|
+
localPath: z.string().min(1).optional().describe('Absolute path of the SOURCE file on disk to upload (on the machine running the server; this is not savePath). Exactly one of contentBase64 or localPath is required.'),
|
|
355
|
+
}
|
|
356
|
+
: {}),
|
|
357
|
+
mimeType: z.string().optional().describe('Source MIME type of the content (inferred from filename if omitted). With `convertTo`, this is the format Drive imports from.'),
|
|
347
358
|
convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('Convert the upload into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted). E.g. upload .md/.html/.docx/.txt with convertTo=document to get a real Google Doc. Source must be an importable format. Omit to store the file as-is.'),
|
|
348
359
|
parentFolderId: z.string().optional().describe('Parent folder ID, not parentId. Defaults to My Drive root'),
|
|
349
360
|
},
|
|
350
|
-
}, async (
|
|
361
|
+
}, async (args) => {
|
|
362
|
+
const { account, filename, contentBase64, mimeType: mimeTypeArg, convertTo, parentFolderId } = args;
|
|
363
|
+
// Declared only when the context may read host files (see the shape).
|
|
364
|
+
const { localPath: localPathArg } = args;
|
|
365
|
+
if (localPathArg && !localFiles)
|
|
366
|
+
return hostFilesRefused(account, 'localPath');
|
|
367
|
+
const hasBase64 = contentBase64 !== undefined;
|
|
368
|
+
const hasLocalPath = !!localPathArg;
|
|
369
|
+
if (hasBase64 === hasLocalPath) {
|
|
370
|
+
return invalidParams(account, `Pass exactly one of contentBase64${localFiles ? ' or localPath' : ''}.`, localFiles
|
|
371
|
+
? 'Use contentBase64 for inline content, or localPath for a file already on the machine running the server.'
|
|
372
|
+
: 'Pass contentBase64 with the file\'s base64-encoded content.');
|
|
373
|
+
}
|
|
351
374
|
try {
|
|
375
|
+
let media;
|
|
376
|
+
if (hasBase64) {
|
|
377
|
+
const maxB64Length = Math.ceil(MAX_UPLOAD_BASE64_BYTES / 3) * 4 + 4;
|
|
378
|
+
if (contentBase64.length > maxB64Length) {
|
|
379
|
+
return invalidParams(account, `Base64-encoded contentBase64 string length ${contentBase64.length} exceeds the ${maxB64Length} character limit (implies decoded size over ${MAX_UPLOAD_BASE64_BYTES} bytes).`, 'Upload a smaller file, or use localPath if this server exposes the host filesystem.');
|
|
380
|
+
}
|
|
381
|
+
const buf = Buffer.from(contentBase64, 'base64');
|
|
382
|
+
if (buf.length > MAX_UPLOAD_BASE64_BYTES) {
|
|
383
|
+
return invalidParams(account, `Decoded contentBase64 is ${buf.length} bytes, over the ${MAX_UPLOAD_BASE64_BYTES} byte cap.`, 'Upload a smaller file, or use localPath if this server exposes the host filesystem.');
|
|
384
|
+
}
|
|
385
|
+
media = { mimeType: mimeTypeArg ?? (mime.lookup(filename) || 'application/octet-stream'), body: Readable.from(buf) };
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
media = {
|
|
389
|
+
mimeType: mimeTypeArg ?? (mime.lookup(localPathArg) || 'application/octet-stream'),
|
|
390
|
+
body: await openLocalReadStream(localPathArg),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
352
393
|
const auth = await getClientFn(account);
|
|
353
394
|
const drive = driveClient({ version: 'v3', auth });
|
|
354
|
-
const resolvedMime = mimeTypeArg ?? (mime.lookup(localPath) || 'application/octet-stream');
|
|
355
|
-
const fileStream = await openLocalReadStream(localPath);
|
|
356
395
|
const res = await drive.files.create({
|
|
357
396
|
requestBody: {
|
|
358
397
|
name: filename,
|
|
@@ -360,10 +399,7 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
360
399
|
// Setting a google-apps target type makes Drive convert the media on import.
|
|
361
400
|
...(convertTo ? { mimeType: resolveConvertTarget(convertTo) } : {}),
|
|
362
401
|
},
|
|
363
|
-
media
|
|
364
|
-
mimeType: resolvedMime,
|
|
365
|
-
body: fileStream,
|
|
366
|
-
},
|
|
402
|
+
media,
|
|
367
403
|
fields: 'id,name,mimeType,webViewLink,size',
|
|
368
404
|
supportsAllDrives: true,
|
|
369
405
|
});
|
|
@@ -676,6 +712,12 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
676
712
|
expirationTime: z.string().optional().describe('RFC 3339 timestamp when access expires. Only valid for role="reader" or "commenter".'),
|
|
677
713
|
},
|
|
678
714
|
}, async ({ account, fileId, type, role, emailAddress, domain, sendNotification, emailMessage, transferOwnership, expirationTime }) => {
|
|
715
|
+
if ((type === 'user' || type === 'group') && !emailAddress) {
|
|
716
|
+
return invalidParams(account, `type is "${type}" but emailAddress was not supplied.`, 'Pass emailAddress, the grantee to share with.');
|
|
717
|
+
}
|
|
718
|
+
if (type === 'domain' && !domain) {
|
|
719
|
+
return invalidParams(account, 'type is "domain" but domain was not supplied.', 'Pass domain, e.g. "example.com".');
|
|
720
|
+
}
|
|
679
721
|
{
|
|
680
722
|
// Outbound allowlist (off unless GOOGLE_OUTBOUND_ALLOWLIST is set): user/group
|
|
681
723
|
// grantees must match, a domain share needs its exact "@domain" entry, "anyone" is refused while active.
|