gogcli-mcp-classroom 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.
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp-classroom",
5
5
  "display_name": "gogcli (Classroom)",
6
- "version": "4.2.5",
6
+ "version": "4.4.0",
7
7
  "description": "Extended Google Classroom for Claude via gogcli — auth + full Classroom support (courses, rosters, coursework, submissions, announcements, topics, invitations)",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -39,7 +39,8 @@
39
39
  ],
40
40
  "env": {
41
41
  "GOG_ACCOUNT": "${user_config.gog_account}",
42
- "GOG_PATH": "${user_config.gog_path}"
42
+ "GOG_PATH": "${user_config.gog_path}",
43
+ "GOG_FILE_ROOTS": "${user_config.gog_file_roots}"
43
44
  }
44
45
  }
45
46
  },
@@ -55,6 +56,12 @@
55
56
  "title": "gog Executable Path",
56
57
  "description": "Path to the gog executable (optional — defaults to 'gog' on your PATH)",
57
58
  "required": false
59
+ },
60
+ "gog_file_roots": {
61
+ "type": "string",
62
+ "title": "Allowed file directories",
63
+ "description": "Directories tools may read files from (attachments, uploads) or write files to (exports, downloads), separated by ':' (';' on Windows). Optional — defaults to ~/gogcli-mcp-files",
64
+ "required": false
58
65
  }
59
66
  },
60
67
  "tools": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-classroom",
3
- "version": "4.2.5",
3
+ "version": "4.4.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp-classroom",
5
5
  "description": "Extended Google Classroom MCP server via gogcli — auth + full Classroom support",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -24,7 +24,7 @@
24
24
  "test:coverage": "vitest run --coverage"
25
25
  },
26
26
  "dependencies": {
27
- "@chrischall/mcp-utils": "^2.1.0",
27
+ "@chrischall/mcp-utils": "^2.4.0",
28
28
  "@modelcontextprotocol/server": "^2.0.0",
29
29
  "zod": "^4.6.1"
30
30
  },
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp-classroom"
9
9
  },
10
- "version": "4.2.5",
10
+ "version": "4.4.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-classroom",
15
- "version": "4.2.5",
15
+ "version": "4.4.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose } from '../../../gogcli-mcp/src/lib.js';
3
+ import { accountParam, runOrDiagnose, pos, readCourse, requireDispatchConfirmation, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam } from '../../../gogcli-mcp/src/lib.js';
4
+ import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
4
5
 
5
6
  const courseState = z.enum(['ACTIVE', 'ARCHIVED', 'PROVISIONED', 'DECLINED', 'SUSPENDED']);
6
7
  const workState = z.enum(['PUBLISHED', 'DRAFT']);
@@ -41,7 +42,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
41
42
  account: accountParam,
42
43
  }),
43
44
  }, async ({ name, owner, section, descriptionHeading, description, room, state, account }) => {
44
- const args = ['classroom', 'courses', 'create', `--name=${name}`];
45
+ const args: GogArg[] = ['classroom', 'courses', 'create', `--name=${name}`];
45
46
  if (owner) args.push(`--owner=${owner}`);
46
47
  if (section) args.push(`--section=${section}`);
47
48
  if (descriptionHeading) args.push(`--description-heading=${descriptionHeading}`);
@@ -61,7 +62,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
61
62
  account: accountParam,
62
63
  }),
63
64
  }, async ({ courseId, name, owner, section, descriptionHeading, description, room, state, account }) => {
64
- const args = ['classroom', 'courses', 'update', courseId];
65
+ const args: GogArg[] = ['classroom', 'courses', 'update', pos(courseId)];
65
66
  if (name) args.push(`--name=${name}`);
66
67
  if (owner) args.push(`--owner=${owner}`);
67
68
  if (section) args.push(`--section=${section}`);
@@ -80,7 +81,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
80
81
  account: accountParam,
81
82
  }),
