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.
@@ -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
+ }
@@ -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
- all: z.boolean().optional().describe('Fetch events from all calendars'),
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
- return runOrDiagnose(args, { account });
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', {
@@ -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
- page: z.string().optional().describe('Page token for pagination'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
  });
@@ -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
- page: z.string().optional().describe('Page token for pagination'),
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
- if (page) args.push(`--page=${page}`);
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 });