gogcli-mcp 2.23.1 → 2.24.0
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +283 -69
- package/dist/lib.js +293 -71
- package/manifest.json +3 -3
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/gmail-results.ts +239 -0
- package/src/lib.ts +9 -0
- package/src/pagination.ts +108 -0
- package/src/runner.ts +1 -1
- package/src/tools/auth.ts +39 -12
- package/src/tools/calendar.ts +28 -7
- package/src/tools/classroom.ts +46 -28
- package/src/tools/drive.ts +6 -4
- package/src/tools/gmail.ts +37 -6
- package/src/tools/utils.ts +36 -5
- package/src/worker.ts +1 -1
- package/tests/gmail-results.test.ts +285 -0
- package/tests/page-cursor-contract.test.ts +50 -0
- package/tests/pagination.test.ts +102 -0
- package/tests/tools/auth.test.ts +60 -0
- package/tests/tools/calendar.test.ts +81 -3
- package/tests/tools/gmail.test.ts +163 -0
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp",
|
|
5
5
|
"display_name": "gogcli",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.24.0",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
},
|
|
103
103
|
{
|
|
104
104
|
"name": "gog_gmail_search",
|
|
105
|
-
"description": "Search Gmail threads
|
|
105
|
+
"description": "Search Gmail threads using Gmail query syntax; newest-first, and flags a truncated result set"
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
"name": "gog_gmail_get",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
"name": "gog_calendar_events",
|
|
121
|
-
"description": "List calendar events
|
|
121
|
+
"description": "List calendar events; paginated (gog returns only 10 by default) and flags a truncated range"
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
124
|
"name": "gog_calendar_get",
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.24.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.24.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { run } from './runner.js';
|
|
4
|
+
import { annotateTruncation, hasMorePages } from './pagination.js';
|
|
5
|
+
import type { MatchCount } from './pagination.js';
|
|
6
|
+
|
|
7
|
+
// Post-processing for Gmail search output, on the seam between gog's JSON and
|
|
8
|
+
// the model client. Two guarantees live here, both of which exist because a
|
|
9
|
+
// client read a search response and confidently reported that a message did not
|
|
10
|
+
// exist when it did:
|
|
11
|
+
//
|
|
12
|
+
// 1. A capped result set SAYS SO, in prose. gog reports a nextPageToken and
|
|
13
|
+
// nothing else; a token is easy to skim past, and skipping it turns a
|
|
14
|
+
// partial answer into a false negative.
|
|
15
|
+
// 2. Results are newest-first by internalDate. Gmail's own thread ordering
|
|
16
|
+
// keys on the thread, so a message that arrived today can rank below
|
|
17
|
+
// older ones and fall off the end of a capped page.
|
|
18
|
+
|
|
19
|
+
type GmailListItem = Record<string, unknown> & {
|
|
20
|
+
date?: string;
|
|
21
|
+
internalDateIso?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Sort key for newest-first. internalDateIso is Gmail's own internalDate and is
|
|
25
|
+
// what a parser should read; `date` parses the sender-written Date header and
|
|
26
|
+
// can be skewed, malformed, or in another zone, so it is only a fallback.
|
|
27
|
+
// Anything unparseable sorts LAST rather than being silently treated as the
|
|
28
|
+
// epoch — which would rank it as the oldest result, the exact failure this
|
|
29
|
+
// guarantee exists to remove.
|
|
30
|
+
function sortKey(item: GmailListItem): number {
|
|
31
|
+
for (const raw of [item.internalDateIso, item.date]) {
|
|
32
|
+
if (typeof raw !== 'string' || !raw) continue;
|
|
33
|
+
const t = Date.parse(raw);
|
|
34
|
+
if (!Number.isNaN(t)) return t;
|
|
35
|
+
}
|
|
36
|
+
return Number.NEGATIVE_INFINITY;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Array.prototype.sort is stable per spec, so equal keys — and the whole
|
|
40
|
+
// undated tail, which shares NEGATIVE_INFINITY — keep their original order.
|
|
41
|
+
function sortNewestFirst(items: GmailListItem[]): GmailListItem[] {
|
|
42
|
+
return [...items].sort((a, b) => {
|
|
43
|
+
const ka = sortKey(a);
|
|
44
|
+
const kb = sortKey(b);
|
|
45
|
+
// Compare before subtracting: (-Infinity) - (-Infinity) is NaN, which would
|
|
46
|
+
// make the comparator incoherent for every undated pair.
|
|
47
|
+
return ka === kb ? 0 : kb - ka;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type GmailListMethod = 'users.threads.list' | 'users.messages.list';
|
|
52
|
+
|
|
53
|
+
// The largest page Gmail's list endpoints will serve. One page at this size is
|
|
54
|
+
// enough to count the whole match set for all but the broadest queries.
|
|
55
|
+
const COUNT_PROBE_PAGE_SIZE = 500;
|
|
56
|
+
|
|
57
|
+
// Count how many matches the query REALLY has.
|
|
58
|
+
//
|
|
59
|
+
// Deliberately NOT Gmail's own resultSizeEstimate, which is the obvious source
|
|
60
|
+
// and is worthless: measured against a live mailbox on 2026-08-12 it returned
|
|
61
|
+
// exactly 201 for every non-empty query tried — `from:freshbooks.com` (21 real
|
|
62
|
+
// matches), `from:housecallpro.com` (6), `from:thumbtack.com newer_than:30d`
|
|
63
|
+
// (3) — and 0 for a query with no matches. It saturates, so it is a
|
|
64
|
+
// has-results boolean wearing a number's clothes. Reporting "3 of ~201" when
|
|
65
|
+
// the truth is 3 of 6 would invent a figure, which is a worse failure than the
|
|
66
|
+
// missing count this whole block exists to supply.
|
|
67
|
+
//
|
|
68
|
+
// So: ask for one maximal page of bare ids and count them. `fields` keeps the
|
|
69
|
+
// response to ids alone, and the count is EXACT whenever the result set fits in
|
|
70
|
+
// a page — which is the common case, and always the case for the narrow queries
|
|
71
|
+
// a caller is most likely to draw a false negative from. A full page with more
|
|
72
|
+
// behind it yields an honest lower bound instead.
|
|
73
|
+
//
|
|
74
|
+
// gog CAN now supply this itself — `--count` on `gmail search` / `gmail
|
|
75
|
+
// messages search` (gog >= 0.36.0, openclaw/gogcli#985) is this same probe,
|
|
76
|
+
// upstreamed, down to the page size and the exact/lower-bound split. The probe
|
|
77
|
+
// stays here anyway, for two differences that both matter at this seam:
|
|
78
|
+
//
|
|
79
|
+
// * It is spent ONLY on a result set already known to be truncated. --count
|
|
80
|
+
// is decided before the search runs, so adopting it would cost every
|
|
81
|
+
// search an extra Gmail request to answer a question most of them never
|
|
82
|
+
// raise.
|
|
83
|
+
// * It is best-effort. gog returns the count probe's error from the whole
|
|
84
|
+
// command, so a failed count would turn a search that DID succeed into an
|
|
85
|
+
// error — trading a missing warning for a missing answer.
|
|
86
|
+
//
|
|
87
|
+
// Keep the two in step: a change to what "exact" means on either side should
|
|
88
|
+
// be made on both.
|
|
89
|
+
//
|
|
90
|
+
// Best-effort by construction: any failure must degrade the warning, never the
|
|
91
|
+
// search.
|
|
92
|
+
async function countMatches(
|
|
93
|
+
method: GmailListMethod,
|
|
94
|
+
itemsKey: 'threads' | 'messages',
|
|
95
|
+
query: string,
|
|
96
|
+
account: string | undefined,
|
|
97
|
+
): Promise<MatchCount> {
|
|
98
|
+
try {
|
|
99
|
+
const params = JSON.stringify({
|
|
100
|
+
userId: 'me',
|
|
101
|
+
q: query,
|
|
102
|
+
maxResults: COUNT_PROBE_PAGE_SIZE,
|
|
103
|
+
fields: `${itemsKey}/id,nextPageToken`,
|
|
104
|
+
});
|
|
105
|
+
const raw = await run(['api', 'call', 'gmail', 'v1', method, `--params=${params}`], { account });
|
|
106
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
107
|
+
const items = parsed[itemsKey];
|
|
108
|
+
if (!Array.isArray(items)) return {};
|
|
109
|
+
const more = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== '';
|
|
110
|
+
return more ? { atLeast: items.length } : { total: items.length };
|
|
111
|
+
} catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface FinalizeOptions {
|
|
117
|
+
/** Which array in gog's payload holds the results. */
|
|
118
|
+
itemsKey: 'threads' | 'messages';
|
|
119
|
+
/** The Discovery method whose resultSizeEstimate matches that array. */
|
|
120
|
+
method: GmailListMethod;
|
|
121
|
+
/** The query as the caller wrote it. */
|
|
122
|
+
query: string;
|
|
123
|
+
account?: string;
|
|
124
|
+
/**
|
|
125
|
+
* False when gog rewrote the query server-side (--from-contact resolves a
|
|
126
|
+
* contact through the People API to addresses we cannot reproduce). An
|
|
127
|
+
* estimate for a DIFFERENT query is worse than none, so the field is skipped
|
|
128
|
+
* rather than guessed.
|
|
129
|
+
*/
|
|
130
|
+
queryIsExact?: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The single seam every Gmail search result passes through: guarantee the
|
|
134
|
+
// ordering, and make a capped result set say so out loud. Any output that is
|
|
135
|
+
// not the JSON shape this understands — an error, a plain "No results" — is
|
|
136
|
+
// returned untouched.
|
|
137
|
+
export async function finalizeGmailSearch(
|
|
138
|
+
result: CallToolResult,
|
|
139
|
+
options: FinalizeOptions,
|
|
140
|
+
): Promise<CallToolResult> {
|
|
141
|
+
const { itemsKey, method, query, account, queryIsExact = true } = options;
|
|
142
|
+
const first = result.content[0];
|
|
143
|
+
if (result.isError || first?.type !== 'text') return result;
|
|
144
|
+
|
|
145
|
+
let parsed: Record<string, unknown>;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(first.text) as Record<string, unknown>;
|
|
148
|
+
} catch {
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
const items = parsed[itemsKey];
|
|
152
|
+
if (!Array.isArray(items)) return result;
|
|
153
|
+
|
|
154
|
+
const sorted = sortNewestFirst(items as GmailListItem[]);
|
|
155
|
+
const out: Record<string, unknown> = { ...parsed, [itemsKey]: sorted };
|
|
156
|
+
|
|
157
|
+
// The presence of the block is itself the signal, so it is added ONLY for a
|
|
158
|
+
// genuinely capped set. gog leaves nextPageToken empty when --all exhausted
|
|
159
|
+
// the pages, which is why --all needs no special case here.
|
|
160
|
+
if (hasMorePages(parsed)) {
|
|
161
|
+
const count = queryIsExact ? await countMatches(method, itemsKey, query, account) : {};
|
|
162
|
+
annotateTruncation(out, sorted.length, count);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return rawTextResult(JSON.stringify(out));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Walk up to `maxPages` pages and merge them into one payload.
|
|
169
|
+
//
|
|
170
|
+
// The point is the existence question — "is there any mail matching X?" — which
|
|
171
|
+
// a single page cannot answer and which is exactly where a truncated response
|
|
172
|
+
// gets misread as a negative. gog has `--all`, but that is unbounded: on a
|
|
173
|
+
// broad query it walks the entire mailbox. This is the bounded form, so a
|
|
174
|
+
// caller can say "look at up to 5 pages" and get either a real answer or an
|
|
175
|
+
// honestly-still-truncated one.
|
|
176
|
+
//
|
|
177
|
+
// The merged payload keeps a nextPageToken only if pages remain when the cap is
|
|
178
|
+
// hit, so finalizeGmailSearch still marks it truncated — running out of budget
|
|
179
|
+
// is not the same as reaching the end, and must not read like it.
|
|
180
|
+
export async function fetchGmailPages(
|
|
181
|
+
runPage: (token: string | undefined) => Promise<CallToolResult>,
|
|
182
|
+
itemsKey: 'threads' | 'messages',
|
|
183
|
+
maxPages: number,
|
|
184
|
+
startToken: string | undefined,
|
|
185
|
+
): Promise<CallToolResult> {
|
|
186
|
+
const merged: unknown[] = [];
|
|
187
|
+
let base: Record<string, unknown> | undefined;
|
|
188
|
+
let token = startToken;
|
|
189
|
+
|
|
190
|
+
for (let pages = 0; pages < maxPages; pages++) {
|
|
191
|
+
const result = await runPage(token);
|
|
192
|
+
const parsed = parsePage(result, itemsKey);
|
|
193
|
+
// An error, or output this does not understand, part-way through the walk.
|
|
194
|
+
// Nothing collected yet means the caller should just see that result; once
|
|
195
|
+
// pages ARE collected, return them WITH the cursor that was about to be
|
|
196
|
+
// consumed, so the set still reads as truncated rather than complete.
|
|
197
|
+
if (parsed === undefined) {
|
|
198
|
+
return base === undefined ? result : finish(base, itemsKey, merged, token);
|
|
199
|
+
}
|
|
200
|
+
base = parsed;
|
|
201
|
+
merged.push(...(parsed[itemsKey] as unknown[]));
|
|
202
|
+
token = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== ''
|
|
203
|
+
? parsed.nextPageToken
|
|
204
|
+
: undefined;
|
|
205
|
+
if (token === undefined) break;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return finish(base as Record<string, unknown>, itemsKey, merged, token);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A page's payload, or undefined if it is not a usable list response.
|
|
212
|
+
function parsePage(
|
|
213
|
+
result: CallToolResult,
|
|
214
|
+
itemsKey: string,
|
|
215
|
+
): Record<string, unknown> | undefined {
|
|
216
|
+
const first = result.content[0];
|
|
217
|
+
if (result.isError || first?.type !== 'text') return undefined;
|
|
218
|
+
let parsed: unknown;
|
|
219
|
+
try {
|
|
220
|
+
parsed = JSON.parse(first.text);
|
|
221
|
+
} catch {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
if (parsed === null || typeof parsed !== 'object') return undefined;
|
|
225
|
+
const obj = parsed as Record<string, unknown>;
|
|
226
|
+
return Array.isArray(obj[itemsKey]) ? obj : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function finish(
|
|
230
|
+
base: Record<string, unknown>,
|
|
231
|
+
itemsKey: string,
|
|
232
|
+
merged: unknown[],
|
|
233
|
+
token: string | undefined,
|
|
234
|
+
): CallToolResult {
|
|
235
|
+
const out: Record<string, unknown> = { ...base, [itemsKey]: merged };
|
|
236
|
+
if (token === undefined) delete out.nextPageToken;
|
|
237
|
+
else out.nextPageToken = token;
|
|
238
|
+
return rawTextResult(JSON.stringify(out));
|
|
239
|
+
}
|
package/src/lib.ts
CHANGED
|
@@ -19,6 +19,12 @@ export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './ru
|
|
|
19
19
|
// `runOrDiagnose` seam) must still apply this, or their timestamps skip the
|
|
20
20
|
// offset repair and the `<field>Display` sibling every other tool returns.
|
|
21
21
|
export { normalizeTimestamps } from './timestamps.js';
|
|
22
|
+
export { annotateTruncatedList, stripConsumedPageToken } from './pagination.js';
|
|
23
|
+
// Search-result finalization (newest-first ordering + loud truncation metadata).
|
|
24
|
+
// Sub-package search tools must route their output through this or they lose both
|
|
25
|
+
// guarantees the base gog_gmail_search makes.
|
|
26
|
+
export { finalizeGmailSearch, fetchGmailPages } from './gmail-results.js';
|
|
27
|
+
export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
|
|
22
28
|
export { useRemoteGogRunner } from './remote-runner.js';
|
|
23
29
|
export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
|
|
24
30
|
export {
|
|
@@ -31,5 +37,8 @@ export {
|
|
|
31
37
|
ids,
|
|
32
38
|
paginationParams,
|
|
33
39
|
pushPaginationFlags,
|
|
40
|
+
pageTokenParam,
|
|
41
|
+
pageAliasParam,
|
|
42
|
+
resolvePageToken,
|
|
34
43
|
registerRunTool,
|
|
35
44
|
} from './tools/utils.js';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
3
|
+
|
|
4
|
+
// gog reports an exhausted cursor as `"nextPageToken": ""` rather than omitting
|
|
5
|
+
// the field. That empty string is worse than nothing: the natural reading of a
|
|
6
|
+
// paginated response is "the cursor key is there, so there is another page",
|
|
7
|
+
// and a caller acting on the key's PRESENCE pages forever or, worse, concludes
|
|
8
|
+
// the opposite of the truth. Strip it so the field means exactly one thing —
|
|
9
|
+
// present iff another page exists.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately top-level only. A nested `nextPageToken` belongs to some
|
|
12
|
+
// embedded resource whose paging semantics are not ours to reinterpret.
|
|
13
|
+
|
|
14
|
+
// Byte-identical passthrough is the contract when nothing was removed — see
|
|
15
|
+
// normalizeTimestamps, which this sits beside on the same seam and whose
|
|
16
|
+
// indent-preserving discipline it copies.
|
|
17
|
+
function detectIndent(text: string): number {
|
|
18
|
+
const match = /\n(\s+)\S/.exec(text);
|
|
19
|
+
return match ? match[1].replace(/\t/g, ' ').length : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stripConsumedPageToken(text: string): string {
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
if (trimmed === '' || !trimmed.startsWith('{')) return text;
|
|
25
|
+
let parsed: unknown;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(trimmed);
|
|
28
|
+
} catch {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
// The `{` guard above means anything that parses here is a plain object —
|
|
32
|
+
// never null, an array, or a scalar — so no further shape check is needed.
|
|
33
|
+
const obj = parsed as Record<string, unknown>;
|
|
34
|
+
if (obj.nextPageToken !== '') return text;
|
|
35
|
+
delete obj.nextPageToken;
|
|
36
|
+
return JSON.stringify(obj, null, detectIndent(text));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Truncation metadata
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
// How many items the query really has, when we were able to find out.
|
|
44
|
+
export interface MatchCount {
|
|
45
|
+
/** Exact total — the probe reached the end of the result set. */
|
|
46
|
+
total?: number;
|
|
47
|
+
/** Lower bound — the probe filled its page and more remain. */
|
|
48
|
+
atLeast?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The prose matters more than the boolean. A bare `truncated: true` is exactly
|
|
52
|
+
// the kind of structured field a model client skims past — which is how a
|
|
53
|
+
// non-empty nextPageToken produced a confident "no such email exists". This
|
|
54
|
+
// says, in words, the one conclusion the caller must not draw.
|
|
55
|
+
export function truncationWarning(returned: number, count: MatchCount): string {
|
|
56
|
+
const scope = count.total !== undefined
|
|
57
|
+
? `returned ${returned} of ${count.total} matches`
|
|
58
|
+
: count.atLeast !== undefined
|
|
59
|
+
? `returned ${returned} of at least ${count.atLeast} matches`
|
|
60
|
+
: `returned ${returned} matches and MORE EXIST beyond this page`;
|
|
61
|
+
return `INCOMPLETE RESULT SET: ${scope}. Do not report an absence of results based on ` +
|
|
62
|
+
'this response. Page with nextPageToken or narrow the query.';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Add the truncation block to a parsed payload, in place.
|
|
66
|
+
//
|
|
67
|
+
// Only ever called for a genuinely capped set, so the block's PRESENCE is
|
|
68
|
+
// itself the signal and a complete response stays clean. By the time this runs
|
|
69
|
+
// an exhausted cursor has already been stripped by stripConsumedPageToken, so
|
|
70
|
+
// "has a nextPageToken" and "has another page" are the same question.
|
|
71
|
+
export function annotateTruncation(
|
|
72
|
+
out: Record<string, unknown>,
|
|
73
|
+
returned: number,
|
|
74
|
+
count: MatchCount,
|
|
75
|
+
): void {
|
|
76
|
+
out.truncated = true;
|
|
77
|
+
out.returned = returned;
|
|
78
|
+
if (count.total !== undefined) out.totalMatches = count.total;
|
|
79
|
+
if (count.atLeast !== undefined) out.totalMatchesAtLeast = count.atLeast;
|
|
80
|
+
out.warning = truncationWarning(returned, count);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// True when the payload still carries a live cursor.
|
|
84
|
+
export function hasMorePages(parsed: Record<string, unknown>): boolean {
|
|
85
|
+
return typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Attach the truncation block to a tool result whose payload we cannot cheaply
|
|
89
|
+
// count — the fact of truncation without a fabricated total. Anything that is
|
|
90
|
+
// not the JSON shape this understands passes through untouched.
|
|
91
|
+
export function annotateTruncatedList(
|
|
92
|
+
result: CallToolResult,
|
|
93
|
+
itemsKey: string,
|
|
94
|
+
): CallToolResult {
|
|
95
|
+
const first = result.content[0];
|
|
96
|
+
if (result.isError || first?.type !== 'text') return result;
|
|
97
|
+
let parsed: Record<string, unknown>;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(first.text) as Record<string, unknown>;
|
|
100
|
+
} catch {
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
const items = parsed[itemsKey];
|
|
104
|
+
if (!Array.isArray(items) || !hasMorePages(parsed)) return result;
|
|
105
|
+
const out: Record<string, unknown> = { ...parsed };
|
|
106
|
+
annotateTruncation(out, items.length, {});
|
|
107
|
+
return rawTextResult(JSON.stringify(out));
|
|
108
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -167,7 +167,7 @@ const TIMEOUT_MS = 30_000;
|
|
|
167
167
|
// so the requirement change is surfaced in the release notes (see
|
|
168
168
|
// .github/release.yml). This is the single source of truth for the required
|
|
169
169
|
// version; keep the README/CLAUDE.md mention in sync.
|
|
170
|
-
export const MIN_GOG_VERSION = '0.
|
|
170
|
+
export const MIN_GOG_VERSION = '0.37.0';
|
|
171
171
|
|
|
172
172
|
// Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
|
|
173
173
|
// values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
|
package/src/tools/auth.ts
CHANGED
|
@@ -13,6 +13,20 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
13
13
|
`Default: "${defaultServices}". Prefer the narrowest set you need — requesting a service whose ` +
|
|
14
14
|
`Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request ` +
|
|
15
15
|
`with invalid_scope.`;
|
|
16
|
+
// Additional raw OAuth scope URIs, appended after the service scopes gog
|
|
17
|
+
// derives from `services`. This exists for scopes no service selection can
|
|
18
|
+
// ask for: notably bigquery.readonly, which Google demands whenever a Sheets
|
|
19
|
+
// response CONTAINS BigQuery Connected Sheets data (gog_sheets_datasource_*)
|
|
20
|
+
// and which ordinary `sheets` authorization deliberately does not request.
|
|
21
|
+
// Same invalid_scope caveat as `services`: Google rejects the WHOLE request
|
|
22
|
+
// if the scope's API is not enabled on the OAuth client's project, and that
|
|
23
|
+
// happens in the user's browser, so this wrapper cannot catch it.
|
|
24
|
+
const extraScopesDescribe =
|
|
25
|
+
'Additional raw OAuth scope URIs to request, comma-separated, on top of the ones `services` implies. ' +
|
|
26
|
+
'Use for scopes no service covers — e.g. https://www.googleapis.com/auth/bigquery.readonly, required ' +
|
|
27
|
+
'before gog_sheets_datasource_* can read BigQuery-backed Connected Sheets. Leave unset otherwise: an ' +
|
|
28
|
+
'extra scope whose API is not enabled on the OAuth client project makes Google reject the WHOLE ' +
|
|
29
|
+
'authorization with invalid_scope.';
|
|
16
30
|
server.registerTool('gog_auth_list', {
|
|
17
31
|
description:
|
|
18
32
|
'List the Google accounts stored in gogcli, with their scopes. This reads local ' +
|
|
@@ -91,10 +105,19 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
91
105
|
inputSchema: {
|
|
92
106
|
email: z.string().describe('Google account email to authorize'),
|
|
93
107
|
services: z.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
108
|
+
extraScopes: z.string().optional().describe(extraScopesDescribe),
|
|
94
109
|
},
|
|
95
|
-
}, async ({ email, services = defaultServices }) => {
|
|
110
|
+
}, async ({ email, services = defaultServices, extraScopes }) => {
|
|
96
111
|
try {
|
|
97
|
-
|
|
112
|
+
const args = ['auth', 'add', email, '--services', services];
|
|
113
|
+
// --force-consent rides along with extraScopes and only with them. Google
|
|
114
|
+
// re-prompts for a NEW scope only when consent is forced; without it the
|
|
115
|
+
// account can come back still missing the scope, with a success message —
|
|
116
|
+
// the exact shape of failure the caller cannot see. The other two tools
|
|
117
|
+
// force consent unconditionally; this one does not, so it must be added
|
|
118
|
+
// here rather than assumed.
|
|
119
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`, '--force-consent');
|
|
120
|
+
return rawTextResult(await run(args, {
|
|
98
121
|
interactive: true,
|
|
99
122
|
timeout: 300_000,
|
|
100
123
|
}));
|
|
@@ -115,17 +138,17 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
115
138
|
inputSchema: {
|
|
116
139
|
email: z.string().describe('Google account email to authorize'),
|
|
117
140
|
services: z.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
141
|
+
extraScopes: z.string().optional().describe(`${extraScopesDescribe} Pass the SAME value to gog_auth_add_complete.`),
|
|
118
142
|
},
|
|
119
|
-
}, async ({ email, services = defaultServices }) => {
|
|
143
|
+
}, async ({ email, services = defaultServices, extraScopes }) => {
|
|
120
144
|
try {
|
|
121
145
|
// --force-consent guarantees a refresh token even if a prior grant exists
|
|
122
146
|
// (the whole point when recovering from a dead one). redactMode 'tokens'
|
|
123
147
|
// keeps the consent URL's scope names intact (the shared redactor mangles
|
|
124
148
|
// them) while still stripping any real token — a step-1 URL carries none.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
));
|
|
149
|
+
const args = ['auth', 'add', email, '--remote', '--step', '1', '--services', services, '--force-consent'];
|
|
150
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
|
|
151
|
+
return rawTextResult(await run(args, { redactMode: 'tokens' }));
|
|
129
152
|
} catch (err) {
|
|
130
153
|
return errorResult(errorText(err));
|
|
131
154
|
}
|
|
@@ -147,13 +170,17 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
147
170
|
services: z.string().optional().default(defaultServices).describe(
|
|
148
171
|
`Services authorized — MUST match the value passed to gog_auth_add_url. Default: "${defaultServices}".`,
|
|
149
172
|
),
|
|
173
|
+
extraScopes: z.string().optional().describe(
|
|
174
|
+
'Extra OAuth scope URIs — MUST match the value passed to gog_auth_add_url, for the same reason `services` must: ' +
|
|
175
|
+
'the two steps have to describe the same grant.',
|
|
176
|
+
),
|
|
150
177
|
},
|
|
151
|
-
}, async ({ email, redirectUrl, services = defaultServices }) => {
|
|
178
|
+
}, async ({ email, redirectUrl, services = defaultServices, extraScopes }) => {
|
|
152
179
|
try {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
));
|
|
180
|
+
const args = ['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
|
|
181
|
+
'--services', services, '--force-consent'];
|
|
182
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
|
|
183
|
+
return rawTextResult(await run(args));
|
|
157
184
|
} catch (err) {
|
|
158
185
|
return errorResult(errorText(err));
|
|
159
186
|
}
|
package/src/tools/calendar.ts
CHANGED
|
@@ -1,33 +1,54 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
|
|
3
|
+
import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
|
|
4
|
+
import { annotateTruncatedList } from '../pagination.js';
|
|
4
5
|
|
|
5
6
|
export function registerCalendarTools(server: McpServer): void {
|
|
6
7
|
server.registerTool('gog_calendar_events', {
|
|
7
|
-
description: 'List calendar events.
|
|
8
|
+
description: 'List calendar events. Describe the window ONE way and one way only (gog >= 0.36.0 rejects the rest as ambiguous rather than silently discarding a flag): '
|
|
9
|
+
+ 'today on its own; or from + to; or from + days; or days on its own (a window of that many days starting today). today cannot be combined with from, to or days, and days cannot be combined with to. '
|
|
10
|
+
+ 'gog returns only 10 events by default, so a wide date range is USUALLY INCOMPLETE: raise max, or page with pageToken until the response carries no nextPageToken. '
|
|
11
|
+
+ 'A response carrying "truncated": true is an incomplete view — never conclude an event does not exist from one.',
|
|
8
12
|
annotations: { readOnlyHint: true },
|
|
9
13
|
inputSchema: {
|
|
10
14
|
calendarId: z.string().optional().describe('Calendar ID (default: primary calendar)'),
|
|
11
15
|
from: z.string().optional().describe('Start time filter (RFC3339, date, or natural language)'),
|
|
12
|
-
to: z.string().optional().describe('End time filter (RFC3339, date, or natural language)'),
|
|
13
|
-
|
|
16
|
+
to: z.string().optional().describe('End time filter (RFC3339, date, or natural language). Mutually exclusive with today and with days.'),
|
|
17
|
+
// gog >= 0.36.0 (openclaw/gogcli#981). Before that release --days sat in
|
|
18
|
+
// a switch arm evaluated ahead of --from, so `--from 2026-09-25 --days 5`
|
|
19
|
+
// silently threw --from away and answered for today instead — at exit 0,
|
|
20
|
+
// in a well-formed table. It is only exposed here now that it means what
|
|
21
|
+
// it says.
|
|
22
|
+
days: z.number().int().positive().optional().describe('Window LENGTH in days (calendar days, DST-aware), measured from `from` when one is given and from today otherwise. Use from + days for "the week of the 25th"; days alone for "the next N days". Mutually exclusive with to and with today.'),
|
|
23
|
+
today: z.boolean().optional().describe('Only show today\'s events. A complete window on its own — mutually exclusive with from, to and days.'),
|
|
14
24
|
query: z.string().optional().describe('Free text search within events'),
|
|
15
|
-
|
|
25
|
+
max: z.number().int().optional().describe('Max events to return. gog defaults to 10, which silently hides the rest — raise it, or page with pageToken.'),
|
|
26
|
+
pageToken: pageTokenParam,
|
|
27
|
+
page: pageAliasParam,
|
|
28
|
+
all: z.boolean().optional().describe('Fetch events from ALL CALENDARS. NOTE: unlike the gmail search tools, this does NOT mean "all pages" — it widens the calendar set, not the page window. Use pageToken to reach later pages.'),
|
|
16
29
|
eventTypes: z.array(z.enum(['default', 'birthday', 'focus-time', 'from-gmail', 'out-of-office', 'working-location'])).optional().describe('Filter to specific event types (repeatable)'),
|
|
17
30
|
timezone: z.string().optional().describe('Display timezone for event times (IANA name, e.g. America/New_York, or "local" for the system timezone). Default: each event\'s timezone, then its calendar\'s timezone.'),
|
|
18
31
|
account: accountParam,
|
|
19
32
|
},
|
|
20
|
-
}, async ({ calendarId, from, to, today, query, all, eventTypes, timezone, account }) => {
|
|
33
|
+
}, async ({ calendarId, from, to, days, today, query, max, pageToken, page, all, eventTypes, timezone, account }) => {
|
|
21
34
|
const args = ['calendar', 'events'];
|
|
22
35
|
if (calendarId) args.push(calendarId);
|
|
23
36
|
if (from) args.push(`--from=${from}`);
|
|
24
37
|
if (to) args.push(`--to=${to}`);
|
|
38
|
+
if (days !== undefined) args.push(`--days=${days}`);
|
|
25
39
|
if (today) args.push('--today');
|
|
26
40
|
if (query) args.push(`--query=${query}`);
|
|
41
|
+
if (max !== undefined) args.push(`--max=${max}`);
|
|
42
|
+
const token = resolvePageToken({ pageToken, page });
|
|
43
|
+
if (token) args.push(`--page=${token}`);
|
|
27
44
|
if (all) args.push('--all');
|
|
28
45
|
if (eventTypes) for (const t of eventTypes) args.push(`--event-types=${t}`);
|
|
29
46
|
if (timezone) args.push(`--timezone=${timezone}`);
|
|
30
|
-
|
|
47
|
+
const result = await runOrDiagnose(args, { account });
|
|
48
|
+
// No count probe here: unlike Gmail's list endpoints, the Calendar API has
|
|
49
|
+
// no cheap way to count a range exactly, so the warning carries the fact of
|
|
50
|
+
// truncation without inventing a total.
|
|
51
|
+
return annotateTruncatedList(result, 'events');
|
|
31
52
|
});
|
|
32
53
|
|
|
33
54
|
server.registerTool('gog_calendar_get', {
|