82
83
  }, async ({ courseId, account }) => {
83
- return runOrDiagnose(['classroom', 'courses', 'delete', courseId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
84
+ return runOrDiagnose(['classroom', 'courses', 'delete', pos(courseId), '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
84
85
  });
85
86
 
86
87
  server.registerTool('gog_classroom_courses_archive', {
@@ -91,7 +92,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
91
92
  account: accountParam,
92
93
  }),
93
94
  }, async ({ courseId, account }) => {
94
- return runOrDiagnose(['classroom', 'courses', 'archive', courseId], { account });
95
+ return runOrDiagnose(['classroom', 'courses', 'archive', pos(courseId)], { account });
95
96
  });
96
97
 
97
98
  server.registerTool('gog_classroom_courses_unarchive', {
@@ -102,7 +103,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
102
103
  account: accountParam,
103
104
  }),
104
105
  }, async ({ courseId, account }) => {
105
- return runOrDiagnose(['classroom', 'courses', 'unarchive', courseId], { account });
106
+ return runOrDiagnose(['classroom', 'courses', 'unarchive', pos(courseId)], { account });
106
107
  });
107
108
 
108
109
  server.registerTool('gog_classroom_students_add', {
@@ -115,7 +116,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
115
116
  account: accountParam,
116
117
  }),
117
118
  }, async ({ courseId, userId, enrollmentCode, account }) => {
118
- const args = ['classroom', 'students', 'add', courseId, userId];
119
+ const args: GogArg[] = ['classroom', 'students', 'add', pos(courseId), pos(userId)];
119
120
  if (enrollmentCode) args.push(`--enrollment-code=${enrollmentCode}`);
120
121
  return runOrDiagnose(args, { account });
121
122
  });
@@ -129,7 +130,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
129
130
  account: accountParam,
130
131
  }),
131
132
  }, async ({ courseId, userId, account }) => {
132
- return runOrDiagnose(['classroom', 'students', 'remove', courseId, userId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
133
+ return runOrDiagnose(['classroom', 'students', 'remove', pos(courseId), pos(userId), '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
133
134
  });
134
135
 
135
136
  server.registerTool('gog_classroom_teachers_add', {
@@ -141,7 +142,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
141
142
  account: accountParam,
142
143
  }),
143
144
  }, async ({ courseId, userId, account }) => {
144
- return runOrDiagnose(['classroom', 'teachers', 'add', courseId, userId], { account });
145
+ return runOrDiagnose(['classroom', 'teachers', 'add', pos(courseId), pos(userId)], { account });
145
146
  });
146
147
 
147
148
  server.registerTool('gog_classroom_teachers_remove', {
@@ -153,7 +154,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
153
154
  account: accountParam,
154
155
  }),
155
156
  }, async ({ courseId, userId, account }) => {
156
- return runOrDiagnose(['classroom', 'teachers', 'remove', courseId, userId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
157
+ return runOrDiagnose(['classroom', 'teachers', 'remove', pos(courseId), pos(userId), '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
157
158
  });
158
159
 
159
160
  server.registerTool('gog_classroom_coursework_create', {
@@ -166,7 +167,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
166
167
  account: accountParam,
167
168
  }),
168
169
  }, async ({ courseId, title, description, type, state, maxPoints, due, dueDate, dueTime, scheduled, topic, account }) => {
169
- const args = ['classroom', 'coursework', 'create', courseId, `--title=${title}`];
170
+ const args: GogArg[] = ['classroom', 'coursework', 'create', pos(courseId), `--title=${title}`];
170
171
  if (description) args.push(`--description=${description}`);
171
172
  if (type) args.push(`--type=${type}`);
172
173
  if (state) args.push(`--state=${state}`);
@@ -190,7 +191,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
190
191
  account: accountParam,
191
192
  }),
192
193
  }, async ({ courseId, courseworkId, title, description, type, state, maxPoints, due, dueDate, dueTime, scheduled, topic, account }) => {
193
- const args = ['classroom', 'coursework', 'update', courseId, courseworkId];
194
+ const args: GogArg[] = ['classroom', 'coursework', 'update', pos(courseId), pos(courseworkId)];
194
195
  if (title) args.push(`--title=${title}`);
195
196
  if (description) args.push(`--description=${description}`);
196
197
  if (type) args.push(`--type=${type}`);
@@ -213,7 +214,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
213
214
  account: accountParam,
214
215
  }),
