gogcli-mcp 4.2.5 → 4.4.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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +1340 -262
  4. package/dist/lib.js +1381 -260
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/dispatch-confirmation.ts +277 -0
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +70 -30
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +15 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +86 -18
  20. package/src/send-confirm-token.ts +167 -0
  21. package/src/tools/api.ts +60 -7
  22. package/src/tools/appscript.ts +12 -8
  23. package/src/tools/auth.ts +13 -3
  24. package/src/tools/calendar.ts +178 -15
  25. package/src/tools/chat.ts +94 -18
  26. package/src/tools/classroom.ts +110 -28
  27. package/src/tools/contacts.ts +5 -3
  28. package/src/tools/docs.ts +8 -6
  29. package/src/tools/drive.ts +96 -20
  30. package/src/tools/gmail.ts +175 -24
  31. package/src/tools/sheets.ts +10 -8
  32. package/src/tools/slides.ts +14 -9
  33. package/src/tools/tasks.ts +7 -5
  34. package/src/tools/utils.ts +52 -6
  35. package/tests/arg-guard.test.ts +80 -0
  36. package/tests/attachment-root.test.ts +130 -0
  37. package/tests/attachments.test.ts +8 -0
  38. package/tests/blob-upload.test.ts +4 -3
  39. package/tests/file-roots.test.ts +101 -0
  40. package/tests/gmail-dispatch-guard.test.ts +270 -11
  41. package/tests/gmail-results.test.ts +35 -1
  42. package/tests/run-path-guard.test.ts +142 -0
  43. package/tests/runner.test.ts +136 -0
  44. package/tests/send-confirm-token.test.ts +200 -0
  45. package/tests/tools/api.test.ts +74 -8
  46. package/tests/tools/appscript.test.ts +34 -8
  47. package/tests/tools/auth.test.ts +44 -17
  48. package/tests/tools/calendar.test.ts +33 -28
  49. package/tests/tools/chat.test.ts +50 -21
  50. package/tests/tools/classroom.test.ts +43 -39
  51. package/tests/tools/contacts.test.ts +3 -2
  52. package/tests/tools/dispatch-gates.test.ts +396 -0
  53. package/tests/tools/docs.test.ts +44 -15
  54. package/tests/tools/drive.test.ts +110 -20
  55. package/tests/tools/gmail-confirm-token.test.ts +274 -0
  56. package/tests/tools/gmail.test.ts +227 -29
  57. package/tests/tools/run-tool-examples.test.ts +69 -0
  58. package/tests/tools/run-vets.test.ts +131 -0
  59. package/tests/tools/sheets.test.ts +16 -15
  60. package/tests/tools/slides.test.ts +47 -11
  61. package/tests/tools/tasks.test.ts +7 -6
  62. package/tests/tools/utils.test.ts +32 -31
  63. package/vitest.config.ts +5 -0
@@ -1,6 +1,59 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
4
+ import { pos } from '../argv.js';
5
+ import type { GogArg } from '../runner.js';
6
+ import { bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, hasCommandWord, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
7
+
8
+ // gog's spellings (internal/cmd/classroom.go, classroom_announcements.go, classroom_invitations.go).
9
+ const CLASSROOM_CREATE_WORDS = new Set(['create', 'add', 'new']);
10
+ const CLASSROOM_GATED: Record<string, { does: string; tool: string }> = {
11
+ announcements: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
12
+ announcement: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
13
+ ann: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
14
+ invitations: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
15
+ invitation: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
16
+ invites: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
17
+ };
18
+
19
+ /** gog_classroom_run must not post or invite what the dedicated tools would ask about. */
20
+ export function vetClassroomRun(subcommand: string, args: readonly string[]): string | undefined {
21
+ const sub = subcommand.toLowerCase();
22
+ const gated = Object.hasOwn(CLASSROOM_GATED, sub) ? CLASSROOM_GATED[sub] : undefined;
23
+ if (!gated) return undefined;
24
+ const word = hasCommandWord(args, CLASSROOM_CREATE_WORDS);
25
+ return word ? gatedElsewhere(`gog classroom ${sub} ${word.toLowerCase()}`, 'gog_classroom_run', gated.does, gated.tool) : undefined;
26
+ }
27
+
28
+ /**
29
+ * The course a Classroom dispatch reaches, for its confirmation prompt: a user
30
+ * approving "post to 123456789" cannot tell which class that is. Read on every
31
+ * call, so it is also the token fallback's phase-2 re-read. Unreadable output
32
+ * names nothing rather than throwing.
33
+ */
34
+ export async function readCourse(
35
+ courseId: string,
36
+ account: string | undefined,
37
+ // A sub-package passes the runOrDiagnose it imported from lib.js, so its own
38
+ // tests' mock of that seam covers this read too.
39
+ runner: typeof runOrDiagnose = runOrDiagnose,
40
+ ) {
41
+ const got = await runner(['classroom', 'courses', 'get', pos(courseId)], { account });
42
+ if (got.isError) return { error: got };
43
+ let course: { name?: unknown; section?: unknown } | undefined;
44
+ try {
45
+ course = (JSON.parse(resultText(got)) as { course?: typeof course } | null)?.course;
46
+ } catch {
47
+ course = undefined;
48
+ }
49
+ return {
50
+ course: {
51
+ id: courseId,
52
+ ...(typeof course?.name === 'string' ? { name: course.name } : {}),
53
+ ...(typeof course?.section === 'string' ? { section: course.section } : {}),
54
+ },
55
+ };
56
+ }
4
57
 
5
58
  export function registerClassroomTools(server: McpServer): void {
6
59
  server.registerTool('gog_classroom_courses_list', {
@@ -17,7 +70,7 @@ export function registerClassroomTools(server: McpServer): void {
17
70
  account: accountParam,
18
71
  }),
19
72
  }, async ({ state, teacher, student, max, pageToken, page, all, account }) => {
20
- const args = ['classroom', 'courses', 'list'];
73
+ const args: GogArg[] = ['classroom', 'courses', 'list'];
21
74
  if (state) args.push(`--state=${state}`);
22
75
  if (teacher) args.push(`--teacher=${teacher}`);
23
76
  if (student) args.push(`--student=${student}`);
@@ -36,7 +89,7 @@ export function registerClassroomTools(server: McpServer): void {
36
89
  account: accountParam,
37
90
  }),
38
91
  }, async ({ courseId, account }) => {
39
- return runOrDiagnose(['classroom', 'courses', 'get', courseId], { account });
92
+ return runOrDiagnose(['classroom', 'courses', 'get', pos(courseId)], { account });
40
93
  });
