gogcli-mcp 2.8.0 → 2.18.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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +20218 -19700
  4. package/dist/lib.js +15845 -23331
  5. package/manifest.json +33 -1
  6. package/package.json +7 -6
  7. package/server.json +2 -2
  8. package/src/connector-auth.ts +46 -0
  9. package/src/connector-runtime.ts +177 -0
  10. package/src/index.ts +7 -5
  11. package/src/lib.ts +8 -7
  12. package/src/runner.ts +225 -30
  13. package/src/server.ts +21 -25
  14. package/src/tools/api.ts +65 -0
  15. package/src/tools/auth.ts +112 -15
  16. package/src/tools/calendar.ts +24 -9
  17. package/src/tools/docs.ts +28 -3
  18. package/src/tools/drive.ts +124 -1
  19. package/src/tools/gmail.ts +7 -3
  20. package/src/tools/sheets.ts +6 -3
  21. package/src/tools/slides.ts +9 -4
  22. package/src/tools/tasks.ts +3 -1
  23. package/src/tools/utils.ts +163 -27
  24. package/src/worker.ts +99 -0
  25. package/tests/connector-auth.test.ts +28 -0
  26. package/tests/connector-runtime.test.ts +474 -0
  27. package/tests/runner-file-args-failure.test.ts +94 -0
  28. package/tests/runner-file-args.test.ts +232 -0
  29. package/tests/runner.test.ts +221 -13
  30. package/tests/server.test.ts +28 -28
  31. package/tests/tools/api.test.ts +107 -0
  32. package/tests/tools/auth.test.ts +187 -31
  33. package/tests/tools/calendar.test.ts +115 -52
  34. package/tests/tools/classroom.test.ts +77 -77
  35. package/tests/tools/contacts.test.ts +24 -24
  36. package/tests/tools/docs.test.ts +84 -46
  37. package/tests/tools/drive.test.ts +226 -55
  38. package/tests/tools/gmail.test.ts +61 -28
  39. package/tests/tools/sheets.test.ts +81 -70
  40. package/tests/tools/slides.test.ts +56 -36
  41. package/tests/tools/tasks.test.ts +33 -33
  42. package/tests/tools/utils.test.ts +116 -2
  43. package/tests/worker.test.ts +142 -0
  44. package/tsconfig.json +4 -1
  45. package/vitest.config.ts +33 -2
  46. package/tests/helpers/test-harness.ts +0 -27
package/src/runner.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { spawn } from 'node:child_process';
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.25.0';
27
-
28
- // Treat unresolved .mcpb placeholders ("${user_config.gog_path}") and empty
29
- // strings the same as an unset env var. When an optional .mcpb user_config
30
- // field is left blank, some clients pass the literal placeholder text through
31
- // to the spawned server instead of substituting "" or omitting the key.
32
- function envOrUndefined(key: string): string | undefined {
33
- const value = process.env[key];
34
- if (!value || value.startsWith('${')) return undefined;
35
- return value;
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
- const TOKEN_PATTERNS: RegExp[] = [
59
- /Bearer\s+[A-Za-z0-9._\-+/=]+/gi,
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
- export function redactSecrets(text: string): string {
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 TOKEN_PATTERNS) {
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
- export async function run(args: string[], options: RunOptions = {}): Promise<string> {
108
- const { account, spawner = spawn as unknown as Spawner, interactive = false, timeout } = options;
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
- const effectiveAccount = account ?? envOrUndefined('GOG_ACCOUNT');
111
-
112
- const fullArgs = ['--json', '--color=never'];
113
- if (!interactive) {
114
- fullArgs.push('--no-input');
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
- if (effectiveAccount) {
117
- fullArgs.push('--account', effectiveAccount);
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
- fullArgs.push(...args);
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 = spawner(envOrUndefined('GOG_PATH') ?? 'gog', fullArgs, { env: childEnv });
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(redactSecrets(stderr || `gog exited with code ${code}`)));
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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { registerAuthTools } from './tools/auth.js';
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
- export function createServer(options?: { name?: string; version?: string }): McpServer {
21
- return new McpServer({
22
- name: options?.name ?? 'gogcli',
23
- version: options?.version ?? VERSION,
24
- });
25
- }
26
-
27
- export function createBaseServer(options?: { name?: string; version?: string }): McpServer {
28
- const server = createServer(options);
29
-
30
- registerAuthTools(server);
31
- registerCalendarTools(server);
32
- registerClassroomTools(server);
33
- registerContactsTools(server);
34
- registerDocsTools(server);
35
- registerDriveTools(server);
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,
@@ -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 { toText, toError, runOrDiagnose, registerRunTool } from './utils.js';
4
+ import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
5
+ import { errorText, formatAuthHealth, registerRunTool } from './utils.js';
5
6
 
6
- export function registerAuthTools(server: McpServer): void {
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 toText(await run(['auth', 'list']));
22
+ return rawTextResult(await run(['auth', 'list']));
14
23
  } catch (err) {
15
- return toError(err);
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 toText(await run(['auth', 'status']));
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 toError(err);
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 toText(await run(['auth', 'services']));
66
+ return rawTextResult(await run(['auth', 'services']));
38
67
  } catch (err) {
39
- return toError(err);
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('all').describe(
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 = 'all' }) => {
84
+ }, async ({ email, services = defaultServices }) => {
58
85
  try {
59
- return toText(await run(['auth', 'add', email, '--services', services], {
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 toError(err);
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", "tokens"',
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
+ }