gogcli-mcp 2.30.0 → 4.0.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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +15345 -18417
  4. package/dist/lib.js +13720 -9481
  5. package/manifest.json +2 -2
  6. package/mint.yaml +37 -33
  7. package/package.json +5 -5
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/gmail-dispatch-guard.ts +106 -0
  14. package/src/gmail-results.ts +1 -1
  15. package/src/index.ts +3 -4
  16. package/src/lib.ts +24 -9
  17. package/src/pagination.ts +1 -1
  18. package/src/runner.ts +23 -175
  19. package/src/tools/api.ts +7 -7
  20. package/src/tools/appscript.ts +16 -16
  21. package/src/tools/auth.ts +16 -16
  22. package/src/tools/calendar.ts +13 -13
  23. package/src/tools/chat.ts +25 -25
  24. package/src/tools/classroom.ts +49 -49
  25. package/src/tools/contacts.ts +9 -9
  26. package/src/tools/docs.ts +13 -13
  27. package/src/tools/drive.ts +23 -25
  28. package/src/tools/gmail.ts +145 -32
  29. package/src/tools/sheets.ts +15 -15
  30. package/src/tools/slides.ts +13 -13
  31. package/src/tools/tasks.ts +13 -13
  32. package/src/tools/utils.ts +14 -61
  33. package/tests/attachments.test.ts +11 -14
  34. package/tests/blob-upload.test.ts +235 -160
  35. package/tests/bootstrap-auth.test.ts +245 -0
  36. package/tests/gmail-dispatch-guard.test.ts +132 -0
  37. package/tests/runner-file-args.test.ts +1 -13
  38. package/tests/runner.test.ts +8 -95
  39. package/tests/sdk-single-copy.test.ts +11 -37
  40. package/tests/tools/appscript.test.ts +1 -1
  41. package/tests/tools/auth-401-shapes.test.ts +2 -3
  42. package/tests/tools/auth.test.ts +5 -4
  43. package/tests/tools/chat.test.ts +1 -1
  44. package/tests/tools/drive.test.ts +11 -3
  45. package/tests/tools/gmail.test.ts +244 -14
  46. package/tests/tools/sheets.test.ts +1 -1
  47. package/tests/tools/utils.test.ts +1 -50
  48. package/tests/zod-single-copy.test.ts +8 -16
  49. package/tsconfig.json +1 -4
  50. package/vitest.config.ts +2 -14
  51. package/src/auth-log.ts +0 -205
  52. package/src/connector-auth.ts +0 -303
  53. package/src/connector-runtime.ts +0 -887
  54. package/src/google-probe.ts +0 -113
  55. package/src/google-token.ts +0 -391
  56. package/src/remote-runner.ts +0 -77
  57. package/src/worker.ts +0 -129
  58. package/tests/auth-log.test.ts +0 -530
  59. package/tests/connector-auth.test.ts +0 -559
  60. package/tests/connector-runtime.test.ts +0 -1644
  61. package/tests/google-probe.test.ts +0 -116
  62. package/tests/google-token.test.ts +0 -425
  63. package/tests/remote-runner.test.ts +0 -202
  64. package/tests/worker.test.ts +0 -167
package/src/blob-urls.ts CHANGED
@@ -56,11 +56,9 @@ import { readEnvVar } from '@chrischall/mcp-utils';
56
56
  * that is easy to get wrong is exactly the part below — which bytes are signed,
57
57
  * and which of them are percent-encoded on the way into the URL.
58
58
  *
59
- * `node:crypto` rather than WebCrypto (which `google-token.ts` uses, for the
60
- * Worker build): HMAC through `crypto.subtle` is async, and a URL minter that
61
- * returns a promise infects every call site for no gain here. The Worker build
62
- * sets `nodejs_compat` (wrangler.jsonc), so `createHmac` resolves there too if
63
- * this module is ever pulled into that graph.
59
+ * `node:crypto` rather than WebCrypto: HMAC through `crypto.subtle` is async,
60
+ * and a URL minter that returns a promise infects every call site for no gain
61
+ * here.
64
62
  */
65
63
 