41
94
 
42
95
  server.registerTool('gog_classroom_students_list', {
@@ -51,7 +104,7 @@ export function registerClassroomTools(server: McpServer): void {
51
104
  account: accountParam,
52
105
  }),
53
106
  }, async ({ courseId, max, pageToken, page, all, account }) => {
54
- const args = ['classroom', 'students', 'list', courseId];
107
+ const args: GogArg[] = ['classroom', 'students', 'list', pos(courseId)];
55
108
  if (max !== undefined) args.push(`--max=${max}`);
56
109
  const token = resolvePageToken({ pageToken, page });
57
110
  if (token) args.push(`--page=${token}`);
@@ -68,7 +121,7 @@ export function registerClassroomTools(server: McpServer): void {
68
121
  account: accountParam,
69
122
  }),
70
123
  }, async ({ courseId, userId, account }) => {
71
- return runOrDiagnose(['classroom', 'students', 'get', courseId, userId], { account });
124
+ return runOrDiagnose(['classroom', 'students', 'get', pos(courseId), pos(userId)], { account });
72
125
  });
73
126
 
74
127
  server.registerTool('gog_classroom_teachers_list', {
@@ -83,7 +136,7 @@ export function registerClassroomTools(server: McpServer): void {
83
136
  account: accountParam,
84
137
  }),
85
138
  }, async ({ courseId, max, pageToken, page, all, account }) => {
86
- const args = ['classroom', 'teachers', 'list', courseId];
139
+ const args: GogArg[] = ['classroom', 'teachers', 'list', pos(courseId)];
87
140
  if (max !== undefined) args.push(`--max=${max}`);
88
141
  const token = resolvePageToken({ pageToken, page });
89
142
  if (token) args.push(`--page=${token}`);
@@ -100,7 +153,7 @@ export function registerClassroomTools(server: McpServer): void {
100
153
  account: accountParam,
101
154
  }),
102
155
  }, async ({ courseId, userId, account }) => {
103
- return runOrDiagnose(['classroom', 'teachers', 'get', courseId, userId], { account });
156
+ return runOrDiagnose(['classroom', 'teachers', 'get', pos(courseId), pos(userId)], { account });
104
157
  });
105
158
 
106
159
  server.registerTool('gog_classroom_roster', {
@@ -117,7 +170,7 @@ export function registerClassroomTools(server: McpServer): void {
117
170
  account: accountParam,
118
171
  }),
119
172
  }, async ({ courseId, students, teachers, max, pageToken, page, all, account }) => {
120
- const args = ['classroom', 'roster', courseId];
173
+ const args: GogArg[] = ['classroom', 'roster', pos(courseId)];
121
174
  if (students) args.push('--students');
122
175
  if (teachers) args.push('--teachers');
123
176
  if (max !== undefined) args.push(`--max=${max}`);
@@ -143,7 +196,7 @@ export function registerClassroomTools(server: McpServer): void {
143
196
  account: accountParam,
144
197
  }),
145
198
  }, async ({ courseId, state, topic, orderBy, max, pageToken, page, all, scanPages, account }) => {
146
- const args = ['classroom', 'coursework', 'list', courseId];
199
+ const args: GogArg[] = ['classroom', 'coursework', 'list', pos(courseId)];
147
200
  if (state) args.push(`--state=${state}`);
148
201
  if (topic) args.push(`--topic=${topic}`);
149
202
  if (orderBy) args.push(`--order-by=${orderBy}`);
@@ -164,7 +217,7 @@ export function registerClassroomTools(server: McpServer): void {
164
217
  account: accountParam,
165
218
  }),