215
216
  }, async ({ courseId, courseworkId, account }) => {
216
- return runOrDiagnose(['classroom', 'coursework', 'delete', courseId, courseworkId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
217
+ return runOrDiagnose(['classroom', 'coursework', 'delete', pos(courseId), pos(courseworkId), '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
217
218
  });
218
219
 
219
220
  server.registerTool('gog_classroom_announcements_update', {
@@ -228,7 +229,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
228
229
  account: accountParam,
229
230
  }),
230
231
  }, async ({ courseId, announcementId, text, state, scheduled, account }) => {
231
- const args = ['classroom', 'announcements', 'update', courseId, announcementId];
232
+ const args: GogArg[] = ['classroom', 'announcements', 'update', pos(courseId), pos(announcementId)];
232
233
  if (text) args.push(`--text=${text}`);
233
234
  if (state) args.push(`--state=${state}`);
234
235
  if (scheduled) args.push(`--scheduled=${scheduled}`);
@@ -244,7 +245,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
244
245
  account: accountParam,
245
246
  }),
246
247
  }, async ({ courseId, announcementId, account }) => {
247
- return runOrDiagnose(['classroom', 'announcements', 'delete', courseId, announcementId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
248
+ return runOrDiagnose(['classroom', 'announcements', 'delete', pos(courseId), pos(announcementId), '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
248
249
  });
249
250
 
250
251
  server.registerTool('gog_classroom_topics_create', {
@@ -256,7 +257,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
256
257
  account: accountParam,
257
258
  }),
258
259
  }, async ({ courseId, name, account }) => {
259
- return runOrDiagnose(['classroom', 'topics', 'create', courseId, `--name=${name}`], { account });
260
+ return runOrDiagnose(['classroom', 'topics', 'create', pos(courseId), `--name=${name}`], { account });
260
261
  });
261
262
 
262
263
  server.registerTool('gog_classroom_topics_update', {
@@ -269,7 +270,7 @@ export function registerExtraClassroomTools(server: McpServer): void {
269
270
  account: accountParam,
270
271
  }),
271
272
  }, async ({ courseId, topicId, name, account }) => {
272
- return runOrDiagnose(['classroom', 'topics', 'update', courseId, topicId, `--name=${name}`], { account });
273
+ return runOrDiagnose(['classroom', 'topics', 'update', pos(courseId), pos(topicId), `--name=${name}`], { account });
273
274
  });
274
275
 
