gogcli-mcp 2.7.1 → 2.18.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +20220 -19700
- package/dist/lib.js +15847 -23331
- package/manifest.json +33 -1
- package/package.json +7 -6
- package/server.json +2 -2
- package/src/connector-auth.ts +46 -0
- package/src/connector-runtime.ts +177 -0
- package/src/index.ts +7 -5
- package/src/lib.ts +8 -7
- package/src/runner.ts +225 -30
- package/src/server.ts +21 -25
- package/src/tools/api.ts +65 -0
- package/src/tools/auth.ts +112 -15
- package/src/tools/calendar.ts +24 -9
- package/src/tools/docs.ts +30 -3
- package/src/tools/drive.ts +124 -1
- package/src/tools/gmail.ts +7 -3
- package/src/tools/sheets.ts +6 -3
- package/src/tools/slides.ts +9 -4
- package/src/tools/tasks.ts +3 -1
- package/src/tools/utils.ts +163 -27
- package/src/worker.ts +99 -0
- package/tests/connector-auth.test.ts +28 -0
- package/tests/connector-runtime.test.ts +474 -0
- package/tests/runner-file-args-failure.test.ts +94 -0
- package/tests/runner-file-args.test.ts +232 -0
- package/tests/runner.test.ts +221 -13
- package/tests/server.test.ts +28 -28
- package/tests/tools/api.test.ts +107 -0
- package/tests/tools/auth.test.ts +187 -31
- package/tests/tools/calendar.test.ts +115 -52
- package/tests/tools/classroom.test.ts +77 -77
- package/tests/tools/contacts.test.ts +24 -24
- package/tests/tools/docs.test.ts +93 -44
- package/tests/tools/drive.test.ts +226 -55
- package/tests/tools/gmail.test.ts +61 -28
- package/tests/tools/sheets.test.ts +81 -70
- package/tests/tools/slides.test.ts +56 -36
- package/tests/tools/tasks.test.ts +33 -33
- package/tests/tools/utils.test.ts +116 -2
- package/tests/worker.test.ts +142 -0
- package/tsconfig.json +4 -1
- package/vitest.config.ts +33 -2
- package/tests/helpers/test-harness.ts +0 -27
package/src/runner.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import type { ChildProcess } from 'node:child_process';
|
|
3
|
-
import { delimiter } from 'node:path';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
import { parseBoolEnv, readEnvVar, redactSecrets as redactSharedSecrets } from '@chrischall/mcp-utils';
|
|
4
5
|
|
|
5
6
|
export type Spawner = (
|
|
6
7
|
command: string,
|
|
@@ -8,11 +9,64 @@ export type Spawner = (
|
|
|
8
9
|
options: { env: NodeJS.ProcessEnv },
|
|
9
10
|
) => ChildProcess;
|
|
10
11
|
|
|
12
|
+
// A payload too large to live in argv. Every argv element is capped — the Fly
|
|
13
|
+
// runner rejects args over 4 KiB, and the Linux kernel hard-caps a single argv
|
|
14
|
+
// string at MAX_ARG_STRLEN (128 KiB) regardless of ARG_MAX — so big values
|
|
15
|
+
// (a long HTML mail body, slide notes) must leave argv entirely. gog exposes
|
|
16
|
+
// `--x-file` companions for exactly these flags; the executor writes the
|
|
17
|
+
// payload to a private temp file and passes the path instead.
|
|
18
|
+
export interface GogFileArg {
|
|
19
|
+
/** Discriminant separating this from a plain argv string. */
|
|
20
|
+
kind: 'file';
|
|
21
|
+
/** Flag NAME without leading dashes, e.g. 'body-html-file'. */
|
|
22
|
+
flag: string;
|
|
23
|
+
/** The large payload, verbatim. */
|
|
24
|
+
contents: string;
|
|
25
|
+
/** Temp-file extension without the dot, e.g. 'html'. Defaults to 'txt'. */
|
|
26
|
+
ext?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type GogArg = string | GogFileArg;
|
|
30
|
+
|
|
31
|
+
export function isGogFileArg(arg: GogArg): arg is GogFileArg {
|
|
32
|
+
return typeof arg !== 'string';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// An executor runs a FULLY-ASSEMBLED gog arg list (already including
|
|
36
|
+
// --json/--no-input/--color=never, --account, --readonly, and the service
|
|
37
|
+
// subcommand) and returns its stdout as a string (or throws). This is the
|
|
38
|
+
// injection seam that lets the same tool registrars run either by spawning
|
|
39
|
+
// `gog` (stdio transport) or by forwarding the arg list to a remote HTTP
|
|
40
|
+
// backend (hosted Cloudflare-Worker connector, which cannot spawn processes).
|
|
41
|
+
// Elements may be GogFileArgs; EVERY executor is responsible for materializing
|
|
42
|
+
// them to a private temp file and removing that file afterwards.
|
|
43
|
+
export type GogExecutor = (
|
|
44
|
+
args: GogArg[],
|
|
45
|
+
opts: { timeout?: number; interactive?: boolean },
|
|
46
|
+
) => Promise<string>;
|
|
47
|
+
|
|
48
|
+
// Ambient override for the executor `run()` uses when no options.spawner is
|
|
49
|
+
// given. The Worker/Fly path wraps request handling in
|
|
50
|
+
// `runExecutor.run({ executor }, ...)`; unset, `run()` falls back to spawning.
|
|
51
|
+
export const runExecutor = new AsyncLocalStorage<{ executor: GogExecutor }>();
|
|
52
|
+
|
|
11
53
|
export interface RunOptions {
|
|
12
54
|
account?: string;
|
|
13
55
|
spawner?: Spawner;
|
|
14
56
|
interactive?: boolean;
|
|
15
57
|
timeout?: number;
|
|
58
|
+
// Inject gog's global --readonly flag, which blocks mutating API requests at
|
|
59
|
+
// runtime. Independent of (and OR-ed with) the GOG_READONLY env var.
|
|
60
|
+
readonly?: boolean;
|
|
61
|
+
// How aggressively to redact the output/error before it reaches the client.
|
|
62
|
+
// 'full' (default) runs the shared mcp-utils redactor plus the Google token
|
|
63
|
+
// shapes. 'tokens' runs ONLY the Google token shapes (ya29.…/1//…) — use it
|
|
64
|
+
// for output that is known-safe but that the broad shared redactor mangles,
|
|
65
|
+
// most notably an OAuth consent URL whose `classroom.coursework.students`-style
|
|
66
|
+
// scope names the shared redactor mistakes for secrets. A step-1 auth URL
|
|
67
|
+
// carries no token, so stripping only real token shapes keeps it intact while
|
|
68
|
+
// still catching any token that unexpectedly appears.
|
|
69
|
+
redactMode?: 'full' | 'tokens';
|
|
16
70
|
}
|
|
17
71
|
|
|
18
72
|
const TIMEOUT_MS = 30_000;
|
|
@@ -23,16 +77,17 @@ const TIMEOUT_MS = 30_000;
|
|
|
23
77
|
// so the requirement change is surfaced in the release notes (see
|
|
24
78
|
// .github/release.yml). This is the single source of truth for the required
|
|
25
79
|
// version; keep the README/CLAUDE.md mention in sync.
|
|
26
|
-
export const MIN_GOG_VERSION = '0.
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
80
|
+
export const MIN_GOG_VERSION = '0.34.1';
|
|
81
|
+
|
|
82
|
+
// Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
|
|
83
|
+
// values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
|
|
84
|
+
// ("${user_config.gog_readonly}") as unset. On top of that, GOG_READONLY is
|
|
85
|
+
// deliberately fail-safe: any *set* value that isn't an explicit off value
|
|
86
|
+
// (0/false/no/off) enables readonly — parseBoolEnv's `default: true` covers
|
|
87
|
+
// unrecognised values (e.g. "enable"), so a typo blocks writes instead of
|
|
88
|
+
// silently allowing them.
|
|
89
|
+
function readonlyEnvEnabled(): boolean {
|
|
90
|
+
return readEnvVar('GOG_READONLY') !== undefined && parseBoolEnv('GOG_READONLY', { default: true });
|
|
36
91
|
}
|
|
37
92
|
|
|
38
93
|
// Strip ambient secrets from the child env so gogcli only sees its own
|
|
@@ -55,19 +110,25 @@ function sanitizedEnv(): NodeJS.ProcessEnv {
|
|
|
55
110
|
// Redact bearer/refresh-token patterns from error text before surfacing
|
|
56
111
|
// it back to the MCP client. If gog ever emits a token in stderr (e.g.
|
|
57
112
|
// from a verbose log mode), this prevents it from leaking to the model.
|
|
58
|
-
|
|
59
|
-
|
|
113
|
+
// The shared mcp-utils redactSecrets covers Bearer/Basic headers, JWTs,
|
|
114
|
+
// cookies, well-known key shapes (incl. Google AIza… API keys), and secret
|
|
115
|
+
// query params — but not Google's OAuth2 token shapes, so those stay here.
|
|
116
|
+
const GOOGLE_TOKEN_PATTERNS: RegExp[] = [
|
|
60
117
|
/ya29\.[A-Za-z0-9._\-]+/g, // OAuth2 access tokens
|
|
61
118
|
/1\/\/[A-Za-z0-9._\-]+/g, // OAuth2 refresh tokens
|
|
62
|
-
/AIza[A-Za-z0-9_\-]{35}/g, // Google API keys
|
|
63
119
|
];
|
|
64
|
-
|
|
120
|
+
// Strip only Google's OAuth2 token shapes. Precise enough to leave an OAuth
|
|
121
|
+
// consent URL (client_id, scope names, state, code_challenge) untouched.
|
|
122
|
+
export function redactGoogleTokens(text: string): string {
|
|
65
123
|
let redacted = text;
|
|
66
|
-
for (const re of
|
|
124
|
+
for (const re of GOOGLE_TOKEN_PATTERNS) {
|
|
67
125
|
redacted = redacted.replace(re, '[REDACTED]');
|
|
68
126
|
}
|
|
69
127
|
return redacted;
|
|
70
128
|
}
|
|
129
|
+
export function redactSecrets(text: string): string {
|
|
130
|
+
return redactGoogleTokens(redactSharedSecrets(text));
|
|
131
|
+
}
|
|
71
132
|
|
|
72
133
|
// MCP desktop clients often spawn servers with a stripped PATH that excludes
|
|
73
134
|
// Homebrew, user-local, and Go's default install dirs — so even when gog is
|
|
@@ -104,25 +165,78 @@ function formatTimeout(ms: number): string {
|
|
|
104
165
|
return `${ms}ms`;
|
|
105
166
|
}
|
|
106
167
|
|
|
107
|
-
|
|
108
|
-
|
|
168
|
+
// Write every GogFileArg to a private temp file, run gog against the resulting
|
|
169
|
+
// plain argv, and remove the temp dir afterwards — on success, on a non-zero
|
|
170
|
+
// exit, and on timeout alike. A leaked temp file holds user email content.
|
|
171
|
+
//
|
|
172
|
+
// node:fs/promises and node:os are imported LAZILY (matching the lazy
|
|
173
|
+
// node:child_process import below) so a Cloudflare Worker importing this module
|
|
174
|
+
// doesn't eagerly pull node builtins, which would break the Worker bundle.
|
|
175
|
+
async function spawnWithTempFiles(
|
|
176
|
+
args: GogArg[],
|
|
177
|
+
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
|
|
178
|
+
): Promise<string> {
|
|
179
|
+
const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
|
|
180
|
+
const { tmpdir } = await import('node:os');
|
|
109
181
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
182
|
+
// mkdtemp creates the directory with mode 0700 (owner-only) on POSIX, so the
|
|
183
|
+
// payload is never world-readable, not even for the instant between the
|
|
184
|
+
// directory appearing and writeFile's own 0600 mode landing.
|
|
185
|
+
const dir = await mkdtemp(join(tmpdir(), 'gogcli-mcp-'));
|
|
186
|
+
try {
|
|
187
|
+
const argv: string[] = [];
|
|
188
|
+
for (const arg of args) {
|
|
189
|
+
if (!isGogFileArg(arg)) {
|
|
190
|
+
argv.push(arg);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Name the file after the flag: one command can carry two payloads
|
|
194
|
+
// (e.g. --body-file and --signature-file, both .txt), and a fixed
|
|
195
|
+
// basename would have the second silently clobber the first.
|
|
196
|
+
const path = join(dir, `${arg.flag}.${arg.ext ?? 'txt'}`);
|
|
197
|
+
await writeFile(path, arg.contents, { encoding: 'utf8', mode: 0o600 });
|
|
198
|
+
argv.push(`--${arg.flag}=${path}`);
|
|
199
|
+
}
|
|
200
|
+
return await spawnGog(argv, opts);
|
|
201
|
+
} finally {
|
|
202
|
+
// Never let a cleanup failure mask the real gog error (or a real result).
|
|
203
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
115
204
|
}
|
|
116
|
-
|
|
117
|
-
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Spawn-based executor. Deliberately NOT async: when no element is a
|
|
208
|
+
// GogFileArg (the overwhelmingly common case) it must create no temp dir and
|
|
209
|
+
// introduce no extra microtask tick before `spawn` is called — the spawn has
|
|
210
|
+
// to happen synchronously within the `run()` call, which the fake-timer tests
|
|
211
|
+
// in tests/runner.test.ts depend on.
|
|
212
|
+
function spawnExecutor(
|
|
213
|
+
args: GogArg[],
|
|
214
|
+
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
|
|
215
|
+
): Promise<string> {
|
|
216
|
+
if (args.some(isGogFileArg)) {
|
|
217
|
+
return spawnWithTempFiles(args, opts);
|
|
118
218
|
}
|
|
119
|
-
|
|
219
|
+
return spawnGog(args as string[], opts);
|
|
220
|
+
}
|
|
120
221
|
|
|
222
|
+
// Owns everything process-specific — building the sanitized child env, PATH
|
|
223
|
+
// augmentation, spawning, collecting stdout/stderr, and the timeout kill. It
|
|
224
|
+
// returns raw output (no redaction — `run()` wraps that around whichever
|
|
225
|
+
// executor runs). The child_process import is LAZY so a Cloudflare Worker
|
|
226
|
+
// importing this module doesn't eagerly pull node:child_process (which would
|
|
227
|
+
// break the Worker bundle); the injected `spawner` bypasses it.
|
|
228
|
+
|
|
229
|
+
async function spawnGog(
|
|
230
|
+
fullArgs: string[],
|
|
231
|
+
opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
|
|
232
|
+
): Promise<string> {
|
|
233
|
+
const { timeout, interactive = false, spawner, binary = false } = opts;
|
|
234
|
+
const spawn = spawner ?? (await import('node:child_process')).spawn as unknown as Spawner;
|
|
121
235
|
const effectiveTimeout = timeout ?? TIMEOUT_MS;
|
|
122
236
|
|
|
123
237
|
return new Promise((resolve, reject) => {
|
|
124
238
|
const childEnv = { ...sanitizedEnv(), PATH: augmentedPath() };
|
|
125
|
-
const child =
|
|
239
|
+
const child = spawn(readEnvVar('GOG_PATH') ?? 'gog', fullArgs, { env: childEnv });
|
|
126
240
|
const stdoutChunks: Buffer[] = [];
|
|
127
241
|
const stderrChunks: Buffer[] = [];
|
|
128
242
|
let settled = false;
|
|
@@ -140,16 +254,22 @@ export async function run(args: string[], options: RunOptions = {}): Promise<str
|
|
|
140
254
|
clearTimeout(timer);
|
|
141
255
|
if (settled) return;
|
|
142
256
|
settled = true;
|
|
143
|
-
const stdout = Buffer.concat(stdoutChunks).toString();
|
|
144
257
|
const stderr = Buffer.concat(stderrChunks).toString().trim();
|
|
145
258
|
if (code === 0) {
|
|
259
|
+
// Binary mode: return the raw stdout bytes base64-encoded, never a utf8
|
|
260
|
+
// string (which would corrupt a PDF/image). No stderr append.
|
|
261
|
+
if (binary) {
|
|
262
|
+
resolve(Buffer.concat(stdoutChunks).toString('base64'));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const stdout = Buffer.concat(stdoutChunks).toString();
|
|
146
266
|
if (interactive && stderr) {
|
|
147
267
|
resolve(stdout + '\n' + stderr);
|
|
148
268
|
} else {
|
|
149
269
|
resolve(stdout);
|
|
150
270
|
}
|
|
151
271
|
} else {
|
|
152
|
-
reject(new Error(
|
|
272
|
+
reject(new Error(stderr || `gog exited with code ${code}`));
|
|
153
273
|
}
|
|
154
274
|
});
|
|
155
275
|
|
|
@@ -169,3 +289,78 @@ export async function run(args: string[], options: RunOptions = {}): Promise<str
|
|
|
169
289
|
});
|
|
170
290
|
});
|
|
171
291
|
}
|
|
292
|
+
|
|
293
|
+
// Assemble the full gog argv: the always-injected flags (--json/--color=never,
|
|
294
|
+
// --no-input unless interactive, --readonly when opted in), --account, then the
|
|
295
|
+
// caller's args. Shared by run() and runBinary() so both get identical flags.
|
|
296
|
+
function assembleArgs(
|
|
297
|
+
args: GogArg[],
|
|
298
|
+
opts: { account?: string; interactive: boolean; readonly: boolean },
|
|
299
|
+
): GogArg[] {
|
|
300
|
+
const effectiveAccount = opts.account ?? readEnvVar('GOG_ACCOUNT');
|
|
301
|
+
const fullArgs: GogArg[] = ['--json', '--color=never'];
|
|
302
|
+
if (!opts.interactive) {
|
|
303
|
+
fullArgs.push('--no-input');
|
|
304
|
+
}
|
|
305
|
+
// Block all mutating gog API requests at runtime when either the caller opts
|
|
306
|
+
// in or GOG_READONLY is set in the environment. gog has no native env binding
|
|
307
|
+
// for --readonly, so the wrapper translates GOG_READONLY into the flag.
|
|
308
|
+
if (opts.readonly || readonlyEnvEnabled()) {
|
|
309
|
+
fullArgs.push('--readonly');
|
|
310
|
+
}
|
|
311
|
+
if (effectiveAccount) {
|
|
312
|
+
fullArgs.push('--account', effectiveAccount);
|
|
313
|
+
}
|
|
314
|
+
fullArgs.push(...args);
|
|
315
|
+
return fullArgs;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function run(args: GogArg[], options: RunOptions = {}): Promise<string> {
|
|
319
|
+
const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full' } = options;
|
|
320
|
+
const redact = redactMode === 'tokens' ? redactGoogleTokens : redactSecrets;
|
|
321
|
+
|
|
322
|
+
const fullArgs = assembleArgs(args, { account, interactive, readonly });
|
|
323
|
+
|
|
324
|
+
// Pick the executor: an injected spawner keeps the stdio spawn path (and all
|
|
325
|
+
// its tests) intact and always wins; otherwise an ambient runExecutor store
|
|
326
|
+
// (the Worker/Fly HTTP-forward path) takes over; otherwise the default lazy
|
|
327
|
+
// real spawn. Redaction wraps the executor regardless of which one runs — a
|
|
328
|
+
// successful `gog auth tokens` (or any command echoing a credential) would
|
|
329
|
+
// otherwise return raw Google tokens (ya29.…/1//…) into model context, where
|
|
330
|
+
// a sibling tool (gog_gmail_send) could exfiltrate them.
|
|
331
|
+
const store = runExecutor.getStore();
|
|
332
|
+
try {
|
|
333
|
+
let output: string;
|
|
334
|
+
if (spawner) {
|
|
335
|
+
output = await spawnExecutor(fullArgs, { timeout, interactive, spawner });
|
|
336
|
+
} else if (store) {
|
|
337
|
+
output = await store.executor(fullArgs, { timeout, interactive });
|
|
338
|
+
} else {
|
|
339
|
+
output = await spawnExecutor(fullArgs, { timeout, interactive });
|
|
340
|
+
}
|
|
341
|
+
return redact(output);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
throw new Error(redact((err as Error).message));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Run gog and return its stdout as raw bytes, base64-encoded — for binary
|
|
348
|
+
// payloads (a Drive file's bytes) that run()'s utf8 decode + secret redaction
|
|
349
|
+
// would corrupt. Spawn path only: the hosted-connector executor forwards over
|
|
350
|
+
// HTTP and hands back a decoded string, so binary cannot survive it — callers
|
|
351
|
+
// on that path get a clear error instead of a mangled file. No redaction: the
|
|
352
|
+
// base64 of a user's own binary file is opaque and has no token shapes to leak.
|
|
353
|
+
export async function runBinary(args: GogArg[], options: RunOptions = {}): Promise<string> {
|
|
354
|
+
const { account, spawner, timeout, readonly = false } = options;
|
|
355
|
+
// An injected spawner is the stdio/test path and always wins. Otherwise, if an
|
|
356
|
+
// ambient forward executor is installed (the Worker/Fly connector), refuse:
|
|
357
|
+
// its text-only transport can't carry bytes intact.
|
|
358
|
+
if (!spawner && runExecutor.getStore()) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
'Raw byte retrieval is not available over the hosted connector (its transport is text-only). ' +
|
|
361
|
+
'Use the text-extraction path instead, or run the local stdio server to fetch bytes.',
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const fullArgs = assembleArgs(args, { account, interactive: false, readonly });
|
|
365
|
+
return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true });
|
|
366
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { ToolRegistrar } from '@chrischall/mcp-utils';
|
|
2
|
+
import { registerApiTools } from './tools/api.js';
|
|
3
|
+
import { registerAuthTools, authToolsFor } from './tools/auth.js';
|
|
3
4
|
import { registerCalendarTools } from './tools/calendar.js';
|
|
4
5
|
import { registerClassroomTools } from './tools/classroom.js';
|
|
5
6
|
import { registerContactsTools } from './tools/contacts.js';
|
|
@@ -17,32 +18,27 @@ declare const GOGCLI_VERSION: string;
|
|
|
17
18
|
/* v8 ignore next */
|
|
18
19
|
export const VERSION = typeof GOGCLI_VERSION !== 'undefined' ? GOGCLI_VERSION : '0.0.0';
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
registerGmailTools(server);
|
|
37
|
-
registerSheetsTools(server);
|
|
38
|
-
registerSlidesTools(server);
|
|
39
|
-
registerTasksTools(server);
|
|
40
|
-
|
|
41
|
-
return server;
|
|
42
|
-
}
|
|
21
|
+
// Registrar list for the base (all-services) server, in runMcp's `tools`
|
|
22
|
+
// shape. Sub-packages assemble their own list from the individual registrars
|
|
23
|
+
// re-exported below.
|
|
24
|
+
export const BASE_TOOL_REGISTRARS: ToolRegistrar[] = [
|
|
25
|
+
registerApiTools,
|
|
26
|
+
registerAuthTools,
|
|
27
|
+
registerCalendarTools,
|
|
28
|
+
registerClassroomTools,
|
|
29
|
+
registerContactsTools,
|
|
30
|
+
registerDocsTools,
|
|
31
|
+
registerDriveTools,
|
|
32
|
+
registerGmailTools,
|
|
33
|
+
registerSheetsTools,
|
|
34
|
+
registerSlidesTools,
|
|
35
|
+
registerTasksTools,
|
|
36
|
+
];
|
|
43
37
|
|
|
44
38
|
export {
|
|
39
|
+
registerApiTools,
|
|
45
40
|
registerAuthTools,
|
|
41
|
+
authToolsFor,
|
|
46
42
|
registerCalendarTools,
|
|
47
43
|
registerClassroomTools,
|
|
48
44
|
registerContactsTools,
|
package/src/tools/api.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { accountParam, runOrDiagnose } from './utils.js';
|
|
4
|
+
|
|
5
|
+
// Generic Google Discovery API access (gog 0.31). gog_api_list / gog_api_describe
|
|
6
|
+
// are read-only Discovery lookups; gog_api_call is a Discovery-backed escape
|
|
7
|
+
// hatch for any method gog has no dedicated subcommand for — guarded by an
|
|
8
|
+
// explicit write opt-in and a dry-run preview.
|
|
9
|
+
export function registerApiTools(server: McpServer): void {
|
|
10
|
+
server.registerTool('gog_api_list', {
|
|
11
|
+
description: 'List the Google Discovery APIs available for gog_api_call / gog_api_describe (name + version + title).',
|
|
12
|
+
annotations: { readOnlyHint: true },
|
|
13
|
+
inputSchema: {
|
|
14
|
+
all: z.boolean().optional().describe('Include every Discovery API (including preview/less-common ones) instead of the curated default set'),
|
|
15
|
+
account: accountParam,
|
|
16
|
+
},
|
|
17
|
+
}, async ({ all, account }) => {
|
|
18
|
+
const args = ['api', 'list'];
|
|
19
|
+
if (all) args.push('--all');
|
|
20
|
+
return runOrDiagnose(args, { account });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
server.registerTool('gog_api_describe', {
|
|
24
|
+
description: 'Describe a Google Discovery API, or a single method within it — its parameters, request/response schema, and required OAuth scopes. Use this to discover the exact api/version/method and params before calling gog_api_call.',
|
|
25
|
+
annotations: { readOnlyHint: true },
|
|
26
|
+
inputSchema: {
|
|
27
|
+
api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
|
|
28
|
+
version: z.string().describe('API version (e.g. v3, v1)'),
|
|
29
|
+
method: z.string().optional().describe('Optional method id to describe a single method (e.g. files.list); omit to describe the whole API'),
|
|
30
|
+
account: accountParam,
|
|
31
|
+
},
|
|
32
|
+
}, async ({ api, version, method, account }) => {
|
|
33
|
+
const args = ['api', 'describe', api, version];
|
|
34
|
+
if (method) args.push(method);
|
|
35
|
+
return runOrDiagnose(args, { account });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
server.registerTool('gog_api_call', {
|
|
39
|
+
description: 'Call any Discovery-described Google API method directly — an escape hatch for endpoints gog has no dedicated tool for. Find the exact api/version/method/params with gog_api_describe first. Read methods (GET/LIST) run as-is. Mutating methods (POST/PUT/PATCH/DELETE) are refused unless you set allowWrite=true — keep it false to preview, or set dryRun=true to print the intended request without sending it.',
|
|
40
|
+
annotations: { destructiveHint: true },
|
|
41
|
+
inputSchema: {
|
|
42
|
+
api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
|
|
43
|
+
version: z.string().describe('API version (e.g. v3, v1)'),
|
|
44
|
+
method: z.string().describe('Method id to call (e.g. files.list, files.create)'),
|
|
45
|
+
params: z.string().optional().describe('Query/path parameters as a JSON object string (e.g. {"fileId":"abc","fields":"name"})'),
|
|
46
|
+
body: z.string().optional().describe('Request body as a JSON string (for write methods)'),
|
|
47
|
+
scope: z.string().optional().describe('Override the OAuth scope used for the call'),
|
|
48
|
+
allowWrite: z.boolean().optional().describe('Required to invoke a mutating method (POST/PUT/PATCH/DELETE). Without it, gog refuses write methods. Leave unset for read-only calls.'),
|
|
49
|
+
dryRun: z.boolean().optional().describe('Print the intended request and exit without sending it (no changes made)'),
|
|
50
|
+
account: accountParam,
|
|
51
|
+
},
|
|
52
|
+
}, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account }) => {
|
|
53
|
+
const args = ['api', 'call', api, version, method];
|
|
54
|
+
if (params) args.push(`--params=${params}`);
|
|
55
|
+
if (body) args.push(`--body=${body}`);
|
|
56
|
+
if (scope) args.push(`--scope=${scope}`);
|
|
57
|
+
// gog additionally gates mutating Discovery calls behind a confirmation;
|
|
58
|
+
// the runner injects --no-input, so --allow-write alone still refuses.
|
|
59
|
+
if (allowWrite) args.push('--allow-write');
|
|
60
|
+
if (dryRun) args.push('--dry-run');
|
|
61
|
+
// Fleet convention: --force is appended LAST (after --dry-run when both are set).
|
|
62
|
+
if (allowWrite) args.push('--force');
|
|
63
|
+
return runOrDiagnose(args, { account });
|
|
64
|
+
});
|
|
65
|
+
}
|
package/src/tools/auth.ts
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { run } from '../runner.js';
|
|
4
|
-
import {
|
|
4
|
+
import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
|
|
5
|
+
import { errorText, formatAuthHealth, registerRunTool } from './utils.js';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// Register the auth tools with a specific least-privilege default `services`.
|
|
8
|
+
// Kept internal so the exported `registerAuthTools` stays a bare
|
|
9
|
+
// `(server) => void` ToolRegistrar; `authToolsFor` binds a narrower default.
|
|
10
|
+
function registerAuthToolsWith(server: McpServer, defaultServices: string): void {
|
|
11
|
+
const servicesDescribe =
|
|
12
|
+
`Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). ` +
|
|
13
|
+
`Default: "${defaultServices}". Prefer the narrowest set you need — requesting a service whose ` +
|
|
14
|
+
`Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request ` +
|
|
15
|
+
`with invalid_scope.`;
|
|
7
16
|
server.registerTool('gog_auth_list', {
|
|
8
17
|
description: 'List all Google accounts stored in gogcli. Use this to check which accounts are configured and available.',
|
|
9
18
|
annotations: { readOnlyHint: true },
|
|
10
19
|
inputSchema: {},
|
|
11
20
|
}, async () => {
|
|
12
21
|
try {
|
|
13
|
-
return
|
|
22
|
+
return rawTextResult(await run(['auth', 'list']));
|
|
14
23
|
} catch (err) {
|
|
15
|
-
return
|
|
24
|
+
return errorResult(errorText(err));
|
|
16
25
|
}
|
|
17
26
|
});
|
|
18
27
|
|
|
@@ -22,9 +31,29 @@ export function registerAuthTools(server: McpServer): void {
|
|
|
22
31
|
inputSchema: {},
|
|
23
32
|
}, async () => {
|
|
24
33
|
try {
|
|
25
|
-
return
|
|
34
|
+
return rawTextResult(await run(['auth', 'status']));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return errorResult(errorText(err));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
server.registerTool('gog_auth_health', {
|
|
41
|
+
description:
|
|
42
|
+
'Check the LIVE health of each stored Google account. Unlike gog_auth_status (which only ' +
|
|
43
|
+
'reports keyring/config setup), this performs a real token refresh against Google, so it detects ' +
|
|
44
|
+
'expired or revoked (invalid_grant) refresh tokens — the account-wide sign-out that blocks every ' +
|
|
45
|
+
'service. Reports per account: whether the token is currently valid, the mapped cause when it is ' +
|
|
46
|
+
'not, how long ago it was authorized, and a warning as it approaches the 7-day refresh-token limit ' +
|
|
47
|
+
'that applies to OAuth apps whose consent screen is still in "Testing" mode. Run it proactively to ' +
|
|
48
|
+
're-authorize on your own schedule instead of mid-task.',
|
|
49
|
+
annotations: { readOnlyHint: true },
|
|
50
|
+
inputSchema: {},
|
|
51
|
+
}, async () => {
|
|
52
|
+
try {
|
|
53
|
+
// `run` injects --json; --check makes gog probe each token live.
|
|
54
|
+
return rawTextResult(formatAuthHealth(await run(['auth', 'list', '--check']), Date.now()));
|
|
26
55
|
} catch (err) {
|
|
27
|
-
return
|
|
56
|
+
return errorResult(errorText(err));
|
|
28
57
|
}
|
|
29
58
|
});
|
|
30
59
|
|
|
@@ -34,9 +63,9 @@ export function registerAuthTools(server: McpServer): void {
|
|
|
34
63
|
inputSchema: {},
|
|
35
64
|
}, async () => {
|
|
36
65
|
try {
|
|
37
|
-
return
|
|
66
|
+
return rawTextResult(await run(['auth', 'services']));
|
|
38
67
|
} catch (err) {
|
|
39
|
-
return
|
|
68
|
+
return errorResult(errorText(err));
|
|
40
69
|
}
|
|
41
70
|
});
|
|
42
71
|
|
|
@@ -50,25 +79,93 @@ export function registerAuthTools(server: McpServer): void {
|
|
|
50
79
|
annotations: { destructiveHint: true },
|
|
51
80
|
inputSchema: {
|
|
52
81
|
email: z.string().describe('Google account email to authorize'),
|
|
53
|
-
services: z.string().optional().default(
|
|
54
|
-
'Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "all"',
|
|
55
|
-
),
|
|
82
|
+
services: z.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
56
83
|
},
|
|
57
|
-
}, async ({ email, services =
|
|
84
|
+
}, async ({ email, services = defaultServices }) => {
|
|
58
85
|
try {
|
|
59
|
-
return
|
|
86
|
+
return rawTextResult(await run(['auth', 'add', email, '--services', services], {
|
|
60
87
|
interactive: true,
|
|
61
88
|
timeout: 300_000,
|
|
62
89
|
}));
|
|
63
90
|
} catch (err) {
|
|
64
|
-
return
|
|
91
|
+
return errorResult(errorText(err));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
server.registerTool('gog_auth_add_url', {
|
|
96
|
+
description:
|
|
97
|
+
'Begin REMOTE/headless Google authorization (step 1 of 2). Returns a sign-in URL to open in any ' +
|
|
98
|
+
'browser — no local server or terminal on the gogcli host is needed, so this works over the hosted ' +
|
|
99
|
+
'connector where the interactive gog_auth_add cannot. Hand the URL to the user; after they sign in, ' +
|
|
100
|
+
'the browser is redirected to a localhost URL that fails to load — that is expected. They copy that ' +
|
|
101
|
+
'full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is ' +
|
|
102
|
+
'valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to ' +
|
|
103
|
+
'gog_auth_add_complete or the second step will not match this one.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
email: z.string().describe('Google account email to authorize'),
|
|
106
|
+
services: z.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
107
|
+
},
|
|
108
|
+
}, async ({ email, services = defaultServices }) => {
|
|
109
|
+
try {
|
|
110
|
+
// --force-consent guarantees a refresh token even if a prior grant exists
|
|
111
|
+
// (the whole point when recovering from a dead one). redactMode 'tokens'
|
|
112
|
+
// keeps the consent URL's scope names intact (the shared redactor mangles
|
|
113
|
+
// them) while still stripping any real token — a step-1 URL carries none.
|
|
114
|
+
return rawTextResult(await run(
|
|
115
|
+
['auth', 'add', email, '--remote', '--step', '1', '--services', services, '--force-consent'],
|
|
116
|
+
{ redactMode: 'tokens' },
|
|
117
|
+
));
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return errorResult(errorText(err));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
server.registerTool('gog_auth_add_complete', {
|
|
124
|
+
description:
|
|
125
|
+
'Complete REMOTE/headless Google authorization (step 2 of 2). Takes the full redirected localhost ' +
|
|
126
|
+
'URL the user copied after finishing gog_auth_add_url, exchanges it for a refresh token, and stores ' +
|
|
127
|
+
'it. Use the SAME `services` value you passed to gog_auth_add_url. Must run within 10 minutes of ' +
|
|
128
|
+
'step 1 and against the same gogcli host.',
|
|
129
|
+
annotations: { destructiveHint: true },
|
|
130
|
+
inputSchema: {
|
|
131
|
+
email: z.string().describe('Google account email being authorized (same as step 1)'),
|
|
132
|
+
redirectUrl: z.string().describe(
|
|
133
|
+
'The full localhost redirect URL the user copied from the browser address bar after signing in ' +
|
|
134
|
+
'(contains code and state; the page itself fails to load, which is expected).',
|
|
135
|
+
),
|
|
136
|
+
services: z.string().optional().default(defaultServices).describe(
|
|
137
|
+
`Services authorized — MUST match the value passed to gog_auth_add_url. Default: "${defaultServices}".`,
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
}, async ({ email, redirectUrl, services = defaultServices }) => {
|
|
141
|
+
try {
|
|
142
|
+
return rawTextResult(await run(
|
|
143
|
+
['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
|
|
144
|
+
'--services', services, '--force-consent'],
|
|
145
|
+
));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return errorResult(errorText(err));
|
|
65
148
|
}
|
|
66
149
|
});
|
|
67
150
|
|
|
68
151
|
registerRunTool(server, {
|
|
69
152
|
service: 'auth',
|
|
70
|
-
examples: '"remove", "alias", "
|
|
153
|
+
examples: '"remove", "alias", "list"',
|
|
71
154
|
omitAccount: true,
|
|
72
155
|
note: 'For browser-based authorization, use gog_auth_add instead.',
|
|
73
156
|
});
|
|
74
157
|
}
|
|
158
|
+
|
|
159
|
+
// The base all-services registrar: bare `(server) => void` so it drops straight
|
|
160
|
+
// into BASE_TOOL_REGISTRARS / ToolRegistrar[]. Requests every service ('all').
|
|
161
|
+
export function registerAuthTools(server: McpServer): void {
|
|
162
|
+
registerAuthToolsWith(server, 'all');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A `registerAuthTools` bound to a least-privilege default `services`. A
|
|
166
|
+
// single-service package/agent registers `authToolsFor('gmail')` instead of the
|
|
167
|
+
// bare `registerAuthTools` so its re-auth requests only that service's scopes.
|
|
168
|
+
// The base all-services package keeps the bare registrar (default 'all').
|
|
169
|
+
export function authToolsFor(defaultServices: string): (server: McpServer) => void {
|
|
170
|
+
return (server) => registerAuthToolsWith(server, defaultServices);
|
|
171
|
+
}
|