166
219
  }, async ({ courseId, courseworkId, account }) => {
167
- return runOrDiagnose(['classroom', 'coursework', 'get', courseId, courseworkId], { account });
220
+ return runOrDiagnose(['classroom', 'coursework', 'get', pos(courseId), pos(courseworkId)], { account });
168
221
  });
169
222
 
170
223
  server.registerTool('gog_classroom_submissions_list', {
@@ -183,7 +236,7 @@ export function registerClassroomTools(server: McpServer): void {
183
236
  account: accountParam,
184
237
  }),
185
238
  }, async ({ courseId, courseworkId, state, late, user, max, pageToken, page, all, account }) => {
186
- const args = ['classroom', 'submissions', 'list', courseId, courseworkId];
239
+ const args: GogArg[] = ['classroom', 'submissions', 'list', pos(courseId), pos(courseworkId)];
187
240
  if (state) args.push(`--state=${state}`);
188
241
  if (late) args.push(`--late=${late}`);
189
242
  if (user) args.push(`--user=${user}`);
@@ -204,7 +257,7 @@ export function registerClassroomTools(server: McpServer): void {
204
257
  account: accountParam,
205
258
  }),
206
259
  }, async ({ courseId, courseworkId, submissionId, account }) => {
207
- return runOrDiagnose(['classroom', 'submissions', 'get', courseId, courseworkId, submissionId], { account });
260
+ return runOrDiagnose(['classroom', 'submissions', 'get', pos(courseId), pos(courseworkId), pos(submissionId)], { account });
208
261
  });
209
262
 
210
263
  server.registerTool('gog_classroom_submissions_grade', {
@@ -219,7 +272,7 @@ export function registerClassroomTools(server: McpServer): void {
219
272
  account: accountParam,
220
273
  }),
221
274
  }, async ({ courseId, courseworkId, submissionId, draft, assigned, account }) => {
222
- const args = ['classroom', 'submissions', 'grade', courseId, courseworkId, submissionId];
275
+ const args: GogArg[] = ['classroom', 'submissions', 'grade', pos(courseId), pos(courseworkId), pos(submissionId)];
223
276
  if (draft) args.push(`--draft=${draft}`);
224
277
  if (assigned) args.push(`--assigned=${assigned}`);
225
278
  return runOrDiagnose(args, { account });
@@ -235,7 +288,7 @@ export function registerClassroomTools(server: McpServer): void {
235
288
  account: accountParam,
236
289
  }),
237
290
  }, async ({ courseId, courseworkId, submissionId, account }) => {
238
- return runOrDiagnose(['classroom', 'submissions', 'return', courseId, courseworkId, submissionId], { account });
291
+ return runOrDiagnose(['classroom', 'submissions', 'return', pos(courseId), pos(courseworkId), pos(submissionId)], { account });
239
292
  });
240
293
 
241
294
  server.registerTool('gog_classroom_submissions_turn_in', {
@@ -248,7 +301,7 @@ export function registerClassroomTools(server: McpServer): void {
248
301
  account: accountParam,
249
302
  }),
250
303
  }, async ({ courseId, courseworkId, submissionId, account }) => {
251
- return runOrDiagnose(['classroom', 'submissions', 'turn-in', courseId, courseworkId, submissionId], { account });
304
+ return runOrDiagnose(['classroom', 'submissions', 'turn-in', pos(courseId), pos(courseworkId), pos(submissionId)], { account });
252
305
  });
253
306
 
254
307
  server.registerTool('gog_classroom_submissions_reclaim', {
@@ -261,7 +314,7 @@ export function registerClassroomTools(server: McpServer): void {
261
314
  account: accountParam,
262
315
  }),
263
316
  }, async ({ courseId, courseworkId, submissionId, account }) => {
264
- return runOrDiagnose(['classroom', 'submissions', 'reclaim', courseId, courseworkId, submissionId], { account });
317
+ return runOrDiagnose(['classroom', 'submissions', 'reclaim', pos(courseId), pos(courseworkId), pos(submissionId)], { account });
265
318
  });
266
319
 
267
320
  server.registerTool('gog_classroom_announcements_list', {
@@ -278,7 +331,7 @@ export function registerClassroomTools(server: McpServer): void {
278
331
  account: accountParam,
279
332
  }),
280
333
  }, async ({ courseId, state, orderBy, max, pageToken, page, all, account }) => {
281
- const args = ['classroom', 'announcements', 'list', courseId];
334
+ const args: GogArg[] = ['classroom', 'announcements', 'list', pos(courseId)];
282
335
  if (state) args.push(`--state=${state}`);
283
336
  if (orderBy) args.push(`--order-by=${orderBy}`);
284
337
  if (max !== undefined) args.push(`--max=${max}`);
@@ -297,23 +350,51 @@ export function registerClassroomTools(server: McpServer): void {
297
350
  account: accountParam,
298
351
  }),