275
276
  server.registerTool('gog_classroom_topics_delete', {
@@ -281,20 +282,42 @@ export function registerExtraClassroomTools(server: McpServer): void {
281
282
  account: accountParam,
282
283
  }),
283
284
  }, async ({ courseId, topicId, account }) => {
284
- return runOrDiagnose(['classroom', 'topics', 'delete', courseId, topicId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
285
+ return runOrDiagnose(['classroom', 'topics', 'delete', pos(courseId), pos(topicId), '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
285
286
  });
286
287
 
287
288
  server.registerTool('gog_classroom_invitations_create', {
288
- description: 'Create an invitation to a Google Classroom course.',
289
+ description: 'Create an invitation to a Google Classroom course — Classroom emails the invitee. Reads the course '
290
+ + 'and asks the MCP host to show the user a confirmation prompt with the class, the invitee and the role; '
291
+ + 'nothing is sent unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION,
289
292
  annotations: { destructiveHint: true },
290
293
  inputSchema: z.object({
291
294
  courseId: z.string().describe('Course ID'),
292
295
  userId: z.string().describe('User ID to invite'),
293
296
  role: z.enum(['STUDENT', 'TEACHER', 'OWNER']).describe('Role for the invited user'),
294
297
  account: accountParam,
298
+ confirmToken: confirmTokenParam,
295
299
  }),
296
- }, async ({ courseId, userId, role, account }) => {
297
- return runOrDiagnose(['classroom', 'invitations', 'create', courseId, userId, `--role=${role}`], { account });
300
+ }, async ({ courseId, userId, role, account, confirmToken }, ctx) => {
301
+ const read = await readCourse(courseId, account, runOrDiagnose);
302
+ if (read.error) return read.error;
303
+ const invitation = { course: read.course, invitee: userId, role };
304
+ const confirmation = await requireDispatchConfirmation(ctx, {
305
+ action: 'classroom.invitation-create',
306
+ message: role === 'OWNER'
307
+ ? 'Review and confirm this Classroom invitation — it offers OWNERSHIP of the course:'
308
+ : 'Review and confirm this Classroom invitation:',
309
+ confirmationLabel: 'Confirm that this invitation should be sent now.',
310
+ details: invitation,
311
+ unsupportedNote: 'Ask the user to invite them from Classroom.',
312
+ fallback: {
313
+ tool: 'gog_classroom_invitations_create',
314
+ account,
315
+ confirmToken,
316
+ subject: () => ({ target: `${courseId}/${userId}`, payload: invitation, preview: invitation }),
317
+ },
318
+ });
319
+ if (confirmation) return confirmation;
320
+ return runOrDiagnose(['classroom', 'invitations', 'create', pos(courseId), pos(userId), `--role=${role}`], { account });
298
321
  });
299
322
 
300
323
  server.registerTool('gog_classroom_invitations_delete', {
@@ -305,6 +328,6 @@ export function registerExtraClassroomTools(server: McpServer): void {
305
328
  account: accountParam,
306
329
  }),
307
330
  }, async ({ invitationId, account }) => {
308
- return runOrDiagnose(['classroom', 'invitations', 'delete', invitationId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
331
+ return runOrDiagnose(['classroom', 'invitations', 'delete', pos(invitationId), '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
309
332
  });
310
333
  }
@@ -3,6 +3,7 @@ import { registerExtraClassroomTools } from '../../src/tools/classroom-extra.js'
3
3
  import * as lib from '../../../gogcli-mcp/src/lib.js';
4
4
  import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
5
5
  import { rawTextResult } from '@chrischall/mcp-utils';
6
+ import { pos } from '../../../gogcli-mcp/src/argv.js';
6
7
 
7
8
  vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
8
9
  const actual = await importOriginal<typeof lib>();
@@ -17,7 +18,9 @@ let harness: TestHarness;
17
18
  beforeEach(async () => {
18
19
  vi.clearAllMocks();
19
20
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
20
- harness = await createTestHarness(registerExtraClassroomTools);
21
+ harness = await createTestHarness(registerExtraClassroomTools, {
22
+ elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
23
+ });
21
24
  });
22
25
 
23
26
  describe('gog_classroom_courses_create', () => {
@@ -49,7 +52,7 @@ describe('gog_classroom_courses_create', () => {
49
52
  describe('gog_classroom_courses_update', () => {
50
53
  it('calls runOrDiagnose with courseId only', async () => {
51
54
  await harness.callTool('gog_classroom_courses_update', { courseId: 'c1' });
52
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'update', 'c1'], { account: undefined });
55
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'update', pos('c1')], { account: undefined });
53
56
  });
54
57
 
55
58
  it('passes all optional flags', async () => {
@@ -64,7 +67,7 @@ describe('gog_classroom_courses_update', () => {
64
67
  state: 'ARCHIVED',
65
68
  });
66
69
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
67
- ['classroom', 'courses', 'update', 'c1', '--name=New Name', '--owner=me', '--section=B', '--description-heading=Heading', '--description=Desc', '--room=R2', '--state=ARCHIVED'],
70
+ ['classroom', 'courses', 'update', pos('c1'), '--name=New Name', '--owner=me', '--section=B', '--description-heading=Heading', '--description=Desc', '--room=R2', '--state=ARCHIVED'],
68
71
  { account: undefined },
69
72
  );
70
73
  });
@@ -73,34 +76,34 @@ describe('gog_classroom_courses_update', () => {
73
76
  describe('gog_classroom_courses_delete', () => {
74
77
  it('calls runOrDiagnose with courseId', async () => {
75
78
  await harness.callTool('gog_classroom_courses_delete', { courseId: 'c1' });
76
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'delete', 'c1', '--force'], { account: undefined });
79
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'delete', pos('c1'), '--force'], { account: undefined });
77
80
  });
78
81
  });
79
82
 
80
83
  describe('gog_classroom_courses_archive', () => {
81
84
  it('calls runOrDiagnose with courseId', async () => {
82
85
  await harness.callTool('gog_classroom_courses_archive', { courseId: 'c1' });
83
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'archive', 'c1'], { account: undefined });
86
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'archive', pos('c1')], { account: undefined });
84
87
  });
85
88
  });
