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.
@@ -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 });
@@ -1,36 +1,67 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, registerRunTool, payloadArg } from './utils.js';
3
+ import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
4
+ import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
4
5
  import type { GogArg } from '../runner.js';
5
6
 
6
7
  export function registerGmailTools(server: McpServer): void {
7
8
  server.registerTool('gog_gmail_search', {
8
- description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com).',
9
+ description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com). '
10
+ + 'Results are ALWAYS newest-first by Gmail\'s internalDate — the wrapper sorts them, so the first result is the most recent match and a recent message can never be buried below older ones. '
11
+ + 'IMPORTANT — a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist, or that there is no such mail, on the strength of one. Page through it (pass nextPageToken back as `pageToken`), set maxPages to walk several pages in one call, or narrow the query, and only then draw a conclusion. '
12
+ + 'If you already know the thread, do not search for it at all — read it directly with gog_gmail_thread_get, which returns the whole thread and cannot be truncated or mis-ranked.',
9
13
  annotations: { readOnlyHint: true },
10
14
  inputSchema: {
11
15
  query: z.string().describe('Gmail search query'),
12
16
  max: z.number().int().optional().describe('Max results to return (default: 10)'),
17
+ pageToken: pageTokenParam,
18
+ page: pageAliasParam,
19
+ maxPages: z.number().int().positive().max(20).optional().describe('Walk up to this many pages in ONE call and merge the results, instead of returning a single page. Use it for existence questions (\"is there any mail matching X?\"), which a single page cannot answer. Stops early at the last page; if pages remain when the cap is hit the response is still marked truncated. Prefer this over all=true, which is unbounded.'),
20
+ all: z.boolean().optional().describe('Fetch every page instead of one. Removes truncation entirely, at the cost of one API round-trip per page — the reliable way to answer "does any message match?" for a query with few expected hits.'),
13
21
  fromContact: z.string().optional().describe('Resolve a Google Contact (name or email) to its addresses and AND a from:(addr OR addr) clause onto the query — saves looking the contact up first when you only know who, not which address.'),
14
22
  account: accountParam,
15
23
  },
16
- }, async ({ query, max, fromContact, account }) => {
24
+ }, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
17
25
  const args = ['gmail', 'search', query];
18
26
  if (max !== undefined) args.push(`--max=${max}`);
27
+ if (all) args.push('--all');
19
28
  if (fromContact) args.push(`--from-contact=${fromContact}`);
20
- return runOrDiagnose(args, { account });
29
+ // The cursor is applied per page rather than baked into args, so the
30
+ // multi-page walk can advance it.
31
+ const runPage = (tok: string | undefined) =>
32
+ runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
33
+ const token = resolvePageToken({ pageToken, page });
34
+ const result = maxPages !== undefined
35
+ ? await fetchGmailPages(runPage, 'threads', maxPages, token)
36
+ : await runPage(token);
37
+ return finalizeGmailSearch(result, {
38
+ itemsKey: 'threads',
39
+ method: 'users.threads.list',
40
+ query,
41
+ account,
42
+ // --from-contact is expanded INSIDE gog, against the People API, so the
43
+ // query Gmail actually saw is not the one we hold here.
44
+ queryIsExact: !fromContact,
45
+ });
21
46
  });
22
47
 
23
48
  server.registerTool('gog_gmail_get', {
24
- description: 'Get a Gmail message by ID.',
49
+ description: 'Get a Gmail message by ID. For a long message, sanitizeContent is the cheapest way to keep it in context: it drops the raw MIME payload and the HTML part, which are usually the bulk of the response.',
25
50
  annotations: { readOnlyHint: true },
26
51
  inputSchema: {
27
52
  messageId: z.string().describe('Message ID'),
28
53
  format: z.enum(['full', 'metadata', 'raw']).optional().describe('Message format (default: full)'),
54
+ // Requires gog >= 0.37.0. Before that (openclaw/gogcli#992) the JSON
55
+ // carried the headers and body TWICE — once inside `message`, once
56
+ // copied to the top level — so the flag meant to shrink the payload
57
+ // enlarged it. MIN_GOG_VERSION is the guard; there is no runtime check.
58
+ sanitizeContent: z.boolean().optional().describe('Return agent-oriented sanitized content: HTML stripped, HTTP(S) URLs removed, raw Gmail payloads omitted from the JSON. The largest payload-size reduction available here. Note the URL removal is lossy — omit this when you need to follow a link out of the message.'),
29
59
  account: accountParam,
30
60
  },
31
- }, async ({ messageId, format, account }) => {
61
+ }, async ({ messageId, format, sanitizeContent, account }) => {
32
62
  const args = ['gmail', 'get', messageId];
33
63
  if (format) args.push(`--format=${format}`);
64
+ if (sanitizeContent) args.push('--sanitize-content');
34
65
  return runOrDiagnose(args, { account });
35
66
  });