299
352
  }, async ({ courseId, announcementId, account }) => {
300
- return runOrDiagnose(['classroom', 'announcements', 'get', courseId, announcementId], { account });
353
+ return runOrDiagnose(['classroom', 'announcements', 'get', pos(courseId), pos(announcementId)], { account });
301
354
  });
302
355
 
303
356
  server.registerTool('gog_classroom_announcements_create', {
304
- description: 'Create an announcement in a Google Classroom course.',
357
+ description: 'Create an announcement in a Google Classroom course. Unless state is DRAFT (which students cannot '
358
+ + 'see), this reads the course and asks the MCP host to show the user a confirmation prompt with the class, the '
359
+ + 'full text and when it publishes; nothing is posted unless they accept. To stage one without asking, pass '
360
+ + 'state DRAFT.' + CONFIRM_FALLBACK_DESCRIPTION,
305
361
  annotations: { destructiveHint: true },
306
362
  inputSchema: z.object({
307
363
  courseId: z.string().describe('Course ID'),
308
364
  text: z.string().describe('Announcement text'),
309
- state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('State'),
365
+ state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('State (DRAFT is visible only to teachers and needs no confirmation)'),
310
366
  scheduled: z.string().optional().describe('Scheduled publish time'),
311
367
  account: accountParam,
368
+ confirmToken: confirmTokenParam,
312
369
  }),
313
- }, async ({ courseId, text, state, scheduled, account }) => {
314
- const args = ['classroom', 'announcements', 'create', courseId, `--text=${text}`];
370
+ }, async ({ courseId, text, state, scheduled, account, confirmToken }, ctx) => {
371
+ const args: GogArg[] = ['classroom', 'announcements', 'create', pos(courseId), `--text=${text}`];
315
372
  if (state) args.push(`--state=${state}`);
316
373
  if (scheduled) args.push(`--scheduled=${scheduled}`);
374
+ // A draft reaches nobody until a teacher publishes it: it is this tool's
375
+ // own staging twin, so it needs no confirmation.
376
+ if (state !== 'DRAFT') {
377
+ const read = await readCourse(courseId, account);
378
+ if (read.error) return read.error;
379
+ const publishes = scheduled ? `at ${scheduled}` : 'immediately';
380
+ const confirmation = await requireDispatchConfirmation(ctx, {
381
+ action: 'classroom.announcement-create',
382
+ message: 'Review and confirm this Classroom announcement:',
383
+ confirmationLabel: 'Confirm that this announcement should be posted to the class.',
384
+ details: { course: read.course, publishes, textPreview: bodyPreview(text) },
385
+ unsupportedNote: 'Create it with state DRAFT instead; the user can review and post it from Classroom.',
386
+ fallback: {
387
+ tool: 'gog_classroom_announcements_create',
388
+ account,
389
+ confirmToken,
390
+ subject: () => {
391
+ const view = { course: read.course, publishes, text };
392
+ return { target: courseId, payload: view, preview: view };
393
+ },
394
+ },
395
+ });
396
+ if (confirmation) return confirmation;
397
+ }
317
398
  return runOrDiagnose(args, { account });
318
399
  });
319
400
 
@@ -329,7 +410,7 @@ export function registerClassroomTools(server: McpServer): void {
329
410
  account: accountParam,
330
411
  }),
331
412
  }, async ({ courseId, max, pageToken, page, all, account }) => {
332
- const args = ['classroom', 'topics', 'list', courseId];
413
+ const args: GogArg[] = ['classroom', 'topics', 'list', pos(courseId)];
333
414
  if (max !== undefined) args.push(`--max=${max}`);
334
415
  const token = resolvePageToken({ pageToken, page });
335
416
  if (token) args.push(`--page=${token}`);
@@ -346,7 +427,7 @@ export function registerClassroomTools(server: McpServer): void {
346
427
  account: accountParam,
347
428
  }),
348
429
  }, async ({ courseId, topicId, account }) => {
349
- return runOrDiagnose(['classroom', 'topics', 'get', courseId, topicId], { account });
430
+ return runOrDiagnose(['classroom', 'topics', 'get', pos(courseId), pos(topicId)], { account });
350
431
  });
351
432
 
352
433
  server.registerTool('gog_classroom_invitations_list', {
@@ -362,7 +443,7 @@ export function registerClassroomTools(server: McpServer): void {
362
443
  account: accountParam,
363
444
  }),
364
445
  }, async ({ course, user, max, pageToken, page, all, account }) => {
365
- const args = ['classroom', 'invitations', 'list'];
446
+ const args: GogArg[] = ['classroom', 'invitations', 'list'];
366
447
  if (course) args.push(`--course=${course}`);
367
448
  if (user) args.push(`--user=${user}`);
368
449
  if (max !== undefined) args.push(`--max=${max}`);
@@ -380,7 +461,7 @@ export function registerClassroomTools(server: McpServer): void {
380
461
  account: accountParam,
381
462
  }),
382
463
  }, async ({ invitationId, account }) => {
383
- return runOrDiagnose(['classroom', 'invitations', 'get', invitationId], { account });
464
+ return runOrDiagnose(['classroom', 'invitations', 'get', pos(invitationId)], { account });
384
465
  });