86
89
 
87
90
  describe('gog_classroom_courses_unarchive', () => {
88
91
  it('calls runOrDiagnose with courseId', async () => {
89
92
  await harness.callTool('gog_classroom_courses_unarchive', { courseId: 'c1' });
90
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'unarchive', 'c1'], { account: undefined });
93
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'unarchive', pos('c1')], { account: undefined });
91
94
  });
92
95
  });
93
96
 
94
97
  describe('gog_classroom_students_add', () => {
95
98
  it('calls runOrDiagnose with courseId and userId', async () => {
96
99
  await harness.callTool('gog_classroom_students_add', { courseId: 'c1', userId: 'u1' });
97
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'students', 'add', 'c1', 'u1'], { account: undefined });
100
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'students', 'add', pos('c1'), pos('u1')], { account: undefined });
98
101
  });
99
102
 
100
103
  it('passes --enrollment-code when provided', async () => {
101
104
  await harness.callTool('gog_classroom_students_add', { courseId: 'c1', userId: 'u1', enrollmentCode: 'abc123' });
102
105
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
103
- ['classroom', 'students', 'add', 'c1', 'u1', '--enrollment-code=abc123'],
106
+ ['classroom', 'students', 'add', pos('c1'), pos('u1'), '--enrollment-code=abc123'],
104
107
  { account: undefined },
105
108
  );
106
109
  });
@@ -109,21 +112,21 @@ describe('gog_classroom_students_add', () => {
109
112
  describe('gog_classroom_students_remove', () => {
110
113
  it('calls runOrDiagnose with courseId and userId', async () => {
111
114
  await harness.callTool('gog_classroom_students_remove', { courseId: 'c1', userId: 'u1' });
112
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'students', 'remove', 'c1', 'u1', '--force'], { account: undefined });
115
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'students', 'remove', pos('c1'), pos('u1'), '--force'], { account: undefined });
113
116
  });
114
117
  });
115
118
 
116
119
  describe('gog_classroom_teachers_add', () => {
117
120
  it('calls runOrDiagnose with courseId and userId', async () => {
118
121
  await harness.callTool('gog_classroom_teachers_add', { courseId: 'c1', userId: 'u1' });
119
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'teachers', 'add', 'c1', 'u1'], { account: undefined });
122
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'teachers', 'add', pos('c1'), pos('u1')], { account: undefined });
120
123
  });
121
124
  });
122
125
 
123
126
  describe('gog_classroom_teachers_remove', () => {
124
127
  it('calls runOrDiagnose with courseId and userId', async () => {
125
128
  await harness.callTool('gog_classroom_teachers_remove', { courseId: 'c1', userId: 'u1' });
126
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'teachers', 'remove', 'c1', 'u1', '--force'], { account: undefined });
129
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'teachers', 'remove', pos('c1'), pos('u1'), '--force'], { account: undefined });
127
130
  });