36
67
 
@@ -5,6 +5,7 @@ import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
5
5
  import { run, isRunnerTransportError } from '../runner.js';
6
6
  import type { GogArg, RunnerFailureKind } from '../runner.js';
7
7
  import { normalizeTimestamps } from '../timestamps.js';
8
+ import { stripConsumedPageToken } from '../pagination.js';
8
9
 
9
10
  // Byte size at or below which a payload stays on the plain inline flag.
10
11
  //
@@ -77,10 +78,36 @@ export const ids = {
77
78
  person: z.string().describe('Person resource name (people/...) or email'),
78
79
  };
79
80
 
80
- // Pagination param triple appears in 20+ tools across base + extras.
81
+ // THE CURSOR IS NAMED AFTER THE FIELD THAT CARRIES IT. Every paginated response
82
+ // reports its cursor as `nextPageToken`, so the request parameter is
83
+ // `pageToken` — a caller reading a response can guess the input name and be
84
+ // right. It used to be `page` (after gog's own `--page` flag), and that
85
+ // mismatch was not cosmetic: MCP tool inputs are zod objects, which SILENTLY
86
+ // STRIP unknown keys, so a client that inferred `pageToken` had it dropped
87
+ // before the handler ran and got page 1 back forever — same items, same token,
88
+ // no error. Two "that email doesn't exist" incidents came from exactly that.
89
+ export const pageTokenParam = z.string().optional().describe(
90
+ 'Cursor for the NEXT page. Pass back the nextPageToken from a previous response verbatim, ' +
91
+ 'keeping the query and max identical: call once, then call again with pageToken=<that value>. ' +
92
+ 'A response with NO nextPageToken is the last page.',
93
+ );
94
+
95
+ // Kept so anything already sending `page` keeps working. Prefer pageTokenParam.
96
+ export const pageAliasParam = z.string().optional().describe(
97
+ 'Deprecated alias for pageToken, accepted so existing callers keep working. Use pageToken — ' +
98
+ 'it matches the nextPageToken field in the response.',
99
+ );
100
+
101
+ // The one place the alias collapses into a single value.
102
+ export function resolvePageToken(p: { pageToken?: string; page?: string }): string | undefined {
103
+ return p.pageToken ?? p.page;
104
+ }
105
+
106
+ // Pagination params — appear in 30+ tools across base + extras.
81
107
  export const paginationParams = {
82
108
  max: z.number().int().optional().describe('Max results'),
83
- page: z.string().optional().describe('Page token'),
109
+ pageToken: pageTokenParam,
110
+ page: pageAliasParam,
84
111
  all: z.boolean().optional().describe('Fetch all pages'),
85
112
  };
86
113
 
@@ -88,10 +115,11 @@ export const paginationParams = {
88
115
  // paginationParams above. Use together to keep call sites concise.
89
116
  export function pushPaginationFlags(
90
117
  args: string[],
91
- p: { max?: number; page?: string; all?: boolean },
118
+ p: { max?: number; pageToken?: string; page?: string; all?: boolean },
92
119
  ): void {
93
120
  if (p.max !== undefined) args.push(`--max=${p.max}`);
94
- if (p.page) args.push(`--page=${p.page}`);
121
+ const token = resolvePageToken(p);
122
+ if (token) args.push(`--page=${token}`);
95
123
  if (p.all) args.push('--all');
96
124
  }
97
125
 
@@ -330,7 +358,10 @@ export async function runOrDiagnose(
330
358
  // truth would stop telling it. Losslessness wins over presentation there —
331
359
  // the friendlier views of the same data are already normalized.
332
360
  const raw = await run(args, options);
333
- return rawTextResult(options.lossless ? raw : normalizeTimestamps(raw));
361
+ // Same seam, same reason as normalizeTimestamps: doing this per call site
362
+ // would let one paginated tool forget and go on reporting a spent cursor as
363
+ // if it were a live one. `lossless` opts the raw dumps out of both.
364
+ return rawTextResult(options.lossless ? raw : stripConsumedPageToken(normalizeTimestamps(raw)));
334
365
  } catch (err) {
335
366
  return diagnose(err);
336
367
  }
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.23.1'; // x-release-please-version
41
+ const VERSION = '2.24.0'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.