385
466
 
386
467
  server.registerTool('gog_classroom_invitations_accept', {
@@ -391,7 +472,7 @@ export function registerClassroomTools(server: McpServer): void {
391
472
  account: accountParam,
392
473
  }),
393
474
  }, async ({ invitationId, account }) => {
394
- return runOrDiagnose(['classroom', 'invitations', 'accept', invitationId], { account });
475
+ return runOrDiagnose(['classroom', 'invitations', 'accept', pos(invitationId)], { account });
395
476
  });
396
477
 
397
478
  server.registerTool('gog_classroom_profile_get', {
@@ -402,14 +483,15 @@ export function registerClassroomTools(server: McpServer): void {
402
483
  account: accountParam,
403
484
  }),
404
485
  }, async ({ userId, account }) => {
405
- const args = ['classroom', 'profile', 'get'];
406
- if (userId) args.push(userId);
486
+ const args: GogArg[] = ['classroom', 'profile', 'get'];
487
+ if (userId) args.push(pos(userId));
407
488
  return runOrDiagnose(args, { account });
408
489
  });
409
490
 
410
491
  registerRunTool(server, {
411
492
  service: 'classroom',
412
493
  examples: '"guardians", "materials", "guardian-invitations"',
494
+ vet: vetClassroomRun,
413
495
  note: 'Covers anything not wrapped by the dedicated tools (guardians, guardian-invitations, materials, coursework assignees, announcement assignees, etc.).',
414
496
  });
415
497
  }
@@ -1,6 +1,8 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
+ import { pos } from '../argv.js';
5
+ import type { GogArg } from '../runner.js';
4
6
 
5
7
  export function registerContactsTools(server: McpServer): void {
6
8
  server.registerTool('gog_contacts_search', {
@@ -11,7 +13,7 @@ export function registerContactsTools(server: McpServer): void {
11
13
  account: accountParam,
12
14
  }),
13
15
  }, async ({ query, account }) => {
14
- return runOrDiagnose(['contacts', 'search', query], { account });
16
+ return runOrDiagnose(['contacts', 'search', pos(query)], { account });
15
17
  });
16
18
 
17
19
  server.registerTool('gog_contacts_list', {
@@ -32,7 +34,7 @@ export function registerContactsTools(server: McpServer): void {
32
34
  account: accountParam,
33
35
  }),
34
36
  }, async ({ resourceName, account }) => {
35
- return runOrDiagnose(['contacts', 'get', resourceName], { account });
37
+ return runOrDiagnose(['contacts', 'get', pos(resourceName)], { account });
36
38
  });
37
39
 
38
40
  server.registerTool('gog_contacts_create', {
@@ -48,7 +50,7 @@ export function registerContactsTools(server: McpServer): void {
48
50
  account: accountParam,
49
51
  }),
50
52
  }, async ({ givenName, familyName, email, phone, org, title, account }) => {
51
- const args = ['contacts', 'create', `--given=${givenName}`];
53
+ const args: GogArg[] = ['contacts', 'create', `--given=${givenName}`];
52
54
  if (familyName) args.push(`--family=${familyName}`);
53
55
  if (email) args.push(`--email=${email}`);
54
56
  if (phone) args.push(`--phone=${phone}`);
package/src/tools/docs.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
+ import { pos } from '../argv.js';
5
+ import type { GogArg } from '../runner.js';
4
6
 
5
7
  export function registerDocsTools(server: McpServer): void {
6
8
  server.registerTool('gog_docs_info', {
@@ -11,7 +13,7 @@ export function registerDocsTools(server: McpServer): void {
11
13
  account: accountParam,
12
14
  }),
13
15
  }, async ({ docId, account }) => {
14
- return runOrDiagnose(['docs', 'info', docId], { account });
16
+ return runOrDiagnose(['docs', 'info', pos(docId)], { account });
15
17
  });
16
18
 
17
19
  server.registerTool('gog_docs_cat', {
@@ -23,7 +25,7 @@ export function registerDocsTools(server: McpServer): void {
23
25
  account: accountParam,
24
26
  }),
25
27
  }, async ({ docId, chips, account }) => {
26
- const args = ['docs', 'cat', docId];
28
+ const args: GogArg[] = ['docs', 'cat', pos(docId)];
27
29
  if (chips) args.push('--chips');
28
30
  return runOrDiagnose(args, { account });
29
31
  });
@@ -36,7 +38,7 @@ export function registerDocsTools(server: McpServer): void {
36
38
  account: accountParam,
37
39
  }),
38
40
  }, async ({ title, account }) => {
39
- return runOrDiagnose(['docs', 'create', title], { account });
41
+ return runOrDiagnose(['docs', 'create', pos(title)], { account });
40
42
  });
41
43
 