128
131
  });
129
132
 
@@ -131,7 +134,7 @@ describe('gog_classroom_coursework_create', () => {
131
134
  it('calls runOrDiagnose with required title only', async () => {
132
135
  await harness.callTool('gog_classroom_coursework_create', { courseId: 'c1', title: 'HW1' });
133
136
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
134
- ['classroom', 'coursework', 'create', 'c1', '--title=HW1'],
137
+ ['classroom', 'coursework', 'create', pos('c1'), '--title=HW1'],
135
138
  { account: undefined },
136
139
  );
137
140
  });
@@ -151,7 +154,7 @@ describe('gog_classroom_coursework_create', () => {
151
154
  topic: 't1',
152
155
  });
153
156
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
154
- ['classroom', 'coursework', 'create', 'c1', '--title=HW1', '--description=Chapter 1', '--type=ASSIGNMENT', '--state=PUBLISHED', '--max-points=100', '--due=2026-05-01T23:59', '--due-date=2026-05-01', '--due-time=23:59', '--scheduled=2026-04-30T12:00', '--topic=t1'],
157
+ ['classroom', 'coursework', 'create', pos('c1'), '--title=HW1', '--description=Chapter 1', '--type=ASSIGNMENT', '--state=PUBLISHED', '--max-points=100', '--due=2026-05-01T23:59', '--due-date=2026-05-01', '--due-time=23:59', '--scheduled=2026-04-30T12:00', '--topic=t1'],
155
158
  { account: undefined },
156
159
  );
157
160
  });
@@ -160,7 +163,7 @@ describe('gog_classroom_coursework_create', () => {
160
163
  describe('gog_classroom_coursework_update', () => {
161
164
  it('calls runOrDiagnose with ids only', async () => {
162
165
  await harness.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1' });
163
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'coursework', 'update', 'c1', 'w1'], { account: undefined });
166
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'coursework', 'update', pos('c1'), pos('w1')], { account: undefined });
164
167
  });
165
168
 
166
169
  it('passes all optional flags', async () => {
@@ -179,7 +182,7 @@ describe('gog_classroom_coursework_update', () => {
179
182
  topic: 't1',
180
183
  });
181
184
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
182
- ['classroom', 'coursework', 'update', 'c1', 'w1', '--title=New Title', '--description=Desc', '--type=SHORT_ANSWER_QUESTION', '--state=DRAFT', '--max-points=50', '--due=2026-05-01T23:59', '--due-date=2026-05-01', '--due-time=23:59', '--scheduled=2026-04-30T12:00', '--topic=t1'],
185
+ ['classroom', 'coursework', 'update', pos('c1'), pos('w1'), '--title=New Title', '--description=Desc', '--type=SHORT_ANSWER_QUESTION', '--state=DRAFT', '--max-points=50', '--due=2026-05-01T23:59', '--due-date=2026-05-01', '--due-time=23:59', '--scheduled=2026-04-30T12:00', '--topic=t1'],
183
186
  { account: undefined },
184
187
  );
185
188
  });
@@ -188,14 +191,14 @@ describe('gog_classroom_coursework_update', () => {
188
191
  describe('gog_classroom_coursework_delete', () => {
189
192
  it('calls runOrDiagnose with ids', async () => {
190
193
  await harness.callTool('gog_classroom_coursework_delete', { courseId: 'c1', courseworkId: 'w1' });
191
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'coursework', 'delete', 'c1', 'w1', '--force'], { account: undefined });
194
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'coursework', 'delete', pos('c1'), pos('w1'), '--force'], { account: undefined });
192
195
  });
193
196
  });
194
197
 
