gogcli-mcp 2.23.0 → 2.23.2
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 +234 -40
- package/dist/lib.js +243 -41
- package/manifest.json +3 -3
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/gmail-results.ts +228 -0
- package/src/lib.ts +9 -0
- package/src/pagination.ts +108 -0
- package/src/tools/calendar.ts +18 -5
- package/src/tools/classroom.ts +46 -28
- package/src/tools/drive.ts +6 -4
- package/src/tools/gmail.ts +29 -4
- 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/calendar.test.ts +51 -0
- package/tests/tools/gmail.test.ts +146 -0
|
@@ -0,0 +1,228 @@
|
|
|
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 cannot supply this itself: `gmail search` and `gmail messages search`
|
|
75
|
+
// build their JSON by hand from the items plus nextPageToken, so a direct
|
|
76
|
+
// Discovery call is the only route. It is only ever spent on a result set
|
|
77
|
+
// already known to be truncated.
|
|
78
|
+
//
|
|
79
|
+
// Best-effort by construction: any failure must degrade the warning, never the
|
|
80
|
+
// search.
|
|
81
|
+
async function countMatches(
|
|
82
|
+
method: GmailListMethod,
|
|
83
|
+
itemsKey: 'threads' | 'messages',
|
|
84
|
+
query: string,
|
|
85
|
+
account: string | undefined,
|
|
86
|
+
): Promise<MatchCount> {
|
|
87
|
+
try {
|
|
88
|
+
const params = JSON.stringify({
|
|
89
|
+
userId: 'me',
|
|
90
|
+
q: query,
|
|
91
|
+
maxResults: COUNT_PROBE_PAGE_SIZE,
|
|
92
|
+
fields: `${itemsKey}/id,nextPageToken`,
|
|
93
|
+
});
|
|
94
|
+
const raw = await run(['api', 'call', 'gmail', 'v1', method, `--params=${params}`], { account });
|
|
95
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
96
|
+
const items = parsed[itemsKey];
|
|
97
|
+
if (!Array.isArray(items)) return {};
|
|
98
|
+
const more = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== '';
|
|
99
|
+
return more ? { atLeast: items.length } : { total: items.length };
|
|
100
|
+
} catch {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface FinalizeOptions {
|
|
106
|
+
/** Which array in gog's payload holds the results. */
|
|
107
|
+
itemsKey: 'threads' | 'messages';
|
|
108
|
+
/** The Discovery method whose resultSizeEstimate matches that array. */
|
|
109
|
+
method: GmailListMethod;
|
|
110
|
+
/** The query as the caller wrote it. */
|
|
111
|
+
query: string;
|
|
112
|
+
account?: string;
|
|
113
|
+
/**
|
|
114
|
+
* False when gog rewrote the query server-side (--from-contact resolves a
|
|
115
|
+
* contact through the People API to addresses we cannot reproduce). An
|
|
116
|
+
* estimate for a DIFFERENT query is worse than none, so the field is skipped
|
|
117
|
+
* rather than guessed.
|
|
118
|
+
*/
|
|
119
|
+
queryIsExact?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The single seam every Gmail search result passes through: guarantee the
|
|
123
|
+
// ordering, and make a capped result set say so out loud. Any output that is
|
|
124
|
+
// not the JSON shape this understands — an error, a plain "No results" — is
|
|
125
|
+
// returned untouched.
|
|
126
|
+
export async function finalizeGmailSearch(
|
|
127
|
+
result: CallToolResult,
|
|
128
|
+
options: FinalizeOptions,
|
|
129
|
+
): Promise<CallToolResult> {
|
|
130
|
+
const { itemsKey, method, query, account, queryIsExact = true } = options;
|
|
131
|
+
const first = result.content[0];
|
|
132
|
+
if (result.isError || first?.type !== 'text') return result;
|
|
133
|
+
|
|
134
|
+
let parsed: Record<string, unknown>;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(first.text) as Record<string, unknown>;
|
|
137
|
+
} catch {
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
const items = parsed[itemsKey];
|
|
141
|
+
if (!Array.isArray(items)) return result;
|
|
142
|
+
|
|
143
|
+
const sorted = sortNewestFirst(items as GmailListItem[]);
|
|
144
|
+
const out: Record<string, unknown> = { ...parsed, [itemsKey]: sorted };
|
|
145
|
+
|
|
146
|
+
// The presence of the block is itself the signal, so it is added ONLY for a
|
|
147
|
+
// genuinely capped set. gog leaves nextPageToken empty when --all exhausted
|
|
148
|
+
// the pages, which is why --all needs no special case here.
|
|
149
|
+
if (hasMorePages(parsed)) {
|
|
150
|
+
const count = queryIsExact ? await countMatches(method, itemsKey, query, account) : {};
|
|
151
|
+
annotateTruncation(out, sorted.length, count);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return rawTextResult(JSON.stringify(out));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Walk up to `maxPages` pages and merge them into one payload.
|
|
158
|
+
//
|
|
159
|
+
// The point is the existence question — "is there any mail matching X?" — which
|
|
160
|
+
// a single page cannot answer and which is exactly where a truncated response
|
|
161
|
+
// gets misread as a negative. gog has `--all`, but that is unbounded: on a
|
|
162
|
+
// broad query it walks the entire mailbox. This is the bounded form, so a
|
|
163
|
+
// caller can say "look at up to 5 pages" and get either a real answer or an
|
|
164
|
+
// honestly-still-truncated one.
|
|
165
|
+
//
|
|
166
|
+
// The merged payload keeps a nextPageToken only if pages remain when the cap is
|
|
167
|
+
// hit, so finalizeGmailSearch still marks it truncated — running out of budget
|
|
168
|
+
// is not the same as reaching the end, and must not read like it.
|
|
169
|
+
export async function fetchGmailPages(
|
|
170
|
+
runPage: (token: string | undefined) => Promise<CallToolResult>,
|
|
171
|
+
itemsKey: 'threads' | 'messages',
|
|
172
|
+
maxPages: number,
|
|
173
|
+
startToken: string | undefined,
|
|
174
|
+
): Promise<CallToolResult> {
|
|
175
|
+
const merged: unknown[] = [];
|
|
176
|
+
let base: Record<string, unknown> | undefined;
|
|
177
|
+
let token = startToken;
|
|
178
|
+
|
|
179
|
+
for (let pages = 0; pages < maxPages; pages++) {
|
|
180
|
+
const result = await runPage(token);
|
|
181
|
+
const parsed = parsePage(result, itemsKey);
|
|
182
|
+
// An error, or output this does not understand, part-way through the walk.
|
|
183
|
+
// Nothing collected yet means the caller should just see that result; once
|
|
184
|
+
// pages ARE collected, return them WITH the cursor that was about to be
|
|
185
|
+
// consumed, so the set still reads as truncated rather than complete.
|
|
186
|
+
if (parsed === undefined) {
|
|
187
|
+
return base === undefined ? result : finish(base, itemsKey, merged, token);
|
|
188
|
+
}
|
|
189
|
+
base = parsed;
|
|
190
|
+
merged.push(...(parsed[itemsKey] as unknown[]));
|
|
191
|
+
token = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== ''
|
|
192
|
+
? parsed.nextPageToken
|
|
193
|
+
: undefined;
|
|
194
|
+
if (token === undefined) break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return finish(base as Record<string, unknown>, itemsKey, merged, token);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A page's payload, or undefined if it is not a usable list response.
|
|
201
|
+
function parsePage(
|
|
202
|
+
result: CallToolResult,
|
|
203
|
+
itemsKey: string,
|
|
204
|
+
): Record<string, unknown> | undefined {
|
|
205
|
+
const first = result.content[0];
|
|
206
|
+
if (result.isError || first?.type !== 'text') return undefined;
|
|
207
|
+
let parsed: unknown;
|
|
208
|
+
try {
|
|
209
|
+
parsed = JSON.parse(first.text);
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
if (parsed === null || typeof parsed !== 'object') return undefined;
|
|
214
|
+
const obj = parsed as Record<string, unknown>;
|
|
215
|
+
return Array.isArray(obj[itemsKey]) ? obj : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function finish(
|
|
219
|
+
base: Record<string, unknown>,
|
|
220
|
+
itemsKey: string,
|
|
221
|
+
merged: unknown[],
|
|
222
|
+
token: string | undefined,
|
|
223
|
+
): CallToolResult {
|
|
224
|
+
const out: Record<string, unknown> = { ...base, [itemsKey]: merged };
|
|
225
|
+
if (token === undefined) delete out.nextPageToken;
|
|
226
|
+
else out.nextPageToken = token;
|
|
227
|
+
return rawTextResult(JSON.stringify(out));
|
|
228
|
+
}
|
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/tools/calendar.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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. Filters can be combined (e.g. --from + --to for a range, or --today for just today).'
|
|
8
|
+
description: 'List calendar events. Filters can be combined (e.g. --from + --to for a range, or --today for just today). '
|
|
9
|
+
+ '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. '
|
|
10
|
+
+ 'A response carrying "truncated": true is an incomplete view — never conclude an event does not exist from one.',
|
|
8
11
|
annotations: { readOnlyHint: true },
|
|
9
12
|
inputSchema: {
|
|
10
13
|
calendarId: z.string().optional().describe('Calendar ID (default: primary calendar)'),
|
|
@@ -12,22 +15,32 @@ export function registerCalendarTools(server: McpServer): void {
|
|
|
12
15
|
to: z.string().optional().describe('End time filter (RFC3339, date, or natural language)'),
|
|
13
16
|
today: z.boolean().optional().describe('Only show today\'s events'),
|
|
14
17
|
query: z.string().optional().describe('Free text search within events'),
|
|
15
|
-
|
|
18
|
+
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.'),
|
|
19
|
+
pageToken: pageTokenParam,
|
|
20
|
+
page: pageAliasParam,
|
|
21
|
+
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
22
|
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
23
|
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
24
|
account: accountParam,
|
|
19
25
|
},
|
|
20
|
-
}, async ({ calendarId, from, to, today, query, all, eventTypes, timezone, account }) => {
|
|
26
|
+
}, async ({ calendarId, from, to, today, query, max, pageToken, page, all, eventTypes, timezone, account }) => {
|
|
21
27
|
const args = ['calendar', 'events'];
|
|
22
28
|
if (calendarId) args.push(calendarId);
|
|
23
29
|
if (from) args.push(`--from=${from}`);
|
|
24
30
|
if (to) args.push(`--to=${to}`);
|
|
25
31
|
if (today) args.push('--today');
|
|
26
32
|
if (query) args.push(`--query=${query}`);
|
|
33
|
+
if (max !== undefined) args.push(`--max=${max}`);
|
|
34
|
+
const token = resolvePageToken({ pageToken, page });
|
|
35
|
+
if (token) args.push(`--page=${token}`);
|
|
27
36
|
if (all) args.push('--all');
|
|
28
37
|
if (eventTypes) for (const t of eventTypes) args.push(`--event-types=${t}`);
|
|
29
38
|
if (timezone) args.push(`--timezone=${timezone}`);
|
|
30
|
-
|
|
39
|
+
const result = await runOrDiagnose(args, { account });
|
|
40
|
+
// No count probe here: unlike Gmail's list endpoints, the Calendar API has
|
|
41
|
+
// no cheap way to count a range exactly, so the warning carries the fact of
|
|
42
|
+
// truncation without inventing a total.
|
|
43
|
+
return annotateTruncatedList(result, 'events');
|
|
31
44
|
});
|
|
32
45
|
|
|
33
46
|
server.registerTool('gog_calendar_get', {
|
package/src/tools/classroom.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
4
|
|
|
5
5
|
export function registerClassroomTools(server: McpServer): void {
|
|
6
6
|
server.registerTool('gog_classroom_courses_list', {
|
|
@@ -11,17 +11,19 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
11
11
|
teacher: z.string().optional().describe('Filter by teacher user ID'),
|
|
12
12
|
student: z.string().optional().describe('Filter by student user ID'),
|
|
13
13
|
max: z.number().optional().describe('Max results per page'),
|
|
14
|
-
|
|
14
|
+
pageToken: pageTokenParam,
|
|
15
|
+
page: pageAliasParam,
|
|
15
16
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
16
17
|
account: accountParam,
|
|
17
18
|
},
|
|
18
|
-
}, async ({ state, teacher, student, max, page, all, account }) => {
|
|
19
|
+
}, async ({ state, teacher, student, max, pageToken, page, all, account }) => {
|
|
19
20
|
const args = ['classroom', 'courses', 'list'];
|
|
20
21
|
if (state) args.push(`--state=${state}`);
|
|
21
22
|
if (teacher) args.push(`--teacher=${teacher}`);
|
|
22
23
|
if (student) args.push(`--student=${student}`);
|
|
23
24
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
24
|
-
|
|
25
|
+
const token = resolvePageToken({ pageToken, page });
|
|
26
|
+
if (token) args.push(`--page=${token}`);
|
|
25
27
|
if (all) args.push('--all');
|
|
26
28
|
return runOrDiagnose(args, { account });
|
|
27
29
|
});
|
|
@@ -43,14 +45,16 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
43
45
|
inputSchema: {
|
|
44
46
|
courseId: z.string().describe('Course ID'),
|
|
45
47
|
max: z.number().optional().describe('Max results per page'),
|
|
46
|
-
|
|
48
|
+
pageToken: pageTokenParam,
|
|
49
|
+
page: pageAliasParam,
|
|
47
50
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
48
51
|
account: accountParam,
|
|
49
52
|
},
|
|
50
|
-
}, async ({ courseId, max, page, all, account }) => {
|
|
53
|
+
}, async ({ courseId, max, pageToken, page, all, account }) => {
|
|
51
54
|
const args = ['classroom', 'students', 'list', courseId];
|
|
52
55
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
53
|
-
|
|
56
|
+
const token = resolvePageToken({ pageToken, page });
|
|
57
|
+
if (token) args.push(`--page=${token}`);
|
|
54
58
|
if (all) args.push('--all');
|
|
55
59
|
return runOrDiagnose(args, { account });
|
|
56
60
|
});
|
|
@@ -73,14 +77,16 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
73
77
|
inputSchema: {
|
|
74
78
|
courseId: z.string().describe('Course ID'),
|
|
75
79
|
max: z.number().optional().describe('Max results per page'),
|
|
76
|
-
|
|
80
|
+
pageToken: pageTokenParam,
|
|
81
|
+
page: pageAliasParam,
|
|
77
82
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
78
83
|
account: accountParam,
|
|
79
84
|
},
|
|
80
|
-
}, async ({ courseId, max, page, all, account }) => {
|
|
85
|
+
}, async ({ courseId, max, pageToken, page, all, account }) => {
|
|
81
86
|
const args = ['classroom', 'teachers', 'list', courseId];
|
|
82
87
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
83
|
-
|
|
88
|
+
const token = resolvePageToken({ pageToken, page });
|
|
89
|
+
if (token) args.push(`--page=${token}`);
|
|
84
90
|
if (all) args.push('--all');
|
|
85
91
|
return runOrDiagnose(args, { account });
|
|
86
92
|
});
|
|
@@ -105,16 +111,18 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
105
111
|
students: z.boolean().optional().describe('Include students only'),
|
|
106
112
|
teachers: z.boolean().optional().describe('Include teachers only'),
|
|
107
113
|
max: z.number().optional().describe('Max results per page'),
|
|
108
|
-
|
|
114
|
+
pageToken: pageTokenParam,
|
|
115
|
+
page: pageAliasParam,
|
|
109
116
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
110
117
|
account: accountParam,
|
|
111
118
|
},
|
|
112
|
-
}, async ({ courseId, students, teachers, max, page, all, account }) => {
|
|
119
|
+
}, async ({ courseId, students, teachers, max, pageToken, page, all, account }) => {
|
|
113
120
|
const args = ['classroom', 'roster', courseId];
|
|
114
121
|
if (students) args.push('--students');
|
|
115
122
|
if (teachers) args.push('--teachers');
|
|
116
123
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
117
|
-
|
|
124
|
+
const token = resolvePageToken({ pageToken, page });
|
|
125
|
+
if (token) args.push(`--page=${token}`);
|
|
118
126
|
if (all) args.push('--all');
|
|
119
127
|
return runOrDiagnose(args, { account });
|
|
120
128
|
});
|
|
@@ -128,18 +136,20 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
128
136
|
topic: z.string().optional().describe('Filter by topic ID'),
|
|
129
137
|
orderBy: z.string().optional().describe('Sort order (e.g. "updateTime desc")'),
|
|
130
138
|
max: z.number().optional().describe('Max results per page'),
|
|
131
|
-
|
|
139
|
+
pageToken: pageTokenParam,
|
|
140
|
+
page: pageAliasParam,
|
|
132
141
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
133
142
|
scanPages: z.number().optional().describe('Max pages to scan when filtering'),
|
|
134
143
|
account: accountParam,
|
|
135
144
|
},
|
|
136
|
-
}, async ({ courseId, state, topic, orderBy, max, page, all, scanPages, account }) => {
|
|
145
|
+
}, async ({ courseId, state, topic, orderBy, max, pageToken, page, all, scanPages, account }) => {
|
|
137
146
|
const args = ['classroom', 'coursework', 'list', courseId];
|
|
138
147
|
if (state) args.push(`--state=${state}`);
|
|
139
148
|
if (topic) args.push(`--topic=${topic}`);
|
|
140
149
|
if (orderBy) args.push(`--order-by=${orderBy}`);
|
|
141
150
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
142
|
-
|
|
151
|
+
const token = resolvePageToken({ pageToken, page });
|
|
152
|
+
if (token) args.push(`--page=${token}`);
|
|
143
153
|
if (all) args.push('--all');
|
|
144
154
|
if (scanPages !== undefined) args.push(`--scan-pages=${scanPages}`);
|
|
145
155
|
return runOrDiagnose(args, { account });
|
|
@@ -167,17 +177,19 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
167
177
|
late: z.enum(['late', 'not-late']).optional().describe('Filter by late status'),
|
|
168
178
|
user: z.string().optional().describe('Filter by student user ID'),
|
|
169
179
|
max: z.number().optional().describe('Max results per page'),
|
|
170
|
-
|
|
180
|
+
pageToken: pageTokenParam,
|
|
181
|
+
page: pageAliasParam,
|
|
171
182
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
172
183
|
account: accountParam,
|
|
173
184
|
},
|
|
174
|
-
}, async ({ courseId, courseworkId, state, late, user, max, page, all, account }) => {
|
|
185
|
+
}, async ({ courseId, courseworkId, state, late, user, max, pageToken, page, all, account }) => {
|
|
175
186
|
const args = ['classroom', 'submissions', 'list', courseId, courseworkId];
|
|
176
187
|
if (state) args.push(`--state=${state}`);
|
|
177
188
|
if (late) args.push(`--late=${late}`);
|
|
178
189
|
if (user) args.push(`--user=${user}`);
|
|
179
190
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
180
|
-
|
|
191
|
+
const token = resolvePageToken({ pageToken, page });
|
|
192
|
+
if (token) args.push(`--page=${token}`);
|
|
181
193
|
if (all) args.push('--all');
|
|
182
194
|
return runOrDiagnose(args, { account });
|
|
183
195
|
});
|
|
@@ -260,16 +272,18 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
260
272
|
state: z.string().optional().describe('Filter by announcement state'),
|
|
261
273
|
orderBy: z.string().optional().describe('Sort order'),
|
|
262
274
|
max: z.number().optional().describe('Max results per page'),
|
|
263
|
-
|
|
275
|
+
pageToken: pageTokenParam,
|
|
276
|
+
page: pageAliasParam,
|
|
264
277
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
265
278
|
account: accountParam,
|
|
266
279
|
},
|
|
267
|
-
}, async ({ courseId, state, orderBy, max, page, all, account }) => {
|
|
280
|
+
}, async ({ courseId, state, orderBy, max, pageToken, page, all, account }) => {
|
|
268
281
|
const args = ['classroom', 'announcements', 'list', courseId];
|
|
269
282
|
if (state) args.push(`--state=${state}`);
|
|
270
283
|
if (orderBy) args.push(`--order-by=${orderBy}`);
|
|
271
284
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
272
|
-
|
|
285
|
+
const token = resolvePageToken({ pageToken, page });
|
|
286
|
+
if (token) args.push(`--page=${token}`);
|
|
273
287
|
if (all) args.push('--all');
|
|
274
288
|
return runOrDiagnose(args, { account });
|
|
275
289
|
});
|
|
@@ -308,14 +322,16 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
308
322
|
inputSchema: {
|
|
309
323
|
courseId: z.string().describe('Course ID'),
|
|
310
324
|
max: z.number().optional().describe('Max results per page'),
|
|
311
|
-
|
|
325
|
+
pageToken: pageTokenParam,
|
|
326
|
+
page: pageAliasParam,
|
|
312
327
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
313
328
|
account: accountParam,
|
|
314
329
|
},
|
|
315
|
-
}, async ({ courseId, max, page, all, account }) => {
|
|
330
|
+
}, async ({ courseId, max, pageToken, page, all, account }) => {
|
|
316
331
|
const args = ['classroom', 'topics', 'list', courseId];
|
|
317
332
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
318
|
-
|
|
333
|
+
const token = resolvePageToken({ pageToken, page });
|
|
334
|
+
if (token) args.push(`--page=${token}`);
|
|
319
335
|
if (all) args.push('--all');
|
|
320
336
|
return runOrDiagnose(args, { account });
|
|
321
337
|
});
|
|
@@ -339,16 +355,18 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
339
355
|
course: z.string().optional().describe('Filter by course ID'),
|
|
340
356
|
user: z.string().optional().describe('Filter by user ID'),
|
|
341
357
|
max: z.number().optional().describe('Max results per page'),
|
|
342
|
-
|
|
358
|
+
pageToken: pageTokenParam,
|
|
359
|
+
page: pageAliasParam,
|
|
343
360
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
344
361
|
account: accountParam,
|
|
345
362
|
},
|
|
346
|
-
}, async ({ course, user, max, page, all, account }) => {
|
|
363
|
+
}, async ({ course, user, max, pageToken, page, all, account }) => {
|
|
347
364
|
const args = ['classroom', 'invitations', 'list'];
|
|
348
365
|
if (course) args.push(`--course=${course}`);
|
|
349
366
|
if (user) args.push(`--user=${user}`);
|
|
350
367
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
351
|
-
|
|
368
|
+
const token = resolvePageToken({ pageToken, page });
|
|
369
|
+
if (token) args.push(`--page=${token}`);
|
|
352
370
|
if (all) args.push('--all');
|
|
353
371
|
return runOrDiagnose(args, { account });
|
|
354
372
|
});
|
package/src/tools/drive.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
5
5
|
import { run, runBinary } from '../runner.js';
|
|
6
|
-
import { accountParam, diagnose, runOrDiagnose, registerRunTool } from './utils.js';
|
|
6
|
+
import { accountParam, diagnose, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
|
|
7
7
|
|
|
8
8
|
// A native Google Doc exports to text directly; anything else (PDF, image,
|
|
9
9
|
// docx, …) is first copied WITH conversion to this type, which makes Drive run
|
|
@@ -25,16 +25,18 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
25
25
|
inputSchema: {
|
|
26
26
|
folderId: z.string().optional().describe('Folder ID to list (default: root)'),
|
|
27
27
|
max: z.number().optional().describe('Max results (default: 20)'),
|
|
28
|
-
|
|
28
|
+
pageToken: pageTokenParam,
|
|
29
|
+
page: pageAliasParam,
|
|
29
30
|
query: z.string().optional().describe('Drive query filter (e.g. "name contains \'budget\'")'),
|
|
30
31
|
allDrives: z.boolean().optional().describe('Include shared drives (default: true). Set false for My Drive only.'),
|
|
31
32
|
account: accountParam,
|
|
32
33
|
},
|
|
33
|
-
}, async ({ folderId, max, page, query, allDrives, account }) => {
|
|
34
|
+
}, async ({ folderId, max, pageToken, page, query, allDrives, account }) => {
|
|
34
35
|
const args = ['drive', 'ls'];
|
|
35
36
|
if (folderId) args.push(`--parent=${folderId}`);
|
|
36
37
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
37
|
-
|
|
38
|
+
const token = resolvePageToken({ pageToken, page });
|
|
39
|
+
if (token) args.push(`--page=${token}`);
|
|
38
40
|
if (query) args.push(`--query=${query}`);
|
|
39
41
|
if (allDrives === false) args.push('--no-all-drives');
|
|
40
42
|
return runOrDiagnose(args, { account });
|