mcp-google-multi 6.0.0-alpha.73 → 6.0.0-alpha.75
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 +1 -1
- package/dist/discover.js +2 -3
- package/dist/hint-provider.d.ts +4 -1
- package/dist/oauth-as.js +2 -2
- 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/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/write-control.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
7
|
-
- 🧰 **Exhaustive** —
|
|
7
|
+
- 🧰 **Exhaustive** — 941 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
|
|
8
8
|
- 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
|
|
9
9
|
- 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
|
|
10
10
|
- 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI). Pull-and-up Docker Compose with optional automatic HTTPS → [remote setup](./docs/http-setup.md)
|
package/dist/discover.js
CHANGED
|
@@ -96,9 +96,8 @@ export function registerDiscoverTools(registry, policy) {
|
|
|
96
96
|
service,
|
|
97
97
|
operations,
|
|
98
98
|
writeControl: rendered?.hint ?? describePolicy(policy),
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
: `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
|
|
99
|
+
// catalog() falls back to a full one-line listing when nothing matches, so this is never empty.
|
|
100
|
+
next: 'Call the chosen tool by its exact name. A client that loads tools on demand may need its own tool-search or load step before the call succeeds.',
|
|
102
101
|
}),
|
|
103
102
|
},
|
|
104
103
|
],
|
package/dist/hint-provider.d.ts
CHANGED
package/dist/oauth-as.js
CHANGED
|
@@ -425,7 +425,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
425
425
|
res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
|
|
426
426
|
`<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
|
|
427
427
|
`<h2>Authorize access?</h2>` +
|
|
428
|
-
`<p>
|
|
428
|
+
`<p>An application (<code>${escapeHtml(clientId)}</code>) asks to connect to this server as you.</p>` +
|
|
429
429
|
`<p>If approved, it will receive an authorization redirected to:<br><code>${escapeHtml(redirectUri)}</code></p>` +
|
|
430
430
|
`<p><strong>If you did not start this, close this window.</strong></p>` +
|
|
431
431
|
`<form method="POST" action="/authorize"><input type="hidden" name="pending" value="${escapeHtml(pendingToken)}">` +
|
|
@@ -498,7 +498,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
498
498
|
}
|
|
499
499
|
log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
|
|
500
500
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
501
|
-
res.end(`<!doctype html><meta charset=utf-8><p>
|
|
501
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Linked "${escapeHtml(st.alias)}" to this server. Start a new chat to use it.</p>`);
|
|
502
502
|
return true;
|
|
503
503
|
}
|
|
504
504
|
if (st.flow === 'alias_reauth') {
|
package/dist/registry.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface CatalogOperation {
|
|
|
38
38
|
* real scope error rather than a bare "not found". */
|
|
39
39
|
unreachable?: true;
|
|
40
40
|
}
|
|
41
|
+
/** The leading sentence of a (possibly multi-sentence) tool description. */
|
|
42
|
+
export declare function firstSentence(text: string): string;
|
|
41
43
|
/** The service a tool name belongs to: its prefix, unless overridden. */
|
|
42
44
|
export declare function serviceOf(name: string): string;
|
|
43
45
|
export declare function inferCud(name: string): Cud;
|
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,
|
|
@@ -18,20 +18,6 @@ export function registerGmailGeneratedTools(registry, deps = {}) {
|
|
|
18
18
|
["https://mail.google.com/", "https://www.googleapis.com/auth/gmail.metadata", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.readonly"],
|
|
19
19
|
["https://mail.google.com/"],
|
|
20
20
|
];
|
|
21
|
-
registerGeneratedTool(registry, {
|
|
22
|
-
name: "gmail_users_drafts_delete",
|
|
23
|
-
cud: "delete",
|
|
24
|
-
description: "Immediately and permanently deletes the specified draft. Does not simply trash it. For more information, see [Create and send draft emails](https://developers.g",
|
|
25
|
-
method: { id: "gmail.users.drafts.delete", httpMethod: "DELETE", path: "gmail/v1/users/{userId}/drafts/{id}", baseUrl: "https://gmail.googleapis.com/", requiredParams: ["userId", "id"], scopes: S_gmail_v1[0] },
|
|
26
|
-
params: [{ "field": "id", "api": "id", "location": "path" }, { "field": "userId", "api": "userId", "location": "path" }, { "field": "fields", "api": "fields", "location": "query" }],
|
|
27
|
-
hasBody: false,
|
|
28
|
-
shape: {
|
|
29
|
-
account: accountField(() => registry.accountAliases()),
|
|
30
|
-
id: z.string().min(1).describe("The ID of the draft to delete."),
|
|
31
|
-
userId: z.string().min(1).describe("The user's email address. The special value `me` can be used to indicate the authenticated user."),
|
|
32
|
-
fields: z.string().optional().describe('Response field mask.'),
|
|
33
|
-
},
|
|
34
|
-
}, deps);
|
|
35
21
|
registerGeneratedTool(registry, {
|
|
36
22
|
name: "gmail_users_drafts_update",
|
|
37
23
|
cud: "update",
|
|
@@ -550,7 +550,6 @@ export const GENERATED_METHOD_TOOLS = {
|
|
|
550
550
|
"forms.forms.watches.create": "forms_forms_watches_create",
|
|
551
551
|
"forms.forms.watches.delete": "forms_forms_watches_delete",
|
|
552
552
|
"forms.forms.watches.renew": "forms_forms_watches_renew",
|
|
553
|
-
"gmail.users.drafts.delete": "gmail_users_drafts_delete",
|
|
554
553
|
"gmail.users.drafts.update": "gmail_users_drafts_update",
|
|
555
554
|
"gmail.users.labels.get": "gmail_users_labels_get",
|
|
556
555
|
"gmail.users.labels.patch": "gmail_users_labels_patch",
|
|
@@ -818,6 +817,7 @@ export const CURATED_METHOD_IDS = [
|
|
|
818
817
|
"forms.forms.setPublishSettings",
|
|
819
818
|
"forms.forms.watches.list",
|
|
820
819
|
"gmail.users.drafts.create",
|
|
820
|
+
"gmail.users.drafts.delete",
|
|
821
821
|
"gmail.users.drafts.get",
|
|
822
822
|
"gmail.users.drafts.list",
|
|
823
823
|
"gmail.users.drafts.send",
|
package/dist/tools/gmail.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare function parseMessage(msg: any, bodyCap?: number, opts?: {
|
|
|
33
33
|
* is rethrown so the caller maps it to a whole-call isError. Returns the
|
|
34
34
|
* ordered per-id entries followed by a trailing counts summary.
|
|
35
35
|
*/
|
|
36
|
-
export declare function readBatch(gmail: any, account: Account, ids: string[], full: boolean, rawHtml: boolean): Promise<any[]>;
|
|
36
|
+
export declare function readBatch(gmail: any, account: Account, ids: string[], full: boolean, rawHtml: boolean, metaHeaders?: string[]): Promise<any[]>;
|
|
37
37
|
/** Compact gmail_search row: just the pick-one-message selection signal, with
|
|
38
38
|
* the snippet flattened to one bounded line. Pure. */
|
|
39
39
|
export declare function compactMessageRow(m: GmailMessageHeader): {
|
package/dist/tools/gmail.js
CHANGED
|
@@ -17,6 +17,12 @@ import * as fs from 'fs';
|
|
|
17
17
|
function getHeader(headers, name) {
|
|
18
18
|
return headers?.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
|
|
19
19
|
}
|
|
20
|
+
function pickHeaders(headers, names) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const name of names)
|
|
23
|
+
out[name] = getHeader(headers, name);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
20
26
|
function collectTextParts(part, plain, html, topLevel = false) {
|
|
21
27
|
if (!part)
|
|
22
28
|
return;
|
|
@@ -176,6 +182,21 @@ export async function resolveReply(gmail, account, primaryEmail, replyToMessageI
|
|
|
176
182
|
subject: deriveReplySubject(getHeader(headers, 'Subject')),
|
|
177
183
|
};
|
|
178
184
|
}
|
|
185
|
+
// Gmail's after:/before: operators take epoch seconds; Date.parse accepts both
|
|
186
|
+
// an ISO date and a full date-time, so one parse covers both input shapes.
|
|
187
|
+
function parseEpochSeconds(value) {
|
|
188
|
+
// A date-only value is interpreted as 00:00 UTC.
|
|
189
|
+
// A date-time must include Z or an offset (+HH:MM or -HH:MM) to be unambiguous.
|
|
190
|
+
if (value.includes('T')) {
|
|
191
|
+
const hasZ = value.endsWith('Z');
|
|
192
|
+
const hasOffset = /[+-]\d{2}:\d{2}$/.test(value);
|
|
193
|
+
if (!hasZ && !hasOffset) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const ms = Date.parse(value);
|
|
198
|
+
return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
|
|
199
|
+
}
|
|
179
200
|
const BODY_CAP_CHARS = 50_000;
|
|
180
201
|
// gmail_read_batch aggregate ceiling: bounds the summed serialized output so a
|
|
181
202
|
// 100-message batch can't blow the context window (per-message bodies are capped at BODY_CAP_CHARS first).
|
|
@@ -331,13 +352,14 @@ export function parseMessage(msg, bodyCap, opts) {
|
|
|
331
352
|
* is rethrown so the caller maps it to a whole-call isError. Returns the
|
|
332
353
|
* ordered per-id entries followed by a trailing counts summary.
|
|
333
354
|
*/
|
|
334
|
-
export async function readBatch(gmail, account, ids, full, rawHtml) {
|
|
355
|
+
export async function readBatch(gmail, account, ids, full, rawHtml, metaHeaders) {
|
|
335
356
|
if (ids.length === 0) {
|
|
336
357
|
throw new GmailComposeError('validation_error', '`ids` must be a non-empty array of 1..100 message IDs.');
|
|
337
358
|
}
|
|
338
359
|
if (ids.length > 100) {
|
|
339
360
|
throw new GmailComposeError('validation_error', `\`ids\` accepts at most 100 message IDs per call (got ${ids.length}).`);
|
|
340
361
|
}
|
|
362
|
+
const metadataMode = metaHeaders !== undefined && metaHeaders.length > 0;
|
|
341
363
|
const cap = full ? undefined : BODY_CAP_CHARS;
|
|
342
364
|
const CHUNK_SIZE = 10;
|
|
343
365
|
const entries = new Array(ids.length);
|
|
@@ -347,7 +369,9 @@ export async function readBatch(gmail, account, ids, full, rawHtml) {
|
|
|
347
369
|
const slice = ids.slice(i, i + CHUNK_SIZE);
|
|
348
370
|
const settled = await Promise.all(slice.map(async (id, j) => {
|
|
349
371
|
try {
|
|
350
|
-
const res =
|
|
372
|
+
const res = metadataMode
|
|
373
|
+
? await gmail.users.messages.get({ userId: 'me', id, format: 'metadata', metadataHeaders: metaHeaders })
|
|
374
|
+
: await gmail.users.messages.get({ userId: 'me', id, format: 'full' });
|
|
351
375
|
return { idx: i + j, id, ok: true, data: res.data };
|
|
352
376
|
}
|
|
353
377
|
catch (err) {
|
|
@@ -356,7 +380,14 @@ export async function readBatch(gmail, account, ids, full, rawHtml) {
|
|
|
356
380
|
}));
|
|
357
381
|
for (const s of settled) {
|
|
358
382
|
if (s.ok) {
|
|
359
|
-
entries[s.idx] =
|
|
383
|
+
entries[s.idx] = metadataMode
|
|
384
|
+
? {
|
|
385
|
+
id: s.data.id ?? '',
|
|
386
|
+
threadId: s.data.threadId ?? '',
|
|
387
|
+
labelIds: s.data.labelIds ?? [],
|
|
388
|
+
headers: pickHeaders(s.data.payload?.headers, metaHeaders),
|
|
389
|
+
}
|
|
390
|
+
: parseMessage(s.data, cap, { rawHtml });
|
|
360
391
|
ok++;
|
|
361
392
|
}
|
|
362
393
|
else {
|
|
@@ -374,10 +405,11 @@ export async function readBatch(gmail, account, ids, full, rawHtml) {
|
|
|
374
405
|
}
|
|
375
406
|
// Aggregate guard: walk in order accumulating serialized size; once the running
|
|
376
407
|
// total would exceed the budget, cap this and every later success body so the batch never blows the context window.
|
|
408
|
+
// Metadata-mode entries carry no body, so there is nothing to cap.
|
|
377
409
|
let running = 0;
|
|
378
410
|
let truncatedAny = false;
|
|
379
411
|
let exhausted = false;
|
|
380
|
-
for (const e of entries) {
|
|
412
|
+
for (const e of metadataMode ? [] : entries) {
|
|
381
413
|
if (!e || e.error) {
|
|
382
414
|
running += JSON.stringify(e ?? {}).length;
|
|
383
415
|
continue;
|
|
@@ -434,27 +466,50 @@ export function registerGmailTools(server, deps = {}) {
|
|
|
434
466
|
const registerHostFileTool = localFiles ? server.registerTool : (() => undefined);
|
|
435
467
|
server.registerTool('gmail_search', {
|
|
436
468
|
description: 'Search messages in a Gmail account. Returns compact rows (id, from, subject, date, snippet); ' +
|
|
437
|
-
'pass full=true for threadId, to, labelIds and the untruncated snippet.'
|
|
469
|
+
'pass full=true for threadId, to, labelIds and the untruncated snippet. ' +
|
|
470
|
+
'For a recurring "what is new" sweep, prefer gmail_list_history over polling this with since.',
|
|
438
471
|
inputSchema: {
|
|
439
472
|
account: accountEnum.describe('Google account alias'),
|
|
440
473
|
query: z.string().describe('Gmail search syntax, e.g. "from:alice is:unread"'),
|
|
441
474
|
maxResults: coerceNumber(z.number().min(1).max(100)).optional()
|
|
442
475
|
.describe('Max results to return (default: 20, max: 100)'),
|
|
443
476
|
full: coerceBoolean.optional().describe('Return the full row shape instead of the compact default'),
|
|
477
|
+
since: z.string().optional()
|
|
478
|
+
.describe('Only messages on/after this ISO 8601 date (00:00 UTC) or date-time with Z or offset, added to the query as after:<epoch seconds>'),
|
|
479
|
+
until: z.string().optional()
|
|
480
|
+
.describe('Only messages before this ISO 8601 date (00:00 UTC) or date-time with Z or offset, added to the query as before:<epoch seconds>'),
|
|
481
|
+
includeSpamTrash: coerceBoolean.optional()
|
|
482
|
+
.describe('Include Spam and Trash in the search. Gmail excludes both by default, even for in:anywhere.'),
|
|
444
483
|
},
|
|
445
|
-
}, async ({ account, query, maxResults, full }) => {
|
|
484
|
+
}, async ({ account, query, maxResults, full, since, until, includeSpamTrash }) => {
|
|
446
485
|
try {
|
|
447
486
|
// Gmail treats a blank `q` as no filter, so a caller passing an unset
|
|
448
487
|
// variable got the whole mailbox back as if it had matched.
|
|
449
488
|
if (query.trim() === '') {
|
|
450
489
|
return invalidParams(account, '`query` is empty, and Gmail reads an empty query as "match everything".', 'Pass a real Gmail search term such as "is:unread" or "from:someone@example.com". Returning the whole mailbox for a blank query would look like a successful search.');
|
|
451
490
|
}
|
|
491
|
+
let effectiveQuery = query;
|
|
492
|
+
if (since !== undefined) {
|
|
493
|
+
const epoch = parseEpochSeconds(since);
|
|
494
|
+
if (epoch === null) {
|
|
495
|
+
return invalidParams(account, `\`since\` is not a parsable ISO 8601 date or date-time: "${since}".`, 'Pass an ISO 8601 date (e.g. "2026-01-01") or a date-time with Z or offset (e.g. "2026-01-01T09:00:00Z").');
|
|
496
|
+
}
|
|
497
|
+
effectiveQuery = `${effectiveQuery} after:${epoch}`;
|
|
498
|
+
}
|
|
499
|
+
if (until !== undefined) {
|
|
500
|
+
const epoch = parseEpochSeconds(until);
|
|
501
|
+
if (epoch === null) {
|
|
502
|
+
return invalidParams(account, `\`until\` is not a parsable ISO 8601 date or date-time: "${until}".`, 'Pass an ISO 8601 date (e.g. "2026-01-01") or a date-time with Z or offset (e.g. "2026-01-01T09:00:00Z").');
|
|
503
|
+
}
|
|
504
|
+
effectiveQuery = `${effectiveQuery} before:${epoch}`;
|
|
505
|
+
}
|
|
452
506
|
const auth = await getClientFn(account);
|
|
453
507
|
const gmail = gmailClient({ version: 'v1', auth });
|
|
454
508
|
const listRes = await gmail.users.messages.list({
|
|
455
509
|
userId: 'me',
|
|
456
|
-
q:
|
|
510
|
+
q: effectiveQuery,
|
|
457
511
|
maxResults: maxResults ?? 20,
|
|
512
|
+
...(includeSpamTrash !== undefined ? { includeSpamTrash } : {}),
|
|
458
513
|
});
|
|
459
514
|
const messages = listRes.data.messages ?? [];
|
|
460
515
|
const results = [];
|
|
@@ -495,18 +550,38 @@ export function registerGmailTools(server, deps = {}) {
|
|
|
495
550
|
});
|
|
496
551
|
server.registerTool('gmail_read', {
|
|
497
552
|
_meta: { 'anthropic/maxResultSizeChars': 50_000 },
|
|
498
|
-
description: 'Read a full Gmail message by ID (body capped at 50k chars unless full=true)'
|
|
553
|
+
description: 'Read a full Gmail message by ID (body capped at 50k chars unless full=true). ' +
|
|
554
|
+
'Pass `headers` for metadata mode: no body decoding, just id, threadId, labelIds and the named headers.',
|
|
499
555
|
inputSchema: {
|
|
500
556
|
account: accountEnum.describe('Google account alias'),
|
|
501
557
|
messageId: z.string().min(1).describe('Gmail message ID'),
|
|
502
558
|
full: coerceBoolean.optional().describe('Return the entire body without the character cap'),
|
|
503
559
|
rawHtml: coerceBoolean.optional()
|
|
504
560
|
.describe('Return the HTML body unconverted instead of the plain-text rendering (HTML-only messages)'),
|
|
561
|
+
headers: coerceArray(z.string()).optional()
|
|
562
|
+
.describe('Header names to fetch in metadata mode instead of the full body (e.g. ["Subject", "From"])'),
|
|
505
563
|
},
|
|
506
|
-
}, async ({ account, messageId, full, rawHtml }) => {
|
|
564
|
+
}, async ({ account, messageId, full, rawHtml, headers }) => {
|
|
507
565
|
try {
|
|
508
566
|
const auth = await getClientFn(account);
|
|
509
567
|
const gmail = gmailClient({ version: 'v1', auth });
|
|
568
|
+
if (headers !== undefined && headers.length > 0) {
|
|
569
|
+
const metaRes = await gmail.users.messages.get({
|
|
570
|
+
userId: 'me',
|
|
571
|
+
id: messageId,
|
|
572
|
+
format: 'metadata',
|
|
573
|
+
metadataHeaders: headers,
|
|
574
|
+
});
|
|
575
|
+
const metaResult = {
|
|
576
|
+
id: metaRes.data.id ?? '',
|
|
577
|
+
threadId: metaRes.data.threadId ?? '',
|
|
578
|
+
labelIds: metaRes.data.labelIds ?? [],
|
|
579
|
+
headers: pickHeaders(metaRes.data.payload?.headers, headers),
|
|
580
|
+
};
|
|
581
|
+
return {
|
|
582
|
+
content: [{ type: 'text', text: JSON.stringify(metaResult, null, 2) }],
|
|
583
|
+
};
|
|
584
|
+
}
|
|
510
585
|
const res = await gmail.users.messages.get({
|
|
511
586
|
userId: 'me',
|
|
512
587
|
id: messageId,
|
|
@@ -576,19 +651,21 @@ export function registerGmailTools(server, deps = {}) {
|
|
|
576
651
|
server.registerTool('gmail_read_batch', {
|
|
577
652
|
_meta: { 'anthropic/maxResultSizeChars': BATCH_MAX_RESULT_CHARS },
|
|
578
653
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
579
|
-
description: 'Read many Gmail messages by ID in one call, collapsing the search→read triage loop. Bodies are capped at 50k chars each (unless full=true) and the aggregate output is bounded. Returns one ordered entry per id plus a trailing counts summary; a single failed id does NOT fail the batch.',
|
|
654
|
+
description: 'Read many Gmail messages by ID in one call, collapsing the search→read triage loop. Bodies are capped at 50k chars each (unless full=true) and the aggregate output is bounded. Returns one ordered entry per id plus a trailing counts summary; a single failed id does NOT fail the batch. Pass `headers` for metadata mode: no body decoding, just id, threadId, labelIds and the named headers per entry.',
|
|
580
655
|
inputSchema: {
|
|
581
656
|
account: accountEnum.describe('Google account alias'),
|
|
582
657
|
ids: coerceArray(z.string()).describe('Gmail message IDs to read (1..100). Comma-separated string or JSON array.'),
|
|
583
658
|
full: coerceBoolean.optional().describe('Return entire bodies without the per-message 50k character cap'),
|
|
584
659
|
rawHtml: coerceBoolean.optional()
|
|
585
660
|
.describe('Return HTML bodies unconverted instead of the plain-text rendering (HTML-only messages)'),
|
|
661
|
+
headers: coerceArray(z.string()).optional()
|
|
662
|
+
.describe('Header names to fetch in metadata mode instead of full bodies (e.g. ["Subject", "From"])'),
|
|
586
663
|
},
|
|
587
|
-
}, async ({ account, ids, full, rawHtml }) => {
|
|
664
|
+
}, async ({ account, ids, full, rawHtml, headers }) => {
|
|
588
665
|
try {
|
|
589
666
|
const auth = await getClientFn(account);
|
|
590
667
|
const gmail = gmailClient({ version: 'v1', auth });
|
|
591
|
-
const result = await readBatch(gmail, account, ids ?? [], full === true, rawHtml === true);
|
|
668
|
+
const result = await readBatch(gmail, account, ids ?? [], full === true, rawHtml === true, headers);
|
|
592
669
|
return {
|
|
593
670
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
594
671
|
};
|
|
@@ -993,6 +1070,26 @@ export function registerGmailTools(server, deps = {}) {
|
|
|
993
1070
|
return mapped ?? handleGmailError(error, account);
|
|
994
1071
|
}
|
|
995
1072
|
});
|
|
1073
|
+
server.registerTool('gmail_delete_draft', {
|
|
1074
|
+
description: 'Permanently delete a Gmail draft by its draft ID',
|
|
1075
|
+
inputSchema: {
|
|
1076
|
+
account: accountEnum.describe('Google account alias'),
|
|
1077
|
+
draftId: z.string().min(1).describe('Draft ID to delete'),
|
|
1078
|
+
},
|
|
1079
|
+
}, async ({ account, draftId }) => {
|
|
1080
|
+
try {
|
|
1081
|
+
const auth = await getClientFn(account);
|
|
1082
|
+
const gmail = gmailClient({ version: 'v1', auth });
|
|
1083
|
+
await gmail.users.drafts.delete({ userId: 'me', id: draftId });
|
|
1084
|
+
return {
|
|
1085
|
+
content: [{ type: 'text', text: JSON.stringify({ deleted: true, draftId }, null, 2) }],
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
const mapped = composeErrorResult(error, account);
|
|
1090
|
+
return mapped ?? handleGmailError(error, account);
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
996
1093
|
server.registerTool('gmail_list_labels', {
|
|
997
1094
|
description: 'List all Gmail labels (system and user-defined). Use to get label IDs for gmail_modify_labels.',
|
|
998
1095
|
inputSchema: {
|
package/dist/write-control.js
CHANGED
|
@@ -13,12 +13,12 @@ export const IRREVERSIBLE_TOOLS = new Set([
|
|
|
13
13
|
'gmail_send',
|
|
14
14
|
'gmail_send_draft',
|
|
15
15
|
'gmail_delete',
|
|
16
|
+
'gmail_delete_draft',
|
|
16
17
|
'gmail_batch_delete',
|
|
17
18
|
'drive_delete',
|
|
18
19
|
'drive_empty_trash',
|
|
19
20
|
// generated twins (permanent deletes / real sends)
|
|
20
21
|
'gmail_users_threads_delete',
|
|
21
|
-
'gmail_users_drafts_delete',
|
|
22
22
|
'chat_spaces_messages_delete',
|
|
23
23
|
'cloudidentity_customers_userinvitations_send',
|
|
24
24
|
'vault_matters_holds_delete',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.75",
|
|
4
4
|
"mcpName": "io.github.bakissation/mcp-google-multi",
|
|
5
5
|
"description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
|
|
6
6
|
"type": "module",
|