195
198
  describe('gog_classroom_announcements_update', () => {
196
199
  it('calls runOrDiagnose with ids only', async () => {
197
200
  await harness.callTool('gog_classroom_announcements_update', { courseId: 'c1', announcementId: 'a1' });
198
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'announcements', 'update', 'c1', 'a1'], { account: undefined });
201
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'announcements', 'update', pos('c1'), pos('a1')], { account: undefined });
199
202
  });
200
203
 
201
204
  it('passes all optional flags', async () => {
@@ -207,7 +210,7 @@ describe('gog_classroom_announcements_update', () => {
207
210
  scheduled: '2026-05-01T12:00',
208
211
  });
209
212
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
210
- ['classroom', 'announcements', 'update', 'c1', 'a1', '--text=edited', '--state=PUBLISHED', '--scheduled=2026-05-01T12:00'],
213
+ ['classroom', 'announcements', 'update', pos('c1'), pos('a1'), '--text=edited', '--state=PUBLISHED', '--scheduled=2026-05-01T12:00'],
211
214
  { account: undefined },
212
215
  );
213
216
  });
@@ -216,7 +219,7 @@ describe('gog_classroom_announcements_update', () => {
216
219
  describe('gog_classroom_announcements_delete', () => {
217
220
  it('calls runOrDiagnose with ids', async () => {
218
221
  await harness.callTool('gog_classroom_announcements_delete', { courseId: 'c1', announcementId: 'a1' });
219
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'announcements', 'delete', 'c1', 'a1', '--force'], { account: undefined });
222
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'announcements', 'delete', pos('c1'), pos('a1'), '--force'], { account: undefined });
220
223
  });
221
224
  });
222
225
 
@@ -224,7 +227,7 @@ describe('gog_classroom_topics_create', () => {
224
227
  it('calls runOrDiagnose with courseId and name', async () => {
225
228
  await harness.callTool('gog_classroom_topics_create', { courseId: 'c1', name: 'Week 1' });
226
229
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
227
- ['classroom', 'topics', 'create', 'c1', '--name=Week 1'],
230
+ ['classroom', 'topics', 'create', pos('c1'), '--name=Week 1'],
228
231
  { account: undefined },
229
232
  );
230
233
  });
@@ -234,7 +237,7 @@ describe('gog_classroom_topics_update', () => {
234
237
  it('calls runOrDiagnose with ids and name', async () => {
235
238
  await harness.callTool('gog_classroom_topics_update', { courseId: 'c1', topicId: 't1', name: 'Week 2' });
236
239
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
237
- ['classroom', 'topics', 'update', 'c1', 't1', '--name=Week 2'],
240
+ ['classroom', 'topics', 'update', pos('c1'), pos('t1'), '--name=Week 2'],
238
241
  { account: undefined },
239
242
  );
240
243
  });
@@ -243,15 +246,64 @@ describe('gog_classroom_topics_update', () => {
243
246
  describe('gog_classroom_topics_delete', () => {
244
247
  it('calls runOrDiagnose with ids', async () => {
245
248
  await harness.callTool('gog_classroom_topics_delete', { courseId: 'c1', topicId: 't1' });
246
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'topics', 'delete', 'c1', 't1', '--force'], { account: undefined });
249
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'topics', 'delete', pos('c1'), pos('t1'), '--force'], { account: undefined });
247
250
  });
248
251
  });
249
252
 
