gogcli-mcp 4.5.0 → 4.5.1
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 +537 -117
- package/dist/lib.js +576 -117
- package/manifest.json +7 -7
- package/package.json +2 -2
- 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/utils.ts
CHANGED
|
@@ -302,10 +302,11 @@ export function formatAccountList(raw: string): string {
|
|
|
302
302
|
|
|
303
303
|
// Turn a thrown error into a diagnosed error result (`isError: true`): the
|
|
304
304
|
// error text, an actionable hint when the failure class is recognised (auth /
|
|
305
|
-
// transient / off-grid write), and the list of
|
|
306
|
-
// that need to surface a failure without going
|
|
307
|
-
// pre-write verification read that must abort)
|
|
308
|
-
// keeps the same diagnostic quality as everywhere
|
|
305
|
+
// transient / off-grid write), and — on an auth failure only — the list of
|
|
306
|
+
// configured accounts. Callers that need to surface a failure without going
|
|
307
|
+
// through runOrDiagnose (e.g. a pre-write verification read that must abort)
|
|
308
|
+
// can reuse this so the error keeps the same diagnostic quality as everywhere
|
|
309
|
+
// else.
|
|
309
310
|
export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
310
311
|
const errText = errorText(err);
|
|
311
312
|
|
|
@@ -333,6 +334,11 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
|
333
334
|
: isGridLimitError
|
|
334
335
|
? GRID_LIMIT_HINT
|
|
335
336
|
: '';
|
|
337
|
+
// The account list answers one question — WHICH account is signed in — so it
|
|
338
|
+
// is appended only when the failure is about that (PRIV-1, fleet-audit
|
|
339
|
+
// #1012). Every other error used to echo every identity on the host to the
|
|
340
|
+
// model, and spawn a `gog auth list`, for nothing.
|
|
341
|
+
if (!isAuthError && !isInvalidGrant) return errorResult(`${errText}${hint}`);
|
|
336
342
|
try {
|
|
337
343
|
const accounts = formatAccountList(await run(['auth', 'list']));
|
|
338
344
|
return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
|
|
@@ -94,7 +94,7 @@ describe('logGmailDispatch', () => {
|
|
|
94
94
|
logGmailDispatch('gog_gmail_reply', ['me@example.com', 'outside@other.com'], 'me@example.com');
|
|
95
95
|
const event = loggedEvent();
|
|
96
96
|
expect(event.hasExternalRecipients).toBe(true);
|
|
97
|
-
expect(event.
|
|
97
|
+
expect(event.externalDomains).toEqual(['other.com']);
|
|
98
98
|
expect(event.externalRecipientCount).toBe(1);
|
|
99
99
|
});
|
|
100
100
|
|
|
@@ -102,34 +102,34 @@ describe('logGmailDispatch', () => {
|
|
|
102
102
|
process.env.GOG_ACCOUNT = 'me@example.com';
|
|
103
103
|
logGmailDispatch('gog_gmail_send', ['me@example.com', 'outside@other.com']);
|
|
104
104
|
const event = loggedEvent();
|
|
105
|
-
expect(event.
|
|
105
|
+
expect(event.externalDomains).toEqual(['other.com']);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
it('adds every domain in GOG_GMAIL_TRUSTED_DOMAINS on top of the account domain', () => {
|
|
109
109
|
process.env.GOG_GMAIL_TRUSTED_DOMAINS = 'law-firm.example, partner.example';
|
|
110
110
|
logGmailDispatch('gog_gmail_reply_all', ['counsel@law-firm.example', 'stranger@random.example'], 'me@example.com');
|
|
111
111
|
const event = loggedEvent();
|
|
112
|
-
expect(event.
|
|
112
|
+
expect(event.externalDomains).toEqual(['random.example']);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
it('reports no external recipients when every address is trusted', () => {
|
|
116
116
|
logGmailDispatch('gog_gmail_forward', ['me@example.com'], 'me@example.com');
|
|
117
117
|
const event = loggedEvent();
|
|
118
118
|
expect(event.hasExternalRecipients).toBe(false);
|
|
119
|
-
expect(event.
|
|
119
|
+
expect(event.externalDomains).toEqual([]);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it('treats an account with no @ as contributing no trusted domain', () => {
|
|
123
123
|
logGmailDispatch('gog_gmail_send', ['a@example.com'], 'not-an-email');
|
|
124
124
|
const event = loggedEvent();
|
|
125
|
-
expect(event.
|
|
125
|
+
expect(event.externalDomains).toEqual(['example.com']);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
it('ignores an empty segment in GOG_GMAIL_TRUSTED_DOMAINS (e.g. a trailing comma)', () => {
|
|
129
129
|
process.env.GOG_GMAIL_TRUSTED_DOMAINS = 'law-firm.example,,';
|
|
130
130
|
logGmailDispatch('gog_gmail_send', ['counsel@law-firm.example'], 'me@example.com');
|
|
131
131
|
const event = loggedEvent();
|
|
132
|
-
expect(event.
|
|
132
|
+
expect(event.externalDomains).toEqual([]);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
it('treats unresolved desktop placeholders as unset env vars', () => {
|
|
@@ -137,13 +137,29 @@ describe('logGmailDispatch', () => {
|
|
|
137
137
|
process.env.GOG_ACCOUNT = '${user_config.account}';
|
|
138
138
|
logGmailDispatch('gog_gmail_send', ['person@user_config.trusted_domains']);
|
|
139
139
|
const event = loggedEvent();
|
|
140
|
-
expect(event.
|
|
140
|
+
expect(event.externalDomains).toEqual(['user_config.trusted_domains']);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
it('treats a recipient with no @ as external, not a crash', () => {
|
|
144
144
|
logGmailDispatch('gog_gmail_send', ['not-an-email'], 'me@example.com');
|
|
145
145
|
const event = loggedEvent();
|
|
146
|
-
expect(event.
|
|
146
|
+
expect(event.externalRecipientCount).toBe(1);
|
|
147
|
+
expect(event.externalDomains).toEqual(['(no domain)']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// PRIV-1 (fleet-audit #1012): on mcp-host this line is host log data the user
|
|
151
|
+
// never sees, so it names domains, never addresses — the count and the
|
|
152
|
+
// domain are what the audit purpose needs (an unexpected law firm, a
|
|
153
|
+
// wrong-number alias), the local part is a third party's identity.
|
|
154
|
+
it('never writes a recipient address to stderr', () => {
|
|
155
|
+
logGmailDispatch('gog_gmail_reply_all', ['me@example.com', 'counsel@law-firm.example', 'second@law-firm.example', 'Kid@School.example'], 'me@example.com');
|
|
156
|
+
const line = writeSpy.mock.calls[0][0] as string;
|
|
157
|
+
expect(line).not.toMatch(/@/);
|
|
158
|
+
expect(line).not.toContain('externalRecipients');
|
|
159
|
+
const event = loggedEvent();
|
|
160
|
+
expect(event.recipientCount).toBe(4);
|
|
161
|
+
expect(event.externalRecipientCount).toBe(3);
|
|
162
|
+
expect(event.externalDomains).toEqual(['law-firm.example', 'school.example']);
|
|
147
163
|
});
|
|
148
164
|
});
|
|
149
165
|
|
package/tests/tools/api.test.ts
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
|
|
2
3
|
import { registerApiTools } from '../../src/tools/api.js';
|
|
3
4
|
import * as runner from '../../src/runner.js';
|
|
4
5
|
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
6
|
import { pos } from '../../src/argv.js';
|
|
7
|
+
import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
|
|
8
|
+
import { CONFIRM_ACTION_INSTRUCTION } from '../../src/dispatch-confirmation.js';
|
|
6
9
|
|
|
7
10
|
vi.mock('../../src/runner.js');
|
|
8
11
|
|
|
9
12
|
const setupHandlers = () => createTestHarness(registerApiTools);
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
/** A client that CAN be prompted, answering with `answer` and recording the prompt. */
|
|
15
|
+
async function prompted(answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
|
|
16
|
+
const seen: ElicitRequest[] = [];
|
|
17
|
+
const harness = await createTestHarness(registerApiTools, { elicitation: async (r) => { seen.push(r); return answer; } });
|
|
18
|
+
const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
|
|
19
|
+
return { harness, seen, details };
|
|
20
|
+
}
|
|
21
|
+
const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
|
|
22
|
+
|
|
23
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
process.env = { ...ORIGINAL_ENV };
|
|
28
|
+
delete process.env.MCP_CONFIRM_MODE;
|
|
29
|
+
delete process.env.MCP_CONFIRM_TTL_SECONDS;
|
|
30
|
+
delete process.env.MCP_CONFIRM_SECRET;
|
|
31
|
+
resetConfirmTokenState();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => { process.env = ORIGINAL_ENV; });
|
|
12
35
|
|
|
13
36
|
describe('gog_api_list', () => {
|
|
14
37
|
it('lists the default API set', async () => {
|
|
@@ -76,7 +99,7 @@ describe('gog_api_call', () => {
|
|
|
76
99
|
await harness.callTool('gog_api_call', {
|
|
77
100
|
api: 'drive', version: 'v3', method: 'files.create',
|
|
78
101
|
params: '{"fields":"id"}', body: '{"name":"f"}', scope: 'https://www.googleapis.com/auth/drive',
|
|
79
|
-
allowWrite: true, dryRun: true, account: 'a@
|
|
102
|
+
allowWrite: true, dryRun: true, account: 'a@example.com',
|
|
80
103
|
});
|
|
81
104
|
expect(runner.run).toHaveBeenCalledWith(
|
|
82
105
|
[
|
|
@@ -84,13 +107,13 @@ describe('gog_api_call', () => {
|
|
|
84
107
|
'--params={"fields":"id"}', '--body={"name":"f"}',
|
|
85
108
|
'--scope=https://www.googleapis.com/auth/drive', '--allow-write', '--dry-run', '--force',
|
|
86
109
|
],
|
|
87
|
-
{ account: 'a@
|
|
110
|
+
{ account: 'a@example.com', gmailNoSend: true },
|
|
88
111
|
);
|
|
89
112
|
});
|
|
90
113
|
|
|
91
|
-
it('appends --force last even when dry-run is absent', async () => {
|
|
114
|
+
it('appends --force last even when dry-run is absent, once the user has confirmed', async () => {
|
|
92
115
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
93
|
-
const harness = await
|
|
116
|
+
const { harness } = await prompted();
|
|
94
117
|
await harness.callTool('gog_api_call', {
|
|
95
118
|
api: 'drive', version: 'v3', method: 'files.create', allowWrite: true,
|
|
96
119
|
});
|
|
@@ -99,6 +122,108 @@ describe('gog_api_call', () => {
|
|
|
99
122
|
expect(passedArgs[passedArgs.length - 1]).toBe('--force');
|
|
100
123
|
});
|
|
101
124
|
|
|
125
|
+
// SEC-2 (fleet-audit #931): allowWrite is a boolean the MODEL sets, and
|
|
126
|
+
// --force skips gog's own confirmation, so a method blocklist could never be
|
|
127
|
+
// complete (events.move/delete, acl.insert, sendAs.create, updateVacation,
|
|
128
|
+
// batchDelete, ...). Every write now asks the user about the exact
|
|
129
|
+
// api/method/params/body before anything is sent.
|
|
130
|
+
describe('the write gate', () => {
|
|
131
|
+
const write = {
|
|
132
|
+
api: 'calendar', version: 'v3', method: 'events.delete',
|
|
133
|
+
params: '{"calendarId":"primary","eventId":"e1","sendUpdates":"all"}',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
it('prompts with the api, version, method, params and body, then runs once accepted', async () => {
|
|
137
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
138
|
+
const { harness, details, seen } = await prompted();
|
|
139
|
+
await harness.callTool('gog_api_call', { ...write, body: '{"a":1}', scope: 'https://www.googleapis.com/auth/calendar', allowWrite: true, account: 'a@example.com' });
|
|
140
|
+
expect(seen[0]!.params.message).toMatch(/calendar v3 events\.delete/);
|
|
141
|
+
expect(details()).toEqual({
|
|
142
|
+
api: 'calendar', version: 'v3', method: 'events.delete',
|
|
143
|
+
params: { calendarId: 'primary', eventId: 'e1', sendUpdates: 'all' },
|
|
144
|
+
body: { a: 1 },
|
|
145
|
+
scope: 'https://www.googleapis.com/auth/calendar',
|
|
146
|
+
});
|
|
147
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
148
|
+
['api', 'call', pos('calendar'), pos('v3'), pos('events.delete'), `--params=${write.params}`, '--body={"a":1}',
|
|
149
|
+
'--scope=https://www.googleapis.com/auth/calendar', '--allow-write', '--force'],
|
|
150
|
+
{ account: 'a@example.com', gmailNoSend: true },
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('shows a body that is not JSON (an @file reference) as the string it is', async () => {
|
|
155
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
156
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
157
|
+
await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.create', body: '@/body.json', params: 'not json', allowWrite: true });
|
|
158
|
+
expect(details()).toEqual({ api: 'drive', version: 'v3', method: 'files.create', params: 'not json', body: '@/body.json' });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('bounds an oversized body in the prompt, marking the cut', async () => {
|
|
162
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
163
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
164
|
+
const body = JSON.stringify({ text: 'x'.repeat(3000) });
|
|
165
|
+
await harness.callTool('gog_api_call', { api: 'docs', version: 'v1', method: 'documents.batchUpdate', body, allowWrite: true });
|
|
166
|
+
const shown = details().body as string;
|
|
167
|
+
expect(shown.startsWith(body.slice(0, 2048))).toBe(true);
|
|
168
|
+
expect(shown).toMatch(/more characters not shown\]$/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('runs nothing when the user declines', async () => {
|
|
172
|
+
const { harness } = await prompted({ action: 'decline' });
|
|
173
|
+
const r = json(await harness.callTool('gog_api_call', { ...write, allowWrite: true }));
|
|
174
|
+
expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'api.call' });
|
|
175
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it.each([
|
|
179
|
+
['a read', { api: 'calendar', version: 'v3', method: 'events.list' }],
|
|
180
|
+
['a dry run', { ...write, allowWrite: true, dryRun: true }],
|
|
181
|
+
['a write without allowWrite (gog refuses it itself)', { ...write }],
|
|
182
|
+
])('does not ask for %s', async (_label, args) => {
|
|
183
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
184
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
185
|
+
const harness = await setupHandlers();
|
|
186
|
+
const r = await harness.callTool('gog_api_call', args);
|
|
187
|
+
expect(r.isError).toBeFalsy();
|
|
188
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('refuses a client that cannot be prompted under MCP_CONFIRM_MODE=refuse, naming the way through', async () => {
|
|
192
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
193
|
+
const harness = await setupHandlers();
|
|
194
|
+
const r = json(await harness.callTool('gog_api_call', { ...write, allowWrite: true }));
|
|
195
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'api.call' });
|
|
196
|
+
expect(r.note).toMatch(/dryRun/);
|
|
197
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('token fallback: previews in full, refuses a changed payload, runs on phase 2', async () => {
|
|
201
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
202
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
203
|
+
const harness = await setupHandlers();
|
|
204
|
+
const args = { ...write, body: '{"a":1}', allowWrite: true };
|
|
205
|
+
const p1 = json(await harness.callTool('gog_api_call', args));
|
|
206
|
+
expect(p1).toMatchObject({
|
|
207
|
+
status: 'confirmation-required',
|
|
208
|
+
preview: {
|
|
209
|
+
api: 'calendar', version: 'v3', method: 'events.delete',
|
|
210
|
+
params: { calendarId: 'primary', eventId: 'e1', sendUpdates: 'all' },
|
|
211
|
+
body: { a: 1 },
|
|
212
|
+
},
|
|
213
|
+
instruction: CONFIRM_ACTION_INSTRUCTION,
|
|
214
|
+
});
|
|
215
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
216
|
+
expect(json(await harness.callTool('gog_api_call', { ...args, body: '{"a":2}', confirmToken: p1.confirmToken })))
|
|
217
|
+
.toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
218
|
+
expect(json(await harness.callTool('gog_api_call', { ...args, params: '{"calendarId":"primary","eventId":"e2"}', confirmToken: p1.confirmToken })))
|
|
219
|
+
.toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
220
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
221
|
+
await harness.callTool('gog_api_call', { ...args, confirmToken: p1.confirmToken });
|
|
222
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--allow-write');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
102
227
|
it('returns error text on failure', async () => {
|
|
103
228
|
vi.mocked(runner.run).mockRejectedValue(new Error('Call failed'));
|
|
104
229
|
const harness = await setupHandlers();
|
|
@@ -7,6 +7,10 @@ import { pos } from '../../src/argv.js';
|
|
|
7
7
|
vi.mock('../../src/runner.js');
|
|
8
8
|
|
|
9
9
|
const setupHandlers = () => createTestHarness(registerAppScriptTools);
|
|
10
|
+
// gog_appscript_run_function asks first (dispatch-gates-more.test.ts covers the prompt itself).
|
|
11
|
+
const acceptingHandlers = () => createTestHarness(registerAppScriptTools, {
|
|
12
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
13
|
+
});
|
|
10
14
|
|
|
11
15
|
beforeEach(() => vi.clearAllMocks());
|
|
12
16
|
|
|
@@ -114,14 +118,14 @@ describe('gog_appscript_versions', () => {
|
|
|
114
118
|
describe('gog_appscript_run_function', () => {
|
|
115
119
|
it('runs a deployed function', async () => {
|
|
116
120
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
117
|
-
const harness = await
|
|
121
|
+
const harness = await acceptingHandlers();
|
|
118
122
|
await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' });
|
|
119
123
|
expect(runner.run).toHaveBeenCalledWith(['appscript', 'run', pos('S1'), pos('doWork')], { account: undefined });
|
|
120
124
|
});
|
|
121
125
|
|
|
122
126
|
it('passes params and --dev-mode', async () => {
|
|
123
127
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
124
|
-
const harness = await
|
|
128
|
+
const harness = await acceptingHandlers();
|
|
125
129
|
await harness.callTool('gog_appscript_run_function', {
|
|
126
130
|
scriptId: 'S1', functionName: 'doWork', params: '["a",1]', devMode: true,
|
|
127
131
|
});
|