42
44
  server.registerTool('gog_docs_write', {
@@ -62,7 +64,7 @@ export function registerDocsTools(server: McpServer): void {
62
64
  account: accountParam,
63
65
  }),
64
66
  }, async ({ docId, text, append, checkOrphans, bullets, bulletPreset, ordered, noBullets, indentStart, indentEnd, indentFirstLine, spaceAbove, spaceBelow, keepLinesTogether, keepWithNext, batch, account }) => {
65
- const args = ['docs', 'write', docId, `--text=${text}`];
67
+ const args: GogArg[] = ['docs', 'write', pos(docId), `--text=${text}`];
66
68
  if (append) args.push('--append');
67
69
  if (checkOrphans) args.push('--check-orphans');
68
70
  if (bullets) args.push('--bullets');
@@ -90,7 +92,7 @@ export function registerDocsTools(server: McpServer): void {
90
92
  account: accountParam,
91
93
  }),
92
94
  }, async ({ docId, find, replace, account }) => {
93
- return runOrDiagnose(['docs', 'find-replace', docId, find, replace], { account });
95
+ return runOrDiagnose(['docs', 'find-replace', pos(docId), pos(find), pos(replace)], { account });
94
96
  });
95
97
 
96
98
  server.registerTool('gog_docs_structure', {
@@ -101,7 +103,7 @@ export function registerDocsTools(server: McpServer): void {
101
103
  account: accountParam,
102
104
  }),
103
105
  }, async ({ docId, account }) => {
104
- return runOrDiagnose(['docs', 'structure', docId], { account });
106
+ return runOrDiagnose(['docs', 'structure', pos(docId)], { account });
105
107
  });
106
108
 
107
109
  registerRunTool(server, { service: 'docs', examples: '"copy", "clear", "insert", "sed", "export"' });
@@ -1,10 +1,21 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import type { CallToolResult } from '@modelcontextprotocol/server';
3
3
  import { z } from 'zod';
4
- import { rawTextResult, viewParam, resolveView } from '@chrischall/mcp-utils';
4
+ import { errorResult, rawTextResult, viewParam, resolveView } from '@chrischall/mcp-utils';
5
+ import { MAX_INLINE_ATTACHMENT_BYTES } from '../attachments.js';
5
6
 
6
7
  import { run, runBinary } from '../runner.js';
7
8
  import { accountParam, diagnose, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
9
+ import { pos } from '../argv.js';
10
+ import type { GogArg } from '../runner.js';
11
+ import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
12
+
13
+ /** gog_drive_run must not grant access that gog_drive_share would ask about. */
14
+ export function vetDriveRun(subcommand: string, _args: readonly string[]): string | undefined {
15
+ return subcommand.toLowerCase() === 'share'
16
+ ? gatedElsewhere('gog drive share', 'gog_drive_run', 'grants access to a file', 'gog_drive_share')
17
+ : undefined;
18
+ }
8
19
 
9
20
  // A native Google Doc exports to text directly; anything else (PDF, image,
10
21
  // docx, …) is first copied WITH conversion to this type, which makes Drive run
@@ -13,12 +24,31 @@ const GOOGLE_DOC_MIME = 'application/vnd.google-apps.document';
13
24
 
14
25
  // Parse `gog drive get` JSON into { name, mimeType }. gog nests the payload
15
26
  // under `file`; fall back to the top level if that ever changes.
16
- function fileMeta(raw: string): { name?: string; mimeType?: string } {
17
- const parsed = JSON.parse(raw) as { file?: { name?: string; mimeType?: string }; name?: string; mimeType?: string };
27
+ type DriveMeta = { name?: string; mimeType?: string; size?: string | number };
28
+
29
+ /** fileMeta for a preview: unreadable output names nothing rather than throwing. */
30
+ export function shareTargetMeta(raw: string): { name?: string; mimeType?: string } {
31
+ try {
32
+ const { name, mimeType } = fileMeta(raw);
33
+ return { name, mimeType };
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function fileMeta(raw: string): { name?: string; mimeType?: string; size?: number } {
40
+ const parsed = JSON.parse(raw) as { file?: DriveMeta } & DriveMeta;
18
41
  const f = parsed.file ?? parsed;
19
- return { name: f.name, mimeType: f.mimeType };
42
+ // Drive reports int64 fields as strings; native Google formats carry none.
43
+ const size = f.size === undefined ? undefined : Number(f.size);
44
+ return { name: f.name, mimeType: f.mimeType, size: Number.isFinite(size) ? size : undefined };
20
45
  }
21
46
 
47
+ // The most gog_drive_read_bytes returns inline — the same 8 MiB per-file cap
48
+ // the attachment path enforces. The bytes travel base64-encoded (4/3 larger)
49
+ // inside one JSON-RPC message, and past this no MCP client accepts the result.
50
+ export const MAX_DRIVE_READ_BYTES = MAX_INLINE_ATTACHMENT_BYTES;
51
+
22
52
 
23
53
  // The `compact` rung for gog_drive_ls, as a Google Drive field mask.
24
54
  //
@@ -52,7 +82,7 @@ export function registerDriveTools(server: McpServer): void {
52
82
  account: accountParam,
53
83
  }),
54
84
  }, async ({ folderId, max, pageToken, page, query, allDrives, view, account }) => {
55
- const args = ['drive', 'ls'];
85
+ const args: GogArg[] = ['drive', 'ls'];
56
86
  if (folderId) args.push(`--parent=${folderId}`);
57
87
  if (max !== undefined) args.push(`--max=${max}`);
58
88
  const token = resolvePageToken({ pageToken, page });
@@ -78,7 +108,7 @@ export function registerDriveTools(server: McpServer): void {
78
108
  account: accountParam,
79
109
  }),
80
110
  }, async ({ query, view, account }) => {
81
- return runOrDiagnose(['drive', 'search', query], {
111
+ return runOrDiagnose(['drive', 'search', pos(query)], {
82
112
  account,
83
113
  stripMedia: resolveView(view, ['compact', 'full']) === 'compact',
84
114
  });
@@ -99,7 +129,7 @@ export function registerDriveTools(server: McpServer): void {
99
129
  account: accountParam,
100
130
  }),
101
131
  }, async ({ fileId, view, account }) => {
102
- return runOrDiagnose(['drive', 'get', fileId], {
132
+ return runOrDiagnose(['drive', 'get', pos(fileId)], {
103
133
  account,
104
134
  stripMedia: resolveView(view, ['compact', 'full']) === 'compact',
105
135
  });
@@ -113,7 +143,7 @@ export function registerDriveTools(server: McpServer): void {
113
143
  account: accountParam,
114
144
  }),
115
145
  }, async ({ name, account }) => {
116
- return runOrDiagnose(['drive', 'mkdir', name], { account });
146
+ return runOrDiagnose(['drive', 'mkdir', pos(name)], { account });
117
147
  });
118
148
 
119
149
  server.registerTool('gog_drive_rename', {
@@ -125,7 +155,7 @@ export function registerDriveTools(server: McpServer): void {
125
155
  account: accountParam,
126
156
  }),
127
157
  }, async ({ fileId, newName, account }) => {
128
- return runOrDiagnose(['drive', 'rename', fileId, newName], { account });
158
+ return runOrDiagnose(['drive', 'rename', pos(fileId), pos(newName)], { account });
129
159
  });
130
160
 
131
161
  server.registerTool('gog_drive_move', {
@@ -137,7 +167,7 @@ export function registerDriveTools(server: McpServer): void {
137
167
  account: accountParam,
138
168
  }),
139
169
  }, async ({ fileId, parentId, account }) => {
140
- return runOrDiagnose(['drive', 'move', fileId, `--parent=${parentId}`], { account });
170
+ return runOrDiagnose(['drive', 'move', pos(fileId), `--parent=${parentId}`], { account });
141
171
  });