250
253
  describe('gog_classroom_invitations_create', () => {
254
+ const COURSE = rawTextResult(JSON.stringify({ course: { id: 'c1', name: 'Algebra II' } }));
255
+
256
+ it('reads the course and prompts with the class, invitee and role', async () => {
257
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(COURSE);
258
+ let message = '';
259
+ const h = await createTestHarness(registerExtraClassroomTools, {
260
+ elicitation: async (r) => { message = r.params.message; return { action: 'decline' }; },
261
+ });
262
+ await h.callTool('gog_classroom_invitations_create', { courseId: 'c1', userId: 'kid@example.com', role: 'OWNER' });
263
+ expect(message).toMatch(/OWNERSHIP/);
264
+ expect(JSON.parse(message.split('\n').slice(1).join('\n')).details)
265
+ .toEqual({ course: { id: 'c1', name: 'Algebra II' }, invitee: 'kid@example.com', role: 'OWNER' });
266
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'courses', 'get', pos('c1')], { account: undefined });
267
+ expect(lib.runOrDiagnose).not.toHaveBeenCalledWith(
268
+ ['classroom', 'invitations', 'create', pos('c1'), pos('kid@example.com'), '--role=OWNER'], { account: undefined });
269
+ });
270
+
271
+ it('refuses a client that cannot be prompted', async () => {
272
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(COURSE);
273
+ const h = await createTestHarness(registerExtraClassroomTools);
274
+ const r = JSON.parse((await h.callTool('gog_classroom_invitations_create', { courseId: 'c1', userId: 'u1', role: 'STUDENT' })).content[0]!.text as string);
275
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'classroom.invitation-create' });
276
+ expect(r.note).toMatch(/invite them from Classroom/);
277
+ });
278
+
279
+ it('token fallback: phase 1 previews, phase 2 invites', async () => {
280
+ const ORIGINAL = { ...process.env };
281
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
282
+ try {
283
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(COURSE);
284
+ const h = await createTestHarness(registerExtraClassroomTools);
285
+ const args = { courseId: 'c1', userId: 'u1', role: 'STUDENT' as const };
286
+ const p1 = JSON.parse((await h.callTool('gog_classroom_invitations_create', args)).content[0]!.text as string);
287
+ expect(p1.preview).toEqual({ course: { id: 'c1', name: 'Algebra II' }, invitee: 'u1', role: 'STUDENT' });
288
+ await h.callTool('gog_classroom_invitations_create', { ...args, confirmToken: p1.confirmToken });
289
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
290
+ ['classroom', 'invitations', 'create', pos('c1'), pos('u1'), '--role=STUDENT'], { account: undefined });
291
+ } finally {
292
+ process.env = ORIGINAL;
293
+ }
294
+ });
295
+
296
+ it('returns a failed course read without inviting', async () => {
297
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue({ content: [{ type: 'text', text: 'Error: not found' }], isError: true });
298
+ const r = await harness.callTool('gog_classroom_invitations_create', { courseId: 'c1', userId: 'u1', role: 'STUDENT' });
299
+ expect(r.isError).toBe(true);
300
+ expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
301
+ });
302
+
251
303
  it('calls runOrDiagnose with courseId, userId, role', async () => {
252
304
  await harness.callTool('gog_classroom_invitations_create', { courseId: 'c1', userId: 'u1', role: 'STUDENT' });
253
305
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
254
- ['classroom', 'invitations', 'create', 'c1', 'u1', '--role=STUDENT'],
306
+ ['classroom', 'invitations', 'create', pos('c1'), pos('u1'), '--role=STUDENT'],
255
307
  { account: undefined },
256
308
  );
257
309
  });
@@ -260,6 +312,6 @@ describe('gog_classroom_invitations_create', () => {
260
312
  describe('gog_classroom_invitations_delete', () => {
261
313
  it('calls runOrDiagnose with invitationId', async () => {
262
314
  await harness.callTool('gog_classroom_invitations_delete', { invitationId: 'i1' });
263
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'invitations', 'delete', 'i1', '--force'], { account: undefined });
315
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['classroom', 'invitations', 'delete', pos('i1'), '--force'], { account: undefined });
264
316
  });
265
317
  });
package/vitest.config.ts CHANGED
@@ -2,6 +2,11 @@ import { defineConfig } from 'vitest/config';
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ // The arg-shape tests pass arbitrary server paths; confinement is exercised
6
+ // explicitly by the tests that narrow GOG_FILE_ROOTS themselves.
7
+ env: {
8
+ GOG_FILE_ROOTS: '/',
9
+ },
5
10
  coverage: {
6
11
  provider: 'v8',
7
12
  include: ['src/**/*.ts'],