gogcli-mcp 4.5.0 → 4.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +659 -162
- package/dist/lib.js +619 -142
- package/manifest.json +7 -7
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +63 -3
- package/src/gmail-dispatch-guard.ts +19 -12
- package/src/lib.ts +6 -2
- package/src/tools/api.ts +74 -8
- package/src/tools/appscript.ts +63 -2
- package/src/tools/calendar.ts +83 -13
- package/src/tools/chat.ts +35 -4
- package/src/tools/classroom.ts +254 -25
- package/src/tools/docs.ts +7 -1
- package/src/tools/drive.ts +82 -10
- package/src/tools/gmail.ts +37 -1
- package/src/tools/sheets.ts +16 -1
- package/src/tools/utils.ts +10 -4
- package/tests/gmail-dispatch-guard.test.ts +24 -8
- package/tests/tools/api.test.ts +130 -5
- package/tests/tools/appscript.test.ts +6 -2
- package/tests/tools/dispatch-gates-more.test.ts +447 -0
- package/tests/tools/dispatch-gates.test.ts +52 -1
- package/tests/tools/docs.test.ts +5 -6
- package/tests/tools/drive.test.ts +2 -5
- package/tests/tools/run-vets-more.test.ts +269 -0
- package/tests/tools/run-vets.test.ts +158 -4
- package/tests/tools/sheets.test.ts +10 -11
- package/tests/tools/utils.test.ts +38 -41
package/src/tools/chat.ts
CHANGED
|
@@ -27,9 +27,20 @@ import {
|
|
|
27
27
|
// gog's spellings of `send` under `messages` and `dm` (internal/cmd/chat_messages.go, chat_dm.go).
|
|
28
28
|
const CHAT_SEND_WORDS = new Set(['send', 'create', 'post']);
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// gog's spellings of `spaces create` (`gog schema` 0.41.0).
|
|
31
|
+
const CHAT_SPACE_CREATE_WORDS = new Set(['create', 'add', 'new']);
|
|
32
|
+
|
|
33
|
+
/** gog_chat_run must not post, or add people to a space, where the dedicated tools would ask. */
|
|
31
34
|
export function vetChatRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
32
35
|
const sub = subcommand.toLowerCase();
|
|
36
|
+
if (sub === 'spaces') {
|
|
37
|
+
// A member-less space reaches nobody; one seeded with --member adds and notifies them.
|
|
38
|
+
const word = hasCommandWord(args, CHAT_SPACE_CREATE_WORDS);
|
|
39
|
+
const withMembers = args.some((a) => /^--members?(?:=|$)/i.test(a));
|
|
40
|
+
return word && withMembers
|
|
41
|
+
? gatedElsewhere(`gog chat spaces ${word.toLowerCase()} --member`, 'gog_chat_run', 'adds people to a space', 'gog_chat_spaces_create')
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
33
44
|
if (sub !== 'messages' && sub !== 'dm') return undefined;
|
|
34
45
|
const word = hasCommandWord(args, CHAT_SEND_WORDS);
|
|
35
46
|
if (!word) return undefined;
|
|
@@ -114,17 +125,37 @@ export function registerChatTools(server: McpServer): void {
|
|
|
114
125
|
|
|
115
126
|
server.registerTool('gog_chat_spaces_create', {
|
|
116
127
|
description:
|
|
117
|
-
'Create a named Chat space, optionally seeding its membership. Members are added immediately and are notified
|
|
118
|
-
+ '
|
|
128
|
+
'Create a named Chat space, optionally seeding its membership. Members are added immediately and are notified, so '
|
|
129
|
+
+ 'with members this asks the MCP host to show the user a confirmation prompt with the space name and every member '
|
|
130
|
+
+ 'first; nothing is created unless they accept. A space with no members reaches nobody and is created without '
|
|
131
|
+
+ 'asking.' + CONFIRM_FALLBACK_DESCRIPTION + workspaceOnlyNote,
|
|
119
132
|
annotations: { destructiveHint: true },
|
|
120
133
|
inputSchema: z.object({
|
|
121
134
|
displayName: z.string().describe('Display name for the new space'),
|
|
122
135
|
members: z.array(z.string()).optional().describe('Initial members, as email addresses or "users/..." resource names'),
|
|
123
136
|
account: accountParam,
|
|
137
|
+
confirmToken: confirmTokenParam,
|
|
124
138
|
}),
|
|
125
|
-
}, async ({ displayName, members, account }) => {
|
|
139
|
+
}, async ({ displayName, members, account, confirmToken }, ctx) => {
|
|
126
140
|
const args: GogArg[] = ['chat', 'spaces', 'create', pos(displayName)];
|
|
127
141
|
if (members) for (const member of members) args.push(`--member=${member}`);
|
|
142
|
+
if (members?.length) {
|
|
143
|
+
const space = { displayName, members };
|
|
144
|
+
const confirmation = await requireDispatchConfirmation(ctx, {
|
|
145
|
+
action: 'chat.space-create',
|
|
146
|
+
message: 'Review and confirm this Chat space — every member is added and notified:',
|
|
147
|
+
confirmationLabel: 'Confirm that these people should be added to a new space now.',
|
|
148
|
+
details: space,
|
|
149
|
+
unsupportedNote: 'Create the space without members instead; the user can add people from Google Chat.',
|
|
150
|
+
fallback: {
|
|
151
|
+
tool: 'gog_chat_spaces_create',
|
|
152
|
+
account,
|
|
153
|
+
confirmToken,
|
|
154
|
+
subject: () => ({ target: displayName, payload: space, preview: space }),
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
if (confirmation) return confirmation;
|
|
158
|
+
}
|
|
128
159
|
return runOrDiagnose(args, { account });
|
|
129
160
|
});
|
|
130
161
|
|
package/src/tools/classroom.ts
CHANGED
|
@@ -3,26 +3,105 @@ import { z } from 'zod';
|
|
|
3
3
|
import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
|
|
4
4
|
import { pos } from '../argv.js';
|
|
5
5
|
import type { GogArg } from '../runner.js';
|
|
6
|
-
import { bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, hasCommandWord, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
|
|
6
|
+
import { bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, flagValue, gatedElsewhere, hasCommandWord, refusedInRun, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
|
|
7
7
|
|
|
8
|
-
// gog's spellings (internal/cmd/classroom
|
|
8
|
+
// gog's spellings (internal/cmd/classroom*.go; aliases per `gog schema` 0.41.0),
|
|
9
|
+
// each alias mapped to the resource it names.
|
|
9
10
|
const CLASSROOM_CREATE_WORDS = new Set(['create', 'add', 'new']);
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
const CLASSROOM_DELETE_WORDS = new Set(['delete', 'rm', 'del', 'remove']);
|
|
12
|
+
const CLASSROOM_UPDATE_WORDS = new Set(['update', 'edit', 'set']);
|
|
13
|
+
const CLASSROOM_RETURN_WORDS = new Set(['return', 'send']);
|
|
14
|
+
const CLASSROOM_ASSIGNEE_WORDS = new Set(['assignees', 'assign']);
|
|
15
|
+
type ClassroomGroup = 'courses' | 'announcements' | 'invitations' | 'coursework' | 'materials' | 'students' | 'teachers' | 'submissions' | 'guardian-invitations';
|
|
16
|
+
const CLASSROOM_GROUPS: Record<string, ClassroomGroup> = {
|
|
17
|
+
courses: 'courses', course: 'courses',
|
|
18
|
+
announcements: 'announcements', announcement: 'announcements', ann: 'announcements',
|
|
19
|
+
invitations: 'invitations', invitation: 'invitations', invites: 'invitations',
|
|
20
|
+
coursework: 'coursework', work: 'coursework',
|
|
21
|
+
materials: 'materials', material: 'materials',
|
|
22
|
+
students: 'students', student: 'students',
|
|
23
|
+
teachers: 'teachers', teacher: 'teachers',
|
|
24
|
+
submissions: 'submissions', submission: 'submissions',
|
|
25
|
+
'guardian-invitations': 'guardian-invitations', 'guardian-invites': 'guardian-invitations',
|
|
17
26
|
};
|
|
18
27
|
|
|
19
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* True when forwarded flags publish existing work to students: state
|
|
30
|
+
* PUBLISHED publishes now; a schedule publishes then (a scheduled item is a
|
|
31
|
+
* draft that publishes itself). A text edit or a return to DRAFT is neither.
|
|
32
|
+
*/
|
|
33
|
+
function publishesToStudents(args: readonly string[]): boolean {
|
|
34
|
+
return flagValue(args, 'state')?.toUpperCase() === 'PUBLISHED' || flagValue(args, 'scheduled') !== undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when a create reaches students: anything but an unscheduled DRAFT (gog's default state is PUBLISHED). */
|
|
38
|
+
function createsVisibleWork(args: readonly string[]): boolean {
|
|
39
|
+
return flagValue(args, 'state')?.toUpperCase() !== 'DRAFT' || flagValue(args, 'scheduled') !== undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when an assignees change shows an item to more students: ALL_STUDENTS or an added student. Removing students only narrows it. */
|
|
43
|
+
function widensAssignees(args: readonly string[]): boolean {
|
|
44
|
+
return flagValue(args, 'mode')?.toUpperCase() === 'ALL_STUDENTS' || flagValue(args, 'add-student') !== undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* gog_classroom_run must not post, publish, enrol, return or invite what the
|
|
49
|
+
* dedicated tools would ask about (#400; SEC-3, fleet-audit #932; SEC-6).
|
|
50
|
+
* Publishing through `update --state=PUBLISHED` was the two-step around the
|
|
51
|
+
* create gate that closed for Gmail drafts, so the update words are vetted on
|
|
52
|
+
* their flags. Deletes that destroy a course or coursework (and every
|
|
53
|
+
* submission to it) go to the tools that ask first.
|
|
54
|
+
*/
|
|
20
55
|
export function vetClassroomRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
21
56
|
const sub = subcommand.toLowerCase();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
57
|
+
if (!Object.hasOwn(CLASSROOM_GROUPS, sub)) return undefined;
|
|
58
|
+
const group = CLASSROOM_GROUPS[sub]!;
|
|
59
|
+
const via = 'gog_classroom_run';
|
|
60
|
+
const what = (word: string) => `gog classroom ${sub} ${word.toLowerCase()}`;
|
|
61
|
+
const create = hasCommandWord(args, CLASSROOM_CREATE_WORDS);
|
|
62
|
+
const update = hasCommandWord(args, CLASSROOM_UPDATE_WORDS);
|
|
63
|
+
const remove = hasCommandWord(args, CLASSROOM_DELETE_WORDS);
|
|
64
|
+
const widenedAssignees = (): string | undefined => {
|
|
65
|
+
const word = hasCommandWord(args, CLASSROOM_ASSIGNEE_WORDS);
|
|
66
|
+
return word && widensAssignees(args)
|
|
67
|
+
? refusedInRun(what(word), via, 'shows it to more students', 'Ask the user to change who it is assigned to from Classroom.')
|
|
68
|
+
: undefined;
|
|
69
|
+
};
|
|
70
|
+
switch (group) {
|
|
71
|
+
case 'courses':
|
|
72
|
+
return remove ? gatedElsewhere(what(remove), via, 'deletes a course', 'gog_classroom_courses_delete') : undefined;
|
|
73
|
+
case 'announcements':
|
|
74
|
+
if (create) return gatedElsewhere(what(create), via, 'posts to a class', 'gog_classroom_announcements_create');
|
|
75
|
+
if (update && publishesToStudents(args)) return gatedElsewhere(what(update), via, 'publishes an announcement to a class', 'gog_classroom_announcements_update');
|
|
76
|
+
return widenedAssignees();
|
|
77
|
+
case 'invitations':
|
|
78
|
+
return create ? gatedElsewhere(what(create), via, 'invites someone to a class', 'gog_classroom_invitations_create') : undefined;
|
|
79
|
+
case 'coursework':
|
|
80
|
+
// An unscheduled DRAFT reaches nobody, and gog_classroom_coursework_create
|
|
81
|
+
// does not ask about one either; publishing it later is the update below.
|
|
82
|
+
if (create && createsVisibleWork(args)) return gatedElsewhere(what(create), via, 'posts coursework to a class', 'gog_classroom_coursework_create');
|
|
83
|
+
if (remove) return gatedElsewhere(what(remove), via, 'deletes coursework and every submission to it', 'gog_classroom_coursework_delete');
|
|
84
|
+
if (update && publishesToStudents(args)) return gatedElsewhere(what(update), via, 'publishes work to students', 'gog_classroom_coursework_update');
|
|
85
|
+
return widenedAssignees();
|
|
86
|
+
case 'materials':
|
|
87
|
+
// No dedicated tool updates materials, so publishing one is refused
|
|
88
|
+
// outright rather than sent to a gated tool.
|
|
89
|
+
if (create && createsVisibleWork(args)) {
|
|
90
|
+
return refusedInRun(what(create), via, 'publishes material to students', 'Create it with --state=DRAFT and no --scheduled; ask the user to publish it from Classroom.');
|
|
91
|
+
}
|
|
92
|
+
if (update && publishesToStudents(args)) return refusedInRun(what(update), via, 'publishes material to students', 'Ask the user to publish it from Classroom.');
|
|
93
|
+
return undefined;
|
|
94
|
+
case 'students':
|
|
95
|
+
return create ? gatedElsewhere(what(create), via, 'enrols someone in a class', 'gog_classroom_students_add') : undefined;
|
|
96
|
+
case 'teachers':
|
|
97
|
+
return create ? gatedElsewhere(what(create), via, "gives someone a teacher's access to a class and its roster", 'gog_classroom_teachers_add') : undefined;
|
|
98
|
+
case 'submissions': {
|
|
99
|
+
const word = hasCommandWord(args, CLASSROOM_RETURN_WORDS);
|
|
100
|
+
return word ? gatedElsewhere(what(word), via, 'returns work to a student, who is notified', 'gog_classroom_submissions_return') : undefined;
|
|
101
|
+
}
|
|
102
|
+
case 'guardian-invitations':
|
|
103
|
+
return create ? refusedInRun(what(create), via, 'emails a guardian invitation', 'Ask the user to invite the guardian from Classroom.') : undefined;
|
|
104
|
+
}
|
|
26
105
|
}
|
|
27
106
|
|
|
28
107
|
/**
|
|
@@ -55,6 +134,127 @@ export async function readCourse(
|
|
|
55
134
|
};
|
|
56
135
|
}
|
|
57
136
|
|
|
137
|
+
/** JSON object under `key` in a gog result, or undefined for unreadable output. */
|
|
138
|
+
function nested(raw: string, key: string): Record<string, unknown> | undefined {
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(raw) as Record<string, unknown> | null;
|
|
141
|
+
const inner = parsed?.[key];
|
|
142
|
+
return inner && typeof inner === 'object' ? inner as Record<string, unknown> : undefined;
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
|
149
|
+
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
|
|
150
|
+
|
|
151
|
+
/** The coursework a Classroom dispatch touches, by title. Same contract as {@link readCourse}. */
|
|
152
|
+
export async function readCoursework(
|
|
153
|
+
courseId: string,
|
|
154
|
+
courseworkId: string,
|
|
155
|
+
account: string | undefined,
|
|
156
|
+
runner: typeof runOrDiagnose = runOrDiagnose,
|
|
157
|
+
) {
|
|
158
|
+
const got = await runner(['classroom', 'coursework', 'get', pos(courseId), pos(courseworkId)], { account });
|
|
159
|
+
if (got.isError) return { error: got };
|
|
160
|
+
const work = nested(resultText(got), 'coursework');
|
|
161
|
+
const title = str(work?.title);
|
|
162
|
+
return { coursework: { id: courseworkId, ...(title !== undefined ? { title } : {}) } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The Classroom items a publish-through-update reaches students with. */
|
|
166
|
+
export type ClassroomWorkKind = 'announcements' | 'coursework';
|
|
167
|
+
|
|
168
|
+
/** What {@link readClassroomWork} names about an item; each field only when gog returned it as a string. */
|
|
169
|
+
export interface ClassroomWork {
|
|
170
|
+
id: string;
|
|
171
|
+
text?: string;
|
|
172
|
+
title?: string;
|
|
173
|
+
description?: string;
|
|
174
|
+
state?: string;
|
|
175
|
+
updateTime?: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The announcement or coursework a publish reaches students with, for its
|
|
180
|
+
* confirmation prompt (the text, or the title and description: a user
|
|
181
|
+
* approving "publish a1" cannot tell what a1 says) and as the token fallback's
|
|
182
|
+
* revision (updateTime rotates on every edit, so an approval never publishes
|
|
183
|
+
* text it did not name). Read on every call. gog 0.41.0 nests the payload
|
|
184
|
+
* under `announcement` or `coursework` (internal/cmd/classroom_*.go; not the
|
|
185
|
+
* API's `courseWork`); unreadable output names nothing rather than throwing.
|
|
186
|
+
*/
|
|
187
|
+
export async function readClassroomWork(
|
|
188
|
+
kind: ClassroomWorkKind,
|
|
189
|
+
courseId: string,
|
|
190
|
+
itemId: string,
|
|
191
|
+
account: string | undefined,
|
|
192
|
+
// A sub-package passes the runOrDiagnose it imported from lib.js (see readCourse).
|
|
193
|
+
runner: typeof runOrDiagnose = runOrDiagnose,
|
|
194
|
+
): Promise<{ error: Awaited<ReturnType<typeof runOrDiagnose>>; work?: undefined } | { error?: undefined; work: ClassroomWork }> {
|
|
195
|
+
const got = await runner(['classroom', kind, 'get', pos(courseId), pos(itemId)], { account });
|
|
196
|
+
if (got.isError) return { error: got };
|
|
197
|
+
const item = nested(resultText(got), kind === 'announcements' ? 'announcement' : 'coursework');
|
|
198
|
+
const work: ClassroomWork = { id: itemId };
|
|
199
|
+
for (const key of ['text', 'title', 'description', 'state', 'updateTime'] as const) {
|
|
200
|
+
const value = str(item?.[key]);
|
|
201
|
+
if (value !== undefined) work[key] = value;
|
|
202
|
+
}
|
|
203
|
+
return { work };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A submission as a return's prompt shows it — whose it is, its state and
|
|
208
|
+
* grades — plus its `updateTime` apart, as the token fallback's revision: a
|
|
209
|
+
* submission re-graded between the phases is DRAFT_CHANGED, not returned with a
|
|
210
|
+
* grade the user never saw.
|
|
211
|
+
*/
|
|
212
|
+
export async function readSubmission(
|
|
213
|
+
courseId: string,
|
|
214
|
+
courseworkId: string,
|
|
215
|
+
submissionId: string,
|
|
216
|
+
account: string | undefined,
|
|
217
|
+
runner: typeof runOrDiagnose = runOrDiagnose,
|
|
218
|
+
) {
|
|
219
|
+
const got = await runner(['classroom', 'submissions', 'get', pos(courseId), pos(courseworkId), pos(submissionId)], { account });
|
|
220
|
+
if (got.isError) return { error: got };
|
|
221
|
+
const sub = nested(resultText(got), 'submission');
|
|
222
|
+
const fields = {
|
|
223
|
+
student: str(sub?.userId),
|
|
224
|
+
state: str(sub?.state),
|
|
225
|
+
draftGrade: num(sub?.draftGrade),
|
|
226
|
+
assignedGrade: num(sub?.assignedGrade),
|
|
227
|
+
};
|
|
228
|
+
const updateTime = str(sub?.updateTime);
|
|
229
|
+
return {
|
|
230
|
+
submission: {
|
|
231
|
+
id: submissionId,
|
|
232
|
+
...Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)),
|
|
233
|
+
} as { id: string; student?: string; state?: string; draftGrade?: number; assignedGrade?: number },
|
|
234
|
+
...(updateTime !== undefined ? { updateTime } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A person on a course roster by name and email, for a prompt that would
|
|
240
|
+
* otherwise show only a numeric user id. Best effort: an unreadable or failed
|
|
241
|
+
* read falls back to the id the caller has.
|
|
242
|
+
*/
|
|
243
|
+
export async function studentLabel(
|
|
244
|
+
courseId: string,
|
|
245
|
+
userId: string,
|
|
246
|
+
account: string | undefined,
|
|
247
|
+
runner: typeof runOrDiagnose = runOrDiagnose,
|
|
248
|
+
): Promise<string> {
|
|
249
|
+
const got = await runner(['classroom', 'students', 'get', pos(courseId), pos(userId)], { account });
|
|
250
|
+
if (got.isError) return userId;
|
|
251
|
+
const profile = nested(resultText(got), 'student')?.profile as { name?: { fullName?: unknown }; emailAddress?: unknown } | undefined;
|
|
252
|
+
const name = str(profile?.name?.fullName);
|
|
253
|
+
const email = str(profile?.emailAddress);
|
|
254
|
+
if (name && email) return `${name} <${email}>`;
|
|
255
|
+
return name ?? email ?? userId;
|
|
256
|
+
}
|
|
257
|
+
|
|
58
258
|
export function registerClassroomTools(server: McpServer): void {
|
|
59
259
|
server.registerTool('gog_classroom_courses_list', {
|
|
60
260
|
description: 'List Google Classroom courses.',
|
|
@@ -279,15 +479,43 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
279
479
|
});
|
|
280
480
|
|
|
281
481
|
server.registerTool('gog_classroom_submissions_return', {
|
|
282
|
-
description: 'Return a graded submission to the student.'
|
|
482
|
+
description: 'Return a graded submission to the student, who is notified and sees the grade. Reads the course, the '
|
|
483
|
+
+ 'coursework and the submission and asks the MCP host to show the user a confirmation prompt with the class, the '
|
|
484
|
+
+ 'assignment, the student and the grade being returned; nothing is returned unless they accept.'
|
|
485
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
283
486
|
annotations: { destructiveHint: true },
|
|
284
487
|
inputSchema: z.object({
|
|
285
488
|
courseId: z.string().describe('Course ID'),
|
|
286
489
|
courseworkId: z.string().describe('Coursework ID'),
|
|
287
490
|
submissionId: z.string().describe('Submission ID'),
|
|
288
491
|
account: accountParam,
|
|
492
|
+
confirmToken: confirmTokenParam,
|
|
289
493
|
}),
|
|
290
|
-
}, async ({ courseId, courseworkId, submissionId, account }) => {
|
|
494
|
+
}, async ({ courseId, courseworkId, submissionId, account, confirmToken }, ctx) => {
|
|
495
|
+
const course = await readCourse(courseId, account);
|
|
496
|
+
if (course.error) return course.error;
|
|
497
|
+
const work = await readCoursework(courseId, courseworkId, account);
|
|
498
|
+
if (work.error) return work.error;
|
|
499
|
+
const sub = await readSubmission(courseId, courseworkId, submissionId, account);
|
|
500
|
+
if (sub.error) return sub.error;
|
|
501
|
+
const submission = sub.submission.student
|
|
502
|
+
? { ...sub.submission, student: await studentLabel(courseId, sub.submission.student, account) }
|
|
503
|
+
: sub.submission;
|
|
504
|
+
const view = { course: course.course, coursework: work.coursework, submission };
|
|
505
|
+
const confirmation = await requireDispatchConfirmation(ctx, {
|
|
506
|
+
action: 'classroom.submission-return',
|
|
507
|
+
message: 'Review and confirm returning this work — the student is notified and sees the grade:',
|
|
508
|
+
confirmationLabel: 'Confirm that this submission should be returned to the student now.',
|
|
509
|
+
details: view,
|
|
510
|
+
unsupportedNote: 'Ask the user to return it from Classroom.',
|
|
511
|
+
fallback: {
|
|
512
|
+
tool: 'gog_classroom_submissions_return',
|
|
513
|
+
account,
|
|
514
|
+
confirmToken,
|
|
515
|
+
subject: () => ({ target: `${courseId}/${courseworkId}/${submissionId}`, revision: sub.updateTime, payload: view, preview: view }),
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
if (confirmation) return confirmation;
|
|
291
519
|
return runOrDiagnose(['classroom', 'submissions', 'return', pos(courseId), pos(courseworkId), pos(submissionId)], { account });
|
|
292
520
|
});
|
|
293
521
|
|
|
@@ -354,16 +582,16 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
354
582
|
});
|
|
355
583
|
|
|
356
584
|
server.registerTool('gog_classroom_announcements_create', {
|
|
357
|
-
description: 'Create an announcement in a Google Classroom course. Unless state is DRAFT
|
|
358
|
-
+ 'see), this reads the course and asks the MCP host to
|
|
359
|
-
+ 'full text and when it publishes; nothing is posted
|
|
360
|
-
+ 'state DRAFT.' + CONFIRM_FALLBACK_DESCRIPTION,
|
|
585
|
+
description: 'Create an announcement in a Google Classroom course. Unless state is DRAFT with no scheduled time '
|
|
586
|
+
+ '(which students cannot see; a scheduled draft publishes itself), this reads the course and asks the MCP host to '
|
|
587
|
+
+ 'show the user a confirmation prompt with the class, the full text and when it publishes; nothing is posted '
|
|
588
|
+
+ 'unless they accept. To stage one without asking, pass state DRAFT and no scheduled time.' + CONFIRM_FALLBACK_DESCRIPTION,
|
|
361
589
|
annotations: { destructiveHint: true },
|
|
362
590
|
inputSchema: z.object({
|
|
363
591
|
courseId: z.string().describe('Course ID'),
|
|
364
592
|
text: z.string().describe('Announcement text'),
|
|
365
|
-
state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('State (DRAFT is visible only to teachers and needs no confirmation)'),
|
|
366
|
-
scheduled: z.string().optional().describe('Scheduled publish time'),
|
|
593
|
+
state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('State (an unscheduled DRAFT is visible only to teachers and needs no confirmation)'),
|
|
594
|
+
scheduled: z.string().optional().describe('Scheduled publish time (asks the user to confirm, even for a DRAFT)'),
|
|
367
595
|
account: accountParam,
|
|
368
596
|
confirmToken: confirmTokenParam,
|
|
369
597
|
}),
|
|
@@ -371,9 +599,10 @@ export function registerClassroomTools(server: McpServer): void {
|
|
|
371
599
|
const args: GogArg[] = ['classroom', 'announcements', 'create', pos(courseId), `--text=${text}`];
|
|
372
600
|
if (state) args.push(`--state=${state}`);
|
|
373
601
|
if (scheduled) args.push(`--scheduled=${scheduled}`);
|
|
374
|
-
//
|
|
375
|
-
// own staging twin, so it needs no confirmation.
|
|
376
|
-
|
|
602
|
+
// An unscheduled draft reaches nobody until a teacher publishes it: it is
|
|
603
|
+
// this tool's own staging twin, so it needs no confirmation. A scheduled
|
|
604
|
+
// one publishes itself.
|
|
605
|
+
if (state !== 'DRAFT' || scheduled) {
|
|
377
606
|
const read = await readCourse(courseId, account);
|
|
378
607
|
if (read.error) return read.error;
|
|
379
608
|
const publishes = scheduled ? `at ${scheduled}` : 'immediately';
|
package/src/tools/docs.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { z } from 'zod';
|
|
|
3
3
|
import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
|
|
4
4
|
import { pos } from '../argv.js';
|
|
5
5
|
import type { GogArg } from '../runner.js';
|
|
6
|
+
import { vetCommentsRun } from '../dispatch-confirmation.js';
|
|
7
|
+
|
|
8
|
+
/** gog_docs_run must not post the comments gog_docs_comments_add / _reply would ask about. */
|
|
9
|
+
export function vetDocsRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
10
|
+
return vetCommentsRun('docs', subcommand, args);
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
export function registerDocsTools(server: McpServer): void {
|
|
8
14
|
server.registerTool('gog_docs_info', {
|
|
@@ -106,5 +112,5 @@ export function registerDocsTools(server: McpServer): void {
|
|
|
106
112
|
return runOrDiagnose(['docs', 'structure', pos(docId)], { account });
|
|
107
113
|
});
|
|
108
114
|
|
|
109
|
-
registerRunTool(server, { service: 'docs', examples: '"copy", "clear", "insert", "sed", "export"' });
|
|
115
|
+
registerRunTool(server, { service: 'docs', examples: '"copy", "clear", "insert", "sed", "export"', vet: vetDocsRun });
|
|
110
116
|
}
|
package/src/tools/drive.ts
CHANGED
|
@@ -8,13 +8,32 @@ import { run, runBinary } from '../runner.js';
|
|
|
8
8
|
import { accountParam, diagnose, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
|
|
9
9
|
import { pos } from '../argv.js';
|
|
10
10
|
import type { GogArg } from '../runner.js';
|
|
11
|
-
import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
|
|
11
|
+
import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, hasTrueFlag, refusedInRun, requireDispatchConfirmation, vetCommentsRun, resultText } from '../dispatch-confirmation.js';
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
// gog's spellings (internal/cmd/drive*.go; aliases per `gog schema` 0.41.0).
|
|
14
|
+
const DRIVE_DELETE_WORDS = new Set(['delete', 'rm', 'del']);
|
|
15
|
+
|
|
16
|
+
/** gog_drive_run must not do what gog_drive_share / comments / a permanent delete would ask about. */
|
|
17
|
+
export function vetDriveRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
18
|
+
const sub = subcommand.toLowerCase();
|
|
19
|
+
if (sub === 'share') return gatedElsewhere('gog drive share', 'gog_drive_run', 'grants access to a file', 'gog_drive_share');
|
|
20
|
+
if (DRIVE_DELETE_WORDS.has(sub) && hasTrueFlag(args, 'permanent')) {
|
|
21
|
+
return gatedElsewhere(`gog drive ${sub} --permanent`, 'gog_drive_run', 'deletes a file for good, bypassing the trash', 'gog_drive_delete');
|
|
22
|
+
}
|
|
23
|
+
// `bulk update-role --from=reader --to=writer` (and `bulk remove-public`)
|
|
24
|
+
// rewrites the permissions of every matching file under a folder, with no
|
|
25
|
+
// positional path for the path guard to see and no file for a preview to
|
|
26
|
+
// name (SEC-1, fleet-audit #930). `permissions` only lists.
|
|
27
|
+
if (sub === 'bulk') {
|
|
28
|
+
return gatedElsewhere('gog drive bulk', 'gog_drive_run',
|
|
29
|
+
'rewrites sharing permissions across every matching file in a tree', 'gog_drive_share (one file at a time)');
|
|
30
|
+
}
|
|
31
|
+
// `unshare` removes a collaborator's access; the dedicated tool shows the
|
|
32
|
+
// host a structured, annotated call rather than an opaque argv.
|
|
33
|
+
if (sub === 'unshare') {
|
|
34
|
+
return refusedInRun('gog drive unshare', 'gog_drive_run', "removes someone's access to a file", 'Use gog_drive_unshare.');
|
|
35
|
+
}
|
|
36
|
+
return vetCommentsRun('drive', sub, args);
|
|
18
37
|
}
|
|
19
38
|
|
|
20
39
|
// A native Google Doc exports to text directly; anything else (PDF, image,
|
|
@@ -36,6 +55,36 @@ export function shareTargetMeta(raw: string): { name?: string; mimeType?: string
|
|
|
36
55
|
}
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A Drive/Docs comment as a reply's prompt shows it: who wrote it, what it
|
|
60
|
+
* says and whether it is resolved. Unreadable output names nothing.
|
|
61
|
+
*/
|
|
62
|
+
export function commentSnapshot(raw: string): { author?: string; content?: string; resolved?: boolean } {
|
|
63
|
+
let comment: { content?: unknown; resolved?: unknown; author?: { displayName?: unknown; emailAddress?: unknown } } | undefined;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(raw) as ({ comment?: typeof comment } & NonNullable<typeof comment>) | null;
|
|
66
|
+
comment = parsed?.comment ?? parsed ?? undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
comment = undefined;
|
|
69
|
+
}
|
|
70
|
+
const name = typeof comment?.author?.displayName === 'string' ? comment.author.displayName : undefined;
|
|
71
|
+
const email = typeof comment?.author?.emailAddress === 'string' ? comment.author.emailAddress : undefined;
|
|
72
|
+
const author = name && email ? `${name} <${email}>` : name ?? email;
|
|
73
|
+
return {
|
|
74
|
+
...(author ? { author } : {}),
|
|
75
|
+
...(typeof comment?.content === 'string' ? { content: comment.content } : {}),
|
|
76
|
+
...(typeof comment?.resolved === 'boolean' ? { resolved: comment.resolved } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The people a comment's text +mentions (or @mentions) by email — Drive
|
|
82
|
+
* notifies each of them, so a comment's prompt names them.
|
|
83
|
+
*/
|
|
84
|
+
export function commentMentions(text: string): string[] {
|
|
85
|
+
return [...new Set(text.match(/(?<=[+@])[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g) ?? [])];
|
|
86
|
+
}
|
|
87
|
+
|
|
39
88
|
function fileMeta(raw: string): { name?: string; mimeType?: string; size?: number } {
|
|
40
89
|
const parsed = JSON.parse(raw) as { file?: DriveMeta } & DriveMeta;
|
|
41
90
|
const f = parsed.file ?? parsed;
|
|
@@ -171,16 +220,39 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
171
220
|
});
|
|
172
221
|
|
|
173
222
|
server.registerTool('gog_drive_delete', {
|
|
174
|
-
description: 'Move a Google Drive file to trash, or permanently delete it with permanent=true (irreversible).'
|
|
223
|
+
description: 'Move a Google Drive file to trash, or permanently delete it with permanent=true (irreversible). A '
|
|
224
|
+
+ 'permanent delete reads the file and asks the MCP host to show the user a confirmation prompt naming it first; '
|
|
225
|
+
+ 'nothing is deleted unless they accept. Moving to trash is recoverable and does not ask.'
|
|
226
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
175
227
|
annotations: { destructiveHint: true },
|
|
176
228
|
inputSchema: z.object({
|
|
177
229
|
fileId: z.string().describe('File ID to delete'),
|
|
178
|
-
permanent: z.boolean().optional().describe('Permanently delete instead of moving to trash (irreversible)'),
|
|
230
|
+
permanent: z.boolean().optional().describe('Permanently delete instead of moving to trash (irreversible; asks the user first)'),
|
|
179
231
|
account: accountParam,
|
|
232
|
+
confirmToken: confirmTokenParam,
|
|
180
233
|
}),
|
|
181
|
-
}, async ({ fileId, permanent, account }) => {
|
|
234
|
+
}, async ({ fileId, permanent, account, confirmToken }, ctx) => {
|
|
182
235
|
const args: GogArg[] = ['drive', 'delete', pos(fileId)];
|
|
183
|
-
if (permanent)
|
|
236
|
+
if (permanent) {
|
|
237
|
+
const got = await runOrDiagnose(['drive', 'get', pos(fileId)], { account });
|
|
238
|
+
if (got.isError) return got;
|
|
239
|
+
const target = { file: { id: fileId, ...shareTargetMeta(resultText(got)) }, permanent: true };
|
|
240
|
+
const confirmation = await requireDispatchConfirmation(ctx, {
|
|
241
|
+
action: 'drive.delete-permanent',
|
|
242
|
+
message: 'Review and confirm PERMANENTLY deleting this file — it skips the trash and cannot be recovered:',
|
|
243
|
+
confirmationLabel: 'Confirm that this file should be permanently deleted now.',
|
|
244
|
+
details: target,
|
|
245
|
+
unsupportedNote: 'Move it to the trash instead (permanent=false); the user can empty the trash from Google Drive.',
|
|
246
|
+
fallback: {
|
|
247
|
+
tool: 'gog_drive_delete',
|
|
248
|
+
account,
|
|
249
|
+
confirmToken,
|
|
250
|
+
subject: () => ({ target: fileId, payload: target, preview: target }),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
if (confirmation) return confirmation;
|
|
254
|
+
args.push('--permanent');
|
|
255
|
+
}
|
|
184
256
|
// gog gates drive delete behind a confirmation; the runner injects
|
|
185
257
|
// --no-input, so without --force it refuses at runtime.
|
|
186
258
|
args.push('--force');
|
package/src/tools/gmail.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
|
|
|
7
7
|
import type { InlineAttachmentInput } from '../attachments.js';
|
|
8
8
|
import { attachmentDetails, attachmentNames, attachmentPreview, bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText, senderPreview } from '../gmail-dispatch-guard.js';
|
|
9
9
|
import { pos } from '../argv.js';
|
|
10
|
-
import { hasCommandWord } from '../dispatch-confirmation.js';
|
|
10
|
+
import { gatedElsewhere, hasCommandWord, hasTrueFlag } from '../dispatch-confirmation.js';
|
|
11
11
|
import { confinePath, confinePaths } from '../file-roots.js';
|
|
12
12
|
|
|
13
13
|
// gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
|
|
@@ -264,6 +264,40 @@ async function sendReply(
|
|
|
264
264
|
// `gog schema`, but they reach Google), so both spellings are refused.
|
|
265
265
|
const GMAIL_RUN_BLOCKED_SETTINGS = new Set(['forwarding', 'autoforward', 'filters', 'delegates']);
|
|
266
266
|
|
|
267
|
+
// Fleet audit 2026-09-24 SEC-6: a batch delete bypasses the Trash, a send-as
|
|
268
|
+
// alias makes Google email the target and gives the account a new sending
|
|
269
|
+
// identity, and an enabled vacation responder auto-replies to every sender.
|
|
270
|
+
// Each has a dedicated tool that asks; gog accepts sendas and vacation both
|
|
271
|
+
// under `settings` and one level up. Aliases per `gog schema` 0.41.0.
|
|
272
|
+
const GMAIL_DELETE_WORDS = new Set(['delete', 'rm', 'del', 'remove']);
|
|
273
|
+
const GMAIL_CREATE_WORDS = new Set(['create', 'add', 'new']);
|
|
274
|
+
const GMAIL_UPDATE_WORDS = new Set(['update', 'edit', 'set']);
|
|
275
|
+
const SENDAS = new Set(['sendas']);
|
|
276
|
+
const VACATION = new Set(['vacation']);
|
|
277
|
+
|
|
278
|
+
function vetGmailGatedRun(sub: string, args: readonly string[]): string | undefined {
|
|
279
|
+
if (sub === 'batch') {
|
|
280
|
+
const word = hasCommandWord(args, GMAIL_DELETE_WORDS);
|
|
281
|
+
return word
|
|
282
|
+
? gatedElsewhere(`gog gmail batch ${word.toLowerCase()}`, 'gog_gmail_run', 'deletes mail for good, bypassing the Trash', 'gog_gmail_batch_delete')
|
|
283
|
+
: undefined;
|
|
284
|
+
}
|
|
285
|
+
if (sub !== 'settings' && sub !== 'sendas' && sub !== 'vacation') return undefined;
|
|
286
|
+
const words = sub === 'settings' ? args : [sub, ...args];
|
|
287
|
+
if (hasCommandWord(words, SENDAS)) {
|
|
288
|
+
const word = hasCommandWord(args, GMAIL_CREATE_WORDS);
|
|
289
|
+
if (word) {
|
|
290
|
+
return gatedElsewhere(`gog gmail sendas ${word.toLowerCase()}`, 'gog_gmail_run',
|
|
291
|
+
'makes Google email the address and adds a sending identity', 'gog_gmail_sendas_create');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (hasCommandWord(words, VACATION) && hasCommandWord(args, GMAIL_UPDATE_WORDS) && hasTrueFlag(args, 'enable')) {
|
|
295
|
+
return gatedElsewhere('gog gmail vacation update --enable', 'gog_gmail_run',
|
|
296
|
+
'turns on an auto-reply to every sender', 'gog_gmail_vacation_update');
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
267
301
|
export function vetGmailRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
268
302
|
if (subcommand === 'autoreply') {
|
|
269
303
|
return 'gog gmail autoreply sends mail and is not available through gog_gmail_run. Use gog_gmail_autoreply, which asks the user to confirm.';
|
|
@@ -271,6 +305,8 @@ export function vetGmailRun(subcommand: string, args: readonly string[]): string
|
|
|
271
305
|
if (GMAIL_RUN_BLOCKED_SETTINGS.has(subcommand)) {
|
|
272
306
|
return `gog gmail ${subcommand} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
|
|
273
307
|
}
|
|
308
|
+
const gated = vetGmailGatedRun(subcommand.toLowerCase(), args);
|
|
309
|
+
if (gated) return gated;
|
|
274
310
|
if (subcommand === 'settings') {
|
|
275
311
|
// A global flag can take its value as the next token (`settings --color
|
|
276
312
|
// never filters ...`), so the word is not necessarily args[0]. Refuse it
|
package/src/tools/sheets.ts
CHANGED
|
@@ -6,6 +6,21 @@ import { accountParam, runOrDiagnose, registerRunTool, diagnose } from './utils.
|
|
|
6
6
|
import { expandAnchorRange, countNonEmptyCells } from './sheets-a1.js';
|
|
7
7
|
import { pos } from '../argv.js';
|
|
8
8
|
import type { GogArg } from '../runner.js';
|
|
9
|
+
import { gatedElsewhere, hasCommandWord } from '../dispatch-confirmation.js';
|
|
10
|
+
|
|
11
|
+
// Fleet audit 2026-09-24 SEC-6: each of these starts a BigQuery job billed to
|
|
12
|
+
// --billing-project. Spellings per `gog schema` 0.41.0.
|
|
13
|
+
const DATASOURCE_ALIASES = new Set(['datasource', 'data-source', 'data-sources', 'connected-sheets']);
|
|
14
|
+
const DATASOURCE_BILLED = new Set(['add', 'update', 'refresh']);
|
|
15
|
+
|
|
16
|
+
/** gog_sheets_run must not start the billed queries gog_sheets_datasource_* would ask about. */
|
|
17
|
+
export function vetSheetsRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
18
|
+
if (!DATASOURCE_ALIASES.has(subcommand.toLowerCase())) return undefined;
|
|
19
|
+
const word = hasCommandWord(args, DATASOURCE_BILLED)?.toLowerCase();
|
|
20
|
+
return word
|
|
21
|
+
? gatedElsewhere(`gog sheets datasource ${word}`, 'gog_sheets_run', 'runs a billed BigQuery job', `gog_sheets_datasource_${word}`)
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
9
24
|
|
|
10
25
|
// Cell value type: matches what gog sheets --values-json accepts (passed
|
|
11
26
|
// straight to the Sheets API as userEnteredValue). Strings starting with
|
|
@@ -144,5 +159,5 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
144
159
|
return runOrDiagnose(['sheets', 'find-replace', pos(spreadsheetId), pos(find), pos(replace)], { account });
|
|
145
160
|
});
|
|
146
161
|
|
|
147
|
-
registerRunTool(server, { service: 'sheets', examples: '"freeze", "add-tab", "rename-tab"' });
|
|
162
|
+
registerRunTool(server, { service: 'sheets', examples: '"freeze", "add-tab", "rename-tab"', vet: vetSheetsRun });
|
|
148
163
|
}
|