142
172
 
143
173
  server.registerTool('gog_drive_delete', {
@@ -149,7 +179,7 @@ export function registerDriveTools(server: McpServer): void {
149
179
  account: accountParam,
150
180
  }),
151
181
  }, async ({ fileId, permanent, account }) => {
152
- const args = ['drive', 'delete', fileId];
182
+ const args: GogArg[] = ['drive', 'delete', pos(fileId)];
153
183
  if (permanent) args.push('--permanent');
154
184
  // gog gates drive delete behind a confirmation; the runner injects
155
185
  // --no-input, so without --force it refuses at runtime.
@@ -158,7 +188,10 @@ export function registerDriveTools(server: McpServer): void {
158
188
  });
159
189
 
160
190
  server.registerTool('gog_drive_share', {
161
- description: 'Share a Google Drive file or folder.',
191
+ description: 'Share a Google Drive file or folder. Granting access is the risk even though no email is sent (gog '
192
+ + 'does not notify by default), so this reads the file and asks the MCP host to show the user a confirmation '
193
+ + 'prompt naming it, who gets access and with what role; nothing is shared unless they accept.'
194
+ + CONFIRM_FALLBACK_DESCRIPTION,
162
195
  annotations: { destructiveHint: true },
163
196
  inputSchema: z.object({
164
197
  fileId: z.string().describe('File or folder ID'),
@@ -167,9 +200,40 @@ export function registerDriveTools(server: McpServer): void {
167
200
  domain: z.string().optional().describe('Domain (required when to=domain)'),
168
201
  role: z.enum(['reader', 'writer']).optional().describe('Permission role (default: reader)'),
169
202
  account: accountParam,
203
+ confirmToken: confirmTokenParam,
170
204
  }),
171
- }, async ({ fileId, to, email, domain, role, account }) => {
172
- const args = ['drive', 'share', fileId, `--to=${to}`];
205
+ }, async ({ fileId, to, email, domain, role, account, confirmToken }, ctx) => {
206
+ // Read first, on every call: a prompt that shows only an opaque id asks the
207
+ // user to approve something they cannot recognise, and on the token
208
+ // fallback's phase 2 this is the re-read the token is checked against.
209
+ const got = await runOrDiagnose(['drive', 'get', pos(fileId)], { account });
210
+ if (got.isError) return got;
211
+ const file = { id: fileId, ...shareTargetMeta(resultText(got)) };
212
+ const grant = {
213
+ file,
214
+ to,
215
+ ...(email ? { email } : {}),
216
+ ...(domain ? { domain } : {}),
217
+ role: role ?? 'reader',
218
+ publicLink: to === 'anyone',
219
+ };
220
+ const confirmation = await requireDispatchConfirmation(ctx, {
221
+ action: 'drive.share',
222
+ message: to === 'anyone'
223
+ ? 'Review and confirm making this file available to ANYONE with the link:'
224
+ : 'Review and confirm this Drive share:',
225
+ confirmationLabel: 'Confirm that this access should be granted now.',
226
+ details: grant,
227
+ unsupportedNote: 'Ask the user to share it themselves from Google Drive.',
228
+ fallback: {
229
+ tool: 'gog_drive_share',
230
+ account,
231
+ confirmToken,
232
+ subject: () => ({ target: fileId, payload: grant, preview: grant }),
233
+ },
234
+ });
235
+ if (confirmation) return confirmation;
236
+ const args: GogArg[] = ['drive', 'share', pos(fileId), `--to=${to}`];
173
237
  if (email) args.push(`--email=${email}`);
174
238
  if (domain) args.push(`--domain=${domain}`);
175
239
  if (role) args.push(`--role=${role}`);
@@ -201,7 +265,7 @@ export function registerDriveTools(server: McpServer): void {
201
265
  }, async ({ fileId, ocrLanguage, offset = 0, maxChars, account }) => {
202
266
  let tempDocId: string | undefined;
203
267
  try {
204
- const { name, mimeType } = fileMeta(await run(['drive', 'get', fileId], { account }));
268
+ const { name, mimeType } = fileMeta(await run(['drive', 'get', pos(fileId)], { account }));
205
269
 
206
270
  // Native Docs export straight to text; everything else is OCR-converted first.
207
271
  let sourceId = fileId;
@@ -242,7 +306,7 @@ export function registerDriveTools(server: McpServer): void {
242
306
  } finally {
243
307
  // Always remove the temp Doc — on success, and on a mid-extraction failure.
244
308
  if (tempDocId) {
245
- await run(['drive', 'delete', tempDocId, '--permanent', '--force'], { account }).catch(() => {});
309
+ await run(['drive', 'delete', pos(tempDocId), '--permanent', '--force'], { account }).catch(() => {});
246
310
  }
247
311
  }
248
312
  });
@@ -251,7 +315,7 @@ export function registerDriveTools(server: McpServer): void {
251
315
  description:
252
316
  'Fetch a Drive file\'s raw bytes and return them base64-encoded as an embedded resource — the ' +
253
317
  'generic fallback for callers that want the file itself (to parse locally) rather than extracted ' +
254
- 'text. For readable text from a PDF, prefer gog_drive_extract_text.',
318
+ 'text. Files over 8 MiB are refused (checked before downloading). For readable text from a PDF, prefer gog_drive_extract_text.',
255
319
  annotations: { readOnlyHint: true },
256
320
  inputSchema: z.object({
257
321
  fileId: z.string().describe('Drive file ID'),
@@ -259,9 +323,21 @@ export function registerDriveTools(server: McpServer): void {
259
323
  }),
260
324
  }, async ({ fileId, account }): Promise<CallToolResult> => {
261
325
  try {
262
- const { name, mimeType } = fileMeta(await run(['drive', 'get', fileId], { account }));
326
+ const { name, mimeType, size } = fileMeta(await run(['drive', 'get', pos(fileId)], { account }));
327
+ // Refuse an oversized file BEFORE fetching it (audit BUG-2): buffering a
328
+ // multi-GB file plus its base64 copy can take down the whole server.
329
+ if (size !== undefined && size > MAX_DRIVE_READ_BYTES) {
330
+ return errorResult(
331
+ `${name ?? fileId} is ${size} bytes, over the 8 MiB this tool returns inline. `
332
+ + 'Use gog_drive_extract_text for its text, or share its webViewLink (gog_drive_get) instead.',
333
+ );
334
+ }
263
335
  const params = JSON.stringify({ fileId, alt: 'media' });
264
- const blob = await runBinary(['api', 'call', 'drive', 'v3', 'files.get', `--params=${params}`], { account });
336
+ // The cap also bounds the fetch itself, for files Drive reports no size for.
337
+ const blob = await runBinary(
338
+ ['api', 'call', 'drive', 'v3', 'files.get', `--params=${params}`],
339
+ { account, maxOutputBytes: MAX_DRIVE_READ_BYTES },
340
+ );
265
341
  const type = mimeType ?? 'application/octet-stream';
266
342
  // Several hosts (claude.ai among them) render embedded IMAGE resources
267
343
  // and reject every other type outright — "Resources of type
@@ -290,5 +366,5 @@ export function registerDriveTools(server: McpServer): void {
290
366
  }
291
367
  });
292
368
 
293
- registerRunTool(server, { service: 'drive', examples: '"copy", "upload", "download", "permissions"' });
369
+ registerRunTool(server, { service: 'drive', examples: '"copy", "download", "permissions"', vet: vetDriveRun });
294
370
  }