66
64
  /**
@@ -0,0 +1,97 @@
1
+ import { join } from 'node:path';
2
+ import { readEnvVar } from '@chrischall/mcp-utils';
3
+ import { redactSecrets, run, type GogFileArg, type Spawner } from './runner.js';
4
+ import { errorText } from './tools/utils.js';
5
+
6
+ export type AuthBootstrapStatus = 'unconfigured' | 'incomplete' | 'present' | 'imported' | 'failed';
7
+
8
+ export interface AuthBootstrapOptions {
9
+ spawner?: Spawner;
10
+ /** Where the marker lives. Defaults to the OS home dir, which is mcp-host's persistent dataDir. */
11
+ home?: string;
12
+ }
13
+
14
+ export const AUTH_BOOTSTRAP_MARKER = 'auth-bootstrap.sha256';
15
+
16
+ const VARS = ['GOG_CLIENT_ID', 'GOG_CLIENT_SECRET', 'GOG_REFRESH_TOKEN', 'GOG_ACCOUNT'] as const;
17
+
18
+ const log = (line: string): void => {
19
+ process.stderr.write(`[gogcli-mcp] auth bootstrap: ${line}\n`);
20
+ };
21
+
22
+ const jsonFile = (name: string, payload: unknown): GogFileArg => ({
23
+ kind: 'file',
24
+ flag: name,
25
+ ext: 'json',
26
+ contents: JSON.stringify(payload),
27
+ positional: true,
28
+ });
29
+
30
+ async function accountListed(account: string, spawner: Spawner | undefined): Promise<boolean> {
31
+ try {
32
+ const out = await run(['auth', 'list'], { spawner, account });
33
+ const accounts = (JSON.parse(out) as { accounts?: { email?: string }[] | null }).accounts ?? [];
34
+ return accounts.some((a) => a.email?.toLowerCase() === account.toLowerCase());
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Seed gog's keyring from GOG_CLIENT_ID / GOG_CLIENT_SECRET / GOG_REFRESH_TOKEN
42
+ * / GOG_ACCOUNT, for a host (mcp-host) that can inject secrets but cannot run
43
+ * `gog auth add` in a browser. Never throws: the server must still start so the
44
+ * auth tools stay reachable.
45
+ *
46
+ * The marker records WHICH secret was last imported, so a rotated secret is
47
+ * re-imported while an in-connector re-auth (gog_auth_add_url/complete) is left
48
+ * in effect until the secret itself changes.
49
+ */
50
+ export async function bootstrapGogAuth(
51
+ env: NodeJS.ProcessEnv = process.env,
52
+ options: AuthBootstrapOptions = {},
53
+ ): Promise<AuthBootstrapStatus> {
54
+ const values = VARS.map((key) => readEnvVar(key, { env }));
55
+ const missing = VARS.filter((_key, i) => values[i] === undefined);
56
+ if (missing.length === VARS.length) return 'unconfigured';
57
+ if (missing.length > 0) {
58
+ log(`skipped, missing ${missing.join(', ')}`);
59
+ return 'incomplete';
60
+ }
61
+ const [clientId, clientSecret, refreshToken, account] = values as string[];
62
+ const { spawner } = options;
63
+
64
+ try {
65
+ const { createHash } = await import('node:crypto');
66
+ const { mkdir, readFile, writeFile, chmod } = await import('node:fs/promises');
67
+ const home = options.home ?? (await import('node:os')).homedir();
68
+ const dir = join(home, '.gogcli-mcp');
69
+ const marker = join(dir, AUTH_BOOTSTRAP_MARKER);
70
+ const fingerprint = createHash('sha256').update(values.join('\0')).digest('hex');
71
+
72
+ const previous = await readFile(marker, 'utf8').catch(() => undefined);
73
+ if (previous === fingerprint && (await accountListed(account, spawner))) return 'present';
74
+
75
+ await run(
76
+ ['auth', 'credentials', 'set', jsonFile('credentials', { installed: { client_id: clientId, client_secret: clientSecret } })],
77
+ { spawner, account },
78
+ );
79
+ await run(
80
+ ['auth', 'tokens', 'import', jsonFile('token', { email: account, refresh_token: refreshToken })],
81
+ { spawner, account },
82
+ );
83
+
84
+ await mkdir(dir, { recursive: true, mode: 0o700 });
85
+ await chmod(dir, 0o700);
86
+ await writeFile(marker, fingerprint, { mode: 0o600 });
87
+ await chmod(marker, 0o600);
88
+ return 'imported';
89
+ } catch (err) {
90
+ // run() already redacts; the literal values are scrubbed too in case a
91
+ // secret shape the redactor does not know (e.g. GOCSPX-…) was echoed.
92
+ let message = redactSecrets(errorText(err));
93
+ for (const value of [clientSecret, refreshToken]) message = message.split(value).join('[REDACTED]');
94
+ log(`failed, ${message}`);
95
+ return 'failed';
96
+ }
97
+ }
@@ -0,0 +1,106 @@
1
+ import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
2
+ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
3
+
4
+ // ============================================================================
5
+ // THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply are
6
+ // the only tools in this fleet that put a message irreversibly into someone
7
+ // else's mailbox on the FIRST call. Every other Gmail write either stages
8
+ // something (drafts) or acts on mail already in this account (labels,
9
+ // archive, trash). A caller that meant "save a draft" and picked the wrong
10
+ // tool — or an agent that inherited the wrong reply target — used to find out
11
+ // only after the send API call already succeeded.
12
+ //
13
+ // MCP elicitation makes the first round inert: it returns an input_required
14
+ // result containing the preview, and only the protocol retry carrying the
15
+ // user's accepted confirmation dispatches. The confirmation is never a tool
16
+ // argument, so a model cannot bypass the user by setting a boolean itself.
17
+ // ============================================================================
18
+ /** Apply the shared stateless confirmation flow with Gmail-specific copy. */
19
+ export function requireGmailDispatchConfirmation(
20
+ ctx: ServerContext,
21
+ op: string,
22
+ details: Record<string, unknown>,
23
+ ): InputRequiredResult | CallToolResult | undefined {
24
+ return requireConfirmation(ctx, {
25
+ action: op,
26
+ message: 'Review and confirm this email dispatch:',
27
+ details,
28
+ confirmationLabel: 'Confirm that this email should be sent now.',
29
+ });
30
+ }
31
+
32
+ // Over-inclusive on purpose: this feeds an audit log and a caller-facing
33
+ // preview, neither of which is the enforcement point (the protocol gate is).
34
+ // Missing a real recipient would be the dangerous direction of error; catching
35
+ // an extra email-shaped substring is not.
36
+ const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
37
+
38
+ export function extractEmails(...values: Array<string | undefined | null>): string[] {
39
+ const seen = new Set<string>();
40
+ const out: string[] = [];
41
+ for (const value of values) {
42
+ if (!value) continue;
43
+ const matches = value.match(EMAIL_PATTERN);
44
+ if (!matches) continue;
45
+ for (const match of matches) {
46
+ const lower = match.toLowerCase();
47
+ if (!seen.has(lower)) {
48
+ seen.add(lower);
49
+ out.push(lower);
50
+ }
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // GOG_GMAIL_TRUSTED_DOMAINS names domains that are never "external" — by
57
+ // default just the sending account's own domain, so a reply-all that includes
58
+ // the account itself never reads as a surprise. Comma-separated, additive.
59
+ function trustedDomains(account: string | undefined): Set<string> {
60
+ const domains = new Set<string>();
61
+ const raw = readEnvVar('GOG_GMAIL_TRUSTED_DOMAINS');
62
+ if (raw) {
63
+ for (const part of raw.split(',')) {
64
+ const domain = part.trim().toLowerCase();
65
+ if (domain) domains.add(domain);
66
+ }
67
+ }
68
+ const acct = account ?? readEnvVar('GOG_ACCOUNT');
69
+ const at = acct?.indexOf('@') ?? -1;
70
+ if (acct && at > -1) domains.add(acct.slice(at + 1).toLowerCase());
71
+ return domains;
72
+ }
73
+
74
+ // A distinguishable, greppable event for every mail dispatch — recipient
75
+ // count plus whichever recipients fall outside the trusted-domain list — so an
76
+ // unexpected external send (outside counsel, a wrong-number alias) can be
77
+ // caught after the fact even if the confirmation step above is somehow
78
+ // bypassed by a future caller. stdout is the JSON-RPC channel, so this goes to
79
+ // stderr like every other diagnostic in this repo.
80
+ export function logGmailDispatch(tool: string, recipients: string[], account?: string): void {
81
+ const domains = trustedDomains(account);
82
+ const externalRecipients = recipients.filter((recipient) => {
83
+ const at = recipient.indexOf('@');
84
+ const domain = at > -1 ? recipient.slice(at + 1) : '';
85
+ return !domain || !domains.has(domain);
86
+ });
87
+ const event = {
88
+ event: 'gmail_dispatch',
89
+ tool,
90
+ recipientCount: recipients.length,
91
+ externalRecipientCount: externalRecipients.length,
92
+ hasExternalRecipients: externalRecipients.length > 0,
93
+ externalRecipients,
94
+ timestamp: new Date().toISOString(),
95
+ };
96
+ process.stderr.write(`${JSON.stringify(event)}\n`);
97
+ }
98
+
99
+ // The single place a CallToolResult's text is pulled back out, for the tools
100
+ // here that need to read gog's own JSON before deciding what to preview or
101
+ // log. Mirrors the shape every runOrDiagnose result actually returns
102
+ // (content[0].text); never throws on an unexpected shape.
103
+ export function resultText(result: CallToolResult): string {
104
+ const first = result.content[0];
105
+ return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
106
+ }
@@ -1,4 +1,4 @@
1
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
1
+ import type { CallToolResult } from '@modelcontextprotocol/server';
2
2
  import { rawTextResult } from '@chrischall/mcp-utils';
3
3
  import { run } from './runner.js';
4
4
  import { annotateTruncation, hasMorePages } from './pagination.js';
package/src/index.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { runMcp } from '@chrischall/mcp-utils';
3
3
  import { BASE_TOOL_REGISTRARS, VERSION } from './server.js';
4
- import { useRemoteGogRunner } from './remote-runner.js';
4
+ import { bootstrapGogAuth } from './bootstrap-auth.js';
5
5
 
6
6
 
7
- // Execute `gog` on the Fly backend when the host points us at one; without
8
- // it, nothing changes and we spawn the local binary as before.
9
- useRemoteGogRunner();
7
+ // Seed gog's keyring from GOG_CLIENT_ID/SECRET/REFRESH_TOKEN/ACCOUNT when the host injects them.
8
+ await bootstrapGogAuth();
10
9
 
11
10
  await runMcp({
12
11
  name: 'gogcli',
package/src/lib.ts CHANGED
@@ -21,7 +21,17 @@ export {
21
21
  // same tool name from both registrar lists would be a duplicate-name error.
22
22
  export { replySchema, appendReplyFlags } from './tools/gmail.js';
23
23
  export type { ReplyFlags } from './tools/gmail.js';
24
- export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
24
+ // The gmail confirmation gate — gog_gmail_reply/send/forward/autoreply are
25
+ // the only tools that dispatch mail irreversibly on the first call. The
26
+ // gmail sub-package's send-side forward/autoreply tools reuse these directly
27
+ // rather than re-declaring the gate; the draft-side twins never import them.
28
+ export {
29
+ extractEmails,
30
+ logGmailDispatch,
31
+ requireGmailDispatchConfirmation,
32
+ resultText,
33
+ } from './gmail-dispatch-guard.js';
34
+ export { run, runBinary, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
25
35
  // Sub-package tools that read gog JSON through bare `run()` (rather than the
26
36
  // `runOrDiagnose` seam) must still apply this, or their timestamps skip the
27
37
  // offset repair and the `<field>Display` sibling every other tool returns.
@@ -32,11 +42,12 @@ export { annotateTruncatedList, stripConsumedPageToken } from './pagination.js';
32
42
  // guarantees the base gog_gmail_search makes.
33
43
  export { finalizeGmailSearch, fetchGmailPages } from './gmail-results.js';
34
44
  export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
35
- export { useRemoteGogRunner } from './remote-runner.js';
36
- export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
45
+ export { bootstrapGogAuth, AUTH_BOOTSTRAP_MARKER } from './bootstrap-auth.js';
46
+ export type { AuthBootstrapStatus, AuthBootstrapOptions } from './bootstrap-auth.js';
47
+ export type { RunOptions, Spawner, GogArg, GogFileArg } from './runner.js';
37
48
  // Caller-supplied attachment bytes — the only outbound attachment path that
38
- // works when the caller and gog share no filesystem (hosted connector, or any
39
- // GOG_RUNNER_URL backend). See src/attachments.ts.
49
+ // works when the caller and gog share no filesystem (a hosted deployment such
50
+ // as mcp-host). See src/attachments.ts.
40
51
  export {
41
52
  attachInlineParam,
42
53
  inlineAttachmentSchema,
@@ -78,8 +89,12 @@ export {
78
89
  BLOB_URL_DEFAULT_TTL_MS,
79
90
  } from './blob-urls.js';
80
91
  export type { BlobStoreConfig, BlobUrlMinter, MintOptions } from './blob-urls.js';
81
- // The other half of that hop: under the hosted connector the bytes are on the
82
- // RUNNER's disk and this child never sees them, so the runner is asked to
83
- // stream them to the URL this process minted. See src/blob-upload.ts.
84
- export { uploadToBlobStore, RUNNER_UPLOAD_TIMEOUT_MS } from './blob-upload.js';
92
+ // The other half of that hop: stream a downloaded attachment off this machine's
93
+ // disk to the URL this process minted. See src/blob-upload.ts.
94
+ export {
95
+ uploadToBlobStore,
96
+ ATTACHMENT_DOWNLOAD_ROOT,
97
+ BLOB_UPLOAD_TIMEOUT_MS,
98
+ MAX_BLOB_UPLOAD_BYTES,
99
+ } from './blob-upload.js';
85
100
  export type { BlobUploadRequest, BlobUploadOutcome, BlobUploadOptions } from './blob-upload.js';
package/src/pagination.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
1
+ import type { CallToolResult } from '@modelcontextprotocol/server';
2
2
  import { rawTextResult } from '@chrischall/mcp-utils';
3
3
 
4
4
  // gog reports an exhausted cursor as `"nextPageToken": ""` rather than omitting
package/src/runner.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
1
  import type { ChildProcess } from 'node:child_process';
3
2
  import { delimiter, join } from 'node:path';
4
3
  import { parseBoolEnv, readEnvVar, redactSecrets as redactSharedSecrets } from '@chrischall/mcp-utils';
@@ -9,12 +8,11 @@ export type Spawner = (
9
8
  options: { env: NodeJS.ProcessEnv },
10
9
  ) => ChildProcess;
11
10
 
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.
11
+ // A payload too large to live in argv. The Linux kernel hard-caps a single argv
12
+ // string at MAX_ARG_STRLEN (128 KiB) regardless of ARG_MAX, so big values (a
13
+ // long HTML mail body, slide notes) must leave argv entirely. gog exposes
14
+ // `--x-file` companions for exactly these flags; the runner writes the payload
15
+ // to a private temp file and passes the path instead.
18
16
  export interface GogFileArg {
19
17
  /** Discriminant separating this from a plain argv string. */
20
18
  kind: 'file';
@@ -50,9 +48,8 @@ export interface GogFileArg {
50
48
  * Emit the materialized path as a BARE argv element instead of `--flag=path`.
51
49
  *
52
50
  * For subcommands taking the file as a positional argument — `gog drive
53
- * upload <localPath>` is the only one today. Argument ORDER is preserved by
54
- * every executor, so a positional file arg lands exactly where it sat in the
55
- * caller's array.
51
+ * upload <localPath>` is the only one today. Argument ORDER is preserved, so
52
+ * a positional file arg lands exactly where it sat in the caller's array.
56
53
  */
57
54
  positional?: boolean;
58
55
  }
@@ -63,114 +60,6 @@ export function isGogFileArg(arg: GogArg): arg is GogFileArg {
63
60
  return typeof arg !== 'string';
64
61
  }
65
62
 
66
- // An executor runs a FULLY-ASSEMBLED gog arg list (already including
67
- // --json/--no-input/--color=never, --account, --readonly, and the service
68
- // subcommand) and returns its stdout as a string (or throws). This is the
69
- // injection seam that lets the same tool registrars run either by spawning
70
- // `gog` (stdio transport) or by forwarding the arg list to a remote HTTP
71
- // backend (hosted Cloudflare-Worker connector, which cannot spawn processes).
72
- // Elements may be GogFileArgs; EVERY executor is responsible for materializing
73
- // them to a private temp file and removing that file afterwards.
74
- export type GogExecutor = (
75
- args: GogArg[],
76
- opts: { timeout?: number; interactive?: boolean },
77
- ) => Promise<string>;
78
-
79
- // Which layer authored a failure, when the layer was OURS and not gog's.
80
- //
81
- // A remote executor (the Fly/Worker path) can fail in two categorically
82
- // different ways, and every consumer downstream needs to tell them apart:
83
- //
84
- // - `gog` ran on the backend and failed. The message is gog's — or Google's,
85
- // relayed by gog — so it is PROSE, and the only way to classify it is to
86
- // read it. That failure is NOT a RunnerTransportError; it stays a plain
87
- // Error so tools/utils.ts keeps applying its patterns to it.
88
- // - The request never got that far: the runner rejected our bearer token,
89
- // refused the request shape, was draining, or never answered. Nothing was
90
- // ever shown to Google, so no amount of re-authorizing a Google account can
91
- // help — and the runner's own words ("unauthorized") are indistinguishable
92
- // from Google's when read as prose. That is what this type exists for.
93
- //
94
- // The kinds, and what each one asks of the caller:
95
- // transport-auth the runner rejected OUR bearer (GOG_RUNNER_KEY on the
96
- // Worker vs RUNNER_KEY on the Fly app). An operator has
97
- // to fix a key; the end user's Google grant is fine.
98
- // transport-request the runner refused the request shape (oversized arg,
99
- // malformed JSON). Deterministic; retrying is pointless.
100
- // transport-retryable the runner is draining, could not reach its disk, or
101
- // never answered. The same call can succeed shortly.
102
- export type RunnerFailureKind = 'transport-auth' | 'transport-request' | 'transport-retryable';
103
-
104
- // `Symbol.for`, not a private symbol or a bare `instanceof`: the class can be
105
- // evaluated more than once in one process (the stdio bundle and the Worker
106
- // bundle are separate builds of the same source, and vitest can load a module
107
- // twice across pools), and a second copy of the class would make `instanceof`
108
- // answer false for an error that IS one. The registry symbol is the same value
109
- // in every copy, so the brand survives.
110
- const RUNNER_TRANSPORT_BRAND = Symbol.for('gogcli.RunnerTransportError');
111
-
112
- /**
113
- * A failure authored by the gog-runner itself (or by the hop to it) rather than
114
- * by `gog`/Google. Carries the runner's HTTP status when there was one.
115
- */
116
- export class RunnerTransportError extends Error {
117
- readonly kind: RunnerFailureKind;
118
- readonly status: number | undefined;
119
-
120
- constructor(message: string, kind: RunnerFailureKind, status?: number) {
121
- super(message);
122
- this.name = 'RunnerTransportError';
123
- this.kind = kind;
124
- this.status = status;
125
- // Non-enumerable so the brand never shows up in a serialized error body.
126
- Object.defineProperty(this, RUNNER_TRANSPORT_BRAND, { value: true });
127
- }
128
- }
129
-
130
- /** Structural check for the above — see RUNNER_TRANSPORT_BRAND on why not `instanceof`. */
131
- export function isRunnerTransportError(err: unknown): err is RunnerTransportError {
132
- return err instanceof Error && (err as unknown as Record<symbol, unknown>)[RUNNER_TRANSPORT_BRAND] === true;
133
- }
134
-
135
- // Ambient override for the executor `run()` uses when no options.spawner is
136
- // given. The Worker/Fly path wraps request handling in
137
- // `runExecutor.run({ executor }, ...)`; unset, `run()` falls back to spawning.
138
- export const runExecutor = new AsyncLocalStorage<{ executor: GogExecutor }>();
139
-
140
- /**
141
- * The PROCESS-WIDE executor, for a host that has exactly one backend for the
142
- * whole process — a stdio bin pointed at a Fly runner (`useRemoteGogRunner`).
143
- *
144
- * It exists because AsyncLocalStorage cannot express that. `enterWith` sets the
145
- * store on the async resource that is current when it runs, and a bin runs it
146
- * during module evaluation; the tool calls arrive later as I/O events on the
147
- * transport's own resources, which are not descendants of that evaluation, so
148
- * `getStore()` is undefined exactly where it is needed. That is not a bug in
149
- * `enterWith` — a process-lifetime default is simply not a scoped value, and
150
- * storing it in a scope meant the seam silently reverted to spawning a binary
151
- * the host does not have.
152
- *
153
- * A per-request store still WINS over this (see `activeExecutor`), because the
154
- * Worker serves many callers from one isolate and each has its own backend
155
- * credential; this is the fallback for the one-backend case, never a second
156
- * answer to "whose backend is this".
157
- */
158
- let defaultExecutor: { executor: GogExecutor } | undefined;
159
-
160
- /** Install the process-wide executor. Passing undefined clears it (tests). */
161
- export function setDefaultGogExecutor(executor: GogExecutor | undefined): void {
162
- defaultExecutor = executor ? { executor } : undefined;
163
- }
164
-
165
- /**
166
- * Whose executor applies right now: the request's, else the process's, else
167
- * none (meaning `run()` spawns the local binary). Both call sites ask through
168
- * here so they can never disagree about which of the three it is.
169
- */
170
- function activeExecutor(): { executor: GogExecutor } | undefined {
171
- return runExecutor.getStore() ?? defaultExecutor;
172
- }
173
-
174
63
  export interface RunOptions {
175
64
  account?: string;
176
65
  spawner?: Spawner;
@@ -238,13 +127,11 @@ function readonlyEnvEnabled(): boolean {
238
127
  // cloud / API secrets in scope that the child has no business seeing.
239
128
  //
240
129
  // `_KEY`, not `_API_KEY|_PRIVATE_KEY`: those were four spellings of "a key"
241
- // with the bare one missing, and TWO credentials this repo hands its own
242
- // process fell in that gap. `MCP_BLOB_SIGNING_KEY` mints the signed blob URLs
243
- // a `deliver="url"` download is uploaded to — a signature IS the whole access
244
- // control on that store — and `GOG_RUNNER_KEY` is the bearer for the Fly
245
- // backend, where `POST /run` is arbitrary `gog` argv. Neither is read by the
246
- // child: both are spent HERE, and when `GOG_RUNNER_URL` is set nothing is
247
- // spawned at all. `_CREDENTIALS` generalises the named
130
+ // with the bare one missing, and a credential this repo hands its own process
131
+ // fell in that gap. `MCP_BLOB_SIGNING_KEY` mints the signed blob URLs a
132
+ // `deliver="url"` download is uploaded to — a signature IS the whole access
133
+ // control on that store — and it is spent HERE, never read by the child.
134
+ // `_CREDENTIALS` generalises the named
248
135
  // GOOGLE_APPLICATION_CREDENTIALS above, which stays named because it is the
249
136
  // one gog itself would act on.
250
137
  //
@@ -411,10 +298,6 @@ function formatTimeout(ms: number): string {
411
298
  // Write every GogFileArg to a private temp file, run gog against the resulting
412
299
  // plain argv, and remove the temp dir afterwards — on success, on a non-zero
413
300
  // exit, and on timeout alike. A leaked temp file holds user email content.
414
- //
415
- // node:fs/promises and node:os are imported LAZILY (matching the lazy
416
- // node:child_process import below) so a Cloudflare Worker importing this module
417
- // doesn't eagerly pull node builtins, which would break the Worker bundle.
418
301
  async function spawnWithTempFiles(
419
302
  args: GogArg[],
420
303
  opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
@@ -460,7 +343,7 @@ async function spawnWithTempFiles(
460
343
  }
461
344
  }
462
345
 
463
- // Spawn-based executor. Deliberately NOT async: when no element is a
346
+ // Spawn gog, materializing GogFileArgs first. Deliberately NOT async: when no element is a
464
347
  // GogFileArg (the overwhelmingly common case) it must create no temp dir and
465
348
  // introduce no extra microtask tick before `spawn` is called — the spawn has
466
349
  // to happen synchronously within the `run()` call, which the fake-timer tests
@@ -477,11 +360,8 @@ function spawnExecutor(
477
360
 
478
361
  // Owns everything process-specific — building the sanitized child env, PATH
479
362
  // augmentation, spawning, collecting stdout/stderr, and the timeout kill. It
480
- // returns raw output (no redaction — `run()` wraps that around whichever
481
- // executor runs). The child_process import is LAZY so a Cloudflare Worker
482
- // importing this module doesn't eagerly pull node:child_process (which would
483
- // break the Worker bundle); the injected `spawner` bypasses it.
484
-
363
+ // returns raw output (no redaction — `run()` wraps that around it). The
364
+ // injected `spawner` bypasses the real child_process spawn.
485
365
  async function spawnGog(
486
366
  fullArgs: string[],
487
367
  opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
@@ -583,58 +463,26 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
583
463
 
584
464
  const fullArgs = assembleArgs(args, { account, interactive, readonly });
585
465
 
586
- // Pick the executor: an injected spawner keeps the stdio spawn path (and all
587
- // its tests) intact and always wins; otherwise an ambient runExecutor store
588
- // (the Worker/Fly HTTP-forward path) takes over; otherwise the default lazy
589
- // real spawn. Redaction wraps the executor regardless of which one runs — a
590
- // successful `gog auth tokens` (or any command echoing a credential) would
591
- // otherwise return raw Google tokens (ya29.…/1//…) into model context, where
592
- // a sibling tool (gog_gmail_send) could exfiltrate them.
593
- const store = activeExecutor();
466
+ // Redaction wraps the spawn: a successful `gog auth tokens` (or any command
467
+ // echoing a credential) would otherwise return raw Google tokens (ya29.…/1//…)
468
+ // into model context, where a sibling tool (gog_gmail_send) could exfiltrate
469
+ // them.
594
470
  try {
595
- let output: string;
596
- if (spawner) {
597
- output = await spawnExecutor(fullArgs, { timeout, interactive, spawner });
598
- } else if (store) {
599
- output = await store.executor(fullArgs, { timeout, interactive });
600
- } else {
601
- output = await spawnExecutor(fullArgs, { timeout, interactive });
602
- }
603
- return redact(output);
471
+ return redact(await spawnExecutor(fullArgs, { timeout, interactive, spawner }));
604
472
  } catch (err) {
605
473
  // A thrown non-Error would make `.message` undefined and redact() blow up
606
474
  // with a TypeError, masking the real failure. Same instanceof guard the
607
475
  // codebase already uses in errorText() (tools/utils.ts).
608
- const message = base(err instanceof Error ? err.message : String(err));
609
- // Redaction must not cost the error its TYPE. `RunnerTransportError` is the
610
- // structural claim "this failure was ours, not Google's"; flattening it to a
611
- // bare Error here would put diagnose() straight back to guessing from prose,
612
- // which is the bug this type exists to close. Rebuilt rather than mutated so
613
- // the un-redacted message never survives anywhere.
614
- if (isRunnerTransportError(err)) {
615
- throw new RunnerTransportError(message, err.kind, err.status);
616
- }
617
- throw new Error(message);
476
+ throw new Error(base(err instanceof Error ? err.message : String(err)));
618
477
  }
619
478
  }
620
479
 
621
480
  // Run gog and return its stdout as raw bytes, base64-encoded — for binary
622
481
  // payloads (a Drive file's bytes) that run()'s utf8 decode + secret redaction
623
- // would corrupt. Spawn path only: the hosted-connector executor forwards over
624
- // HTTP and hands back a decoded string, so binary cannot survive it — callers
625
- // on that path get a clear error instead of a mangled file. No redaction: the
626
- // base64 of a user's own binary file is opaque and has no token shapes to leak.
482
+ // would corrupt. No redaction: the base64 of a user's own binary file is opaque
483
+ // and has no token shapes to leak.
627
484
  export async function runBinary(args: GogArg[], options: RunOptions = {}): Promise<string> {
628
485
  const { account, spawner, timeout, readonly = false } = options;
629
- // An injected spawner is the stdio/test path and always wins. Otherwise, if an
630
- // ambient forward executor is installed (the Worker/Fly connector), refuse:
631
- // its text-only transport can't carry bytes intact.
632
- if (!spawner && activeExecutor()) {
633
- throw new Error(
634
- 'Raw byte retrieval is not available over the hosted connector (its transport is text-only). ' +
635
- 'Use the text-extraction path instead, or run the local stdio server to fetch bytes.',
636
- );
637
- }
638
486
  const fullArgs = assembleArgs(args, { account, interactive: false, readonly });
639
487
  return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true });
640
488
  }
package/src/tools/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose } from './utils.js';
4
4
 
@@ -10,10 +10,10 @@ export function registerApiTools(server: McpServer): void {
10
10
  server.registerTool('gog_api_list', {
11
11
  description: 'List the Google Discovery APIs available for gog_api_call / gog_api_describe (name + version + title).',
12
12
  annotations: { readOnlyHint: true },
13
- inputSchema: {
13
+ inputSchema: z.object({
14
14
  all: z.boolean().optional().describe('Include every Discovery API (including preview/less-common ones) instead of the curated default set'),
15
15
  account: accountParam,
16
- },
16
+ }),
17
17
  }, async ({ all, account }) => {
18
18
  const args = ['api', 'list'];
19
19
  if (all) args.push('--all');
@@ -23,12 +23,12 @@ export function registerApiTools(server: McpServer): void {
23
23
  server.registerTool('gog_api_describe', {
24
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
25
  annotations: { readOnlyHint: true },
26
- inputSchema: {
26
+ inputSchema: z.object({
27
27
  api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
28
28
  version: z.string().describe('API version (e.g. v3, v1)'),
29
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
30
  account: accountParam,
31
- },
31
+ }),
32
32
  }, async ({ api, version, method, account }) => {
33
33
  const args = ['api', 'describe', api, version];
34
34
  if (method) args.push(method);
@@ -38,7 +38,7 @@ export function registerApiTools(server: McpServer): void {
38
38
  server.registerTool('gog_api_call', {
39
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
40
  annotations: { destructiveHint: true },
41
- inputSchema: {
41
+ inputSchema: z.object({
42
42
  api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
43
43
  version: z.string().describe('API version (e.g. v3, v1)'),
44
44
  method: z.string().describe('Method id to call (e.g. files.list, files.create)'),
@@ -48,7 +48,7 @@ export function registerApiTools(server: McpServer): void {
48
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
49
  dryRun: z.boolean().optional().describe('Print the intended request and exit without sending it (no changes made)'),
50
50
  account: accountParam,
51
- },
51
+ }),
52
52
  }, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account }) => {
53
53
  const args = ['api', 'call', api, version, method];
54
54
  if (params) args.push(`--params=${params}`);