gogcli-mcp 3.0.0 → 4.0.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 (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +474 -947
  4. package/dist/lib.js +575 -983
  5. package/manifest.json +2 -2
  6. package/mint.yaml +41 -37
  7. package/package.json +3 -3
  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/index.ts +3 -4
  14. package/src/lib.ts +14 -9
  15. package/src/runner.ts +27 -176
  16. package/src/tools/appscript.ts +1 -1
  17. package/src/tools/auth.ts +5 -5
  18. package/src/tools/drive.ts +1 -3
  19. package/src/tools/gmail.ts +9 -9
  20. package/src/tools/utils.ts +12 -58
  21. package/tests/attachments.test.ts +11 -14
  22. package/tests/blob-upload.test.ts +235 -160
  23. package/tests/bootstrap-auth.test.ts +245 -0
  24. package/tests/runner-file-args.test.ts +1 -13
  25. package/tests/runner.test.ts +53 -96
  26. package/tests/sdk-single-copy.test.ts +4 -12
  27. package/tests/tools/auth-401-shapes.test.ts +2 -3
  28. package/tests/tools/auth.test.ts +5 -4
  29. package/tests/tools/drive.test.ts +11 -3
  30. package/tests/tools/gmail.test.ts +2 -2
  31. package/tests/tools/utils.test.ts +1 -50
  32. package/tests/zod-single-copy.test.ts +6 -14
  33. package/tsconfig.json +1 -2
  34. package/vitest.config.ts +2 -12
  35. package/src/auth-log.ts +0 -205
  36. package/src/connector-auth.ts +0 -319
  37. package/src/connector-login.ts +0 -87
  38. package/src/connector-runtime.ts +0 -910
  39. package/src/google-probe.ts +0 -113
  40. package/src/google-token.ts +0 -391
  41. package/src/remote-runner.ts +0 -77
  42. package/src/worker.ts +0 -117
  43. package/tests/auth-log.test.ts +0 -530
  44. package/tests/connector-auth.test.ts +0 -559
  45. package/tests/connector-login.test.ts +0 -151
  46. package/tests/connector-runtime.test.ts +0 -1664
  47. package/tests/google-probe.test.ts +0 -116
  48. package/tests/google-token.test.ts +0 -425
  49. package/tests/remote-runner.test.ts +0 -202
  50. 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
+ }
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
@@ -31,7 +31,7 @@ export {
31
31
  requireGmailDispatchConfirmation,
32
32
  resultText,
33
33
  } from './gmail-dispatch-guard.js';
34
- export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
34
+ export { run, runBinary, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
35
35
  // Sub-package tools that read gog JSON through bare `run()` (rather than the
36
36
  // `runOrDiagnose` seam) must still apply this, or their timestamps skip the
37
37
  // offset repair and the `<field>Display` sibling every other tool returns.
@@ -42,11 +42,12 @@ export { annotateTruncatedList, stripConsumedPageToken } from './pagination.js';
42
42
  // guarantees the base gog_gmail_search makes.
43
43
  export { finalizeGmailSearch, fetchGmailPages } from './gmail-results.js';
44
44
  export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
45
- export { useRemoteGogRunner } from './remote-runner.js';
46
- 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';
47
48
  // Caller-supplied attachment bytes — the only outbound attachment path that
48
- // works when the caller and gog share no filesystem (hosted connector, or any
49
- // 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.
50
51
  export {
51
52
  attachInlineParam,
52
53
  inlineAttachmentSchema,
@@ -88,8 +89,12 @@ export {
88
89
  BLOB_URL_DEFAULT_TTL_MS,
89
90
  } from './blob-urls.js';
90
91
  export type { BlobStoreConfig, BlobUrlMinter, MintOptions } from './blob-urls.js';
91
- // The other half of that hop: under the hosted connector the bytes are on the
92
- // RUNNER's disk and this child never sees them, so the runner is asked to
93
- // stream them to the URL this process minted. See src/blob-upload.ts.
94
- 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';
95
100
  export type { BlobUploadRequest, BlobUploadOutcome, BlobUploadOptions } from './blob-upload.js';
package/src/runner.ts CHANGED
@@ -1,7 +1,7 @@
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';
4
+ import { naiveSourceTimeZone } from './timestamps.js';
5
5
 
6
6
  export type Spawner = (
7
7
  command: string,
@@ -9,12 +9,11 @@ export type Spawner = (
9
9
  options: { env: NodeJS.ProcessEnv },
10
10
  ) => ChildProcess;
11
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.
12
+ // A payload too large to live in argv. The Linux kernel hard-caps a single argv
13
+ // string at MAX_ARG_STRLEN (128 KiB) regardless of ARG_MAX, so big values (a
14
+ // long HTML mail body, slide notes) must leave argv entirely. gog exposes
15
+ // `--x-file` companions for exactly these flags; the runner writes the payload
16
+ // to a private temp file and passes the path instead.
18
17
  export interface GogFileArg {
19
18
  /** Discriminant separating this from a plain argv string. */
20
19
  kind: 'file';
@@ -50,9 +49,8 @@ export interface GogFileArg {
50
49
  * Emit the materialized path as a BARE argv element instead of `--flag=path`.
51
50
  *
52
51
  * 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.
52
+ * upload <localPath>` is the only one today. Argument ORDER is preserved, so
53
+ * a positional file arg lands exactly where it sat in the caller's array.
56
54
  */
57
55
  positional?: boolean;
58
56
  }
@@ -63,114 +61,6 @@ export function isGogFileArg(arg: GogArg): arg is GogFileArg {
63
61
  return typeof arg !== 'string';
64
62
  }
65
63
 
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
64
  export interface RunOptions {
175
65
  account?: string;
176
66
  spawner?: Spawner;
@@ -238,13 +128,11 @@ function readonlyEnvEnabled(): boolean {
238
128
  // cloud / API secrets in scope that the child has no business seeing.
239
129
  //
240
130
  // `_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
131
+ // with the bare one missing, and a credential this repo hands its own process
132
+ // fell in that gap. `MCP_BLOB_SIGNING_KEY` mints the signed blob URLs a
133
+ // `deliver="url"` download is uploaded to — a signature IS the whole access
134
+ // control on that store — and it is spent HERE, never read by the child.
135
+ // `_CREDENTIALS` generalises the named
248
136
  // GOOGLE_APPLICATION_CREDENTIALS above, which stays named because it is the
249
137
  // one gog itself would act on.
250
138
  //
@@ -411,10 +299,6 @@ function formatTimeout(ms: number): string {
411
299
  // Write every GogFileArg to a private temp file, run gog against the resulting
412
300
  // plain argv, and remove the temp dir afterwards — on success, on a non-zero
413
301
  // 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
302
  async function spawnWithTempFiles(
419
303
  args: GogArg[],
420
304
  opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
@@ -460,7 +344,7 @@ async function spawnWithTempFiles(
460
344
  }
461
345
  }
462
346
 
463
- // Spawn-based executor. Deliberately NOT async: when no element is a
347
+ // Spawn gog, materializing GogFileArgs first. Deliberately NOT async: when no element is a
464
348
  // GogFileArg (the overwhelmingly common case) it must create no temp dir and
465
349
  // introduce no extra microtask tick before `spawn` is called — the spawn has
466
350
  // to happen synchronously within the `run()` call, which the fake-timer tests
@@ -477,11 +361,8 @@ function spawnExecutor(
477
361
 
478
362
  // Owns everything process-specific — building the sanitized child env, PATH
479
363
  // 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
-
364
+ // returns raw output (no redaction — `run()` wraps that around it). The
365
+ // injected `spawner` bypasses the real child_process spawn.
485
366
  async function spawnGog(
486
367
  fullArgs: string[],
487
368
  opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
@@ -491,7 +372,9 @@ async function spawnGog(
491
372
  const effectiveTimeout = timeout ?? TIMEOUT_MS;
492
373
 
493
374
  return new Promise((resolve, reject) => {
494
- const childEnv = { ...sanitizedEnv(), PATH: augmentedPath() };
375
+ // gog must format naive dates in the zone normalizeTimestamps assumes; left
376
+ // to itself it falls back to the host's local zone (UTC on mcp-host).
377
+ const childEnv = { ...sanitizedEnv(), GOG_TIMEZONE: naiveSourceTimeZone(), PATH: augmentedPath() };
495
378
  const child = spawn(readEnvVar('GOG_PATH') ?? 'gog', fullArgs, { env: childEnv });
496
379
  const stdoutChunks: Buffer[] = [];
497
380
  const stderrChunks: Buffer[] = [];
@@ -583,58 +466,26 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
583
466
 
584
467
  const fullArgs = assembleArgs(args, { account, interactive, readonly });
585
468
 
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();
469
+ // Redaction wraps the spawn: a successful `gog auth tokens` (or any command
470
+ // echoing a credential) would otherwise return raw Google tokens (ya29.…/1//…)
471
+ // into model context, where a sibling tool (gog_gmail_send) could exfiltrate
472
+ // them.
594
473
  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);
474
+ return redact(await spawnExecutor(fullArgs, { timeout, interactive, spawner }));
604
475
  } catch (err) {
605
476
  // A thrown non-Error would make `.message` undefined and redact() blow up
606
477
  // with a TypeError, masking the real failure. Same instanceof guard the
607
478
  // 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);
479
+ throw new Error(base(err instanceof Error ? err.message : String(err)));
618
480
  }
619
481
  }
620
482
 
621
483
  // Run gog and return its stdout as raw bytes, base64-encoded — for binary
622
484
  // 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.
485
+ // would corrupt. No redaction: the base64 of a user's own binary file is opaque
486
+ // and has no token shapes to leak.
627
487
  export async function runBinary(args: GogArg[], options: RunOptions = {}): Promise<string> {
628
488
  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
489
  const fullArgs = assembleArgs(args, { account, interactive: false, readonly });
639
490
  return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true });
640
491
  }
@@ -61,7 +61,7 @@ export function registerAppScriptTools(server: McpServer): void {
61
61
  description:
62
62
  'Write a project\'s files into a local directory, for editing a script as ordinary files. '
63
63
  + 'THE DIRECTORY IS RESOLVED WHERE GOG RUNS, which is the caller\'s own machine only on a local (stdio) deployment: '
64
- + 'on the hosted connector, or any GOG_RUNNER_URL backend, the files land on that server where the caller cannot '
64
+ + 'on a hosted deployment (e.g. mcp-host) the files land on that server where the caller cannot '
65
65
  + 'reach them. Use gog_appscript_content there instead — it returns the same source in the response. Existing files '
66
66
  + 'are left alone unless overwrite is set. Read-only as far as Google is concerned: nothing is pushed back.'
67
67
  + apiEnableNote,
package/src/tools/auth.ts CHANGED
@@ -67,10 +67,10 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
67
67
  'service. Reports per account: whether the token is currently valid, the mapped cause when it is ' +
68
68
  'not, how long ago it was authorized, and a warning as it approaches the 7-day refresh-token limit ' +
69
69
  'that applies to OAuth apps whose consent screen is still in "Testing" mode. Run it proactively to ' +
70
- 're-authorize on your own schedule instead of mid-task. On the hosted connector this is the ONLY ' +
70
+ 're-authorize on your own schedule instead of mid-task. On a hosted deployment this is the ONLY ' +
71
71
  'check that measures Google: a connector showing "connected" or "refreshed" has verified the ' +
72
- 'connector key that reaches the gog machine, and nothing else — the Google credential lives on ' +
73
- 'that machine and can be dead while the connection looks perfectly healthy.',
72
+ 'client\'s connection to the MCP host, and nothing else — the Google credential lives in gog\'s ' +
73
+ 'keyring on that host and can be dead while the connection looks perfectly healthy.',
74
74
  annotations: { readOnlyHint: true },
75
75
  inputSchema: z.object({}),
76
76
  }, async () => {
@@ -129,8 +129,8 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
129
129
  server.registerTool('gog_auth_add_url', {
130
130
  description:
131
131
  'Begin REMOTE/headless Google authorization (step 1 of 2). Returns a sign-in URL to open in any ' +
132
- 'browser — no local server or terminal on the gogcli host is needed, so this works over the hosted ' +
133
- 'connector where the interactive gog_auth_add cannot. Hand the URL to the user; after they sign in, ' +
132
+ 'browser — no local server or terminal on the gogcli host is needed, so this works on a hosted ' +
133
+ 'deployment where the interactive gog_auth_add cannot. Hand the URL to the user; after they sign in, ' +
134
134
  'the browser is redirected to a localhost URL that fails to load — that is expected. They copy that ' +
135
135
  'full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is ' +
136
136
  'valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to ' +
@@ -251,9 +251,7 @@ export function registerDriveTools(server: McpServer): void {
251
251
  description:
252
252
  'Fetch a Drive file\'s raw bytes and return them base64-encoded as an embedded resource — the ' +
253
253
  'generic fallback for callers that want the file itself (to parse locally) rather than extracted ' +
254
- 'text. For readable text from a PDF, prefer gog_drive_extract_text. NOTE: only works on the local ' +
255
- 'stdio server; over the hosted connector the transport is text-only and this returns a clear error ' +
256
- '(use gog_drive_extract_text there).',
254
+ 'text. For readable text from a PDF, prefer gog_drive_extract_text.',
257
255
  annotations: { readOnlyHint: true },
258
256
  inputSchema: z.object({
259
257
  fileId: z.string().describe('Drive file ID'),
@@ -24,7 +24,7 @@ export const replySchema = {
24
24
  remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
25
25
  subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
26
26
  noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
27
- attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
27
+ attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
28
28
  attachInline: attachInlineParam,
29
29
  from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
30
30
  autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
@@ -66,9 +66,9 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
66
66
  if (f.noQuote) args.push('--no-quote');
67
67
  if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
68
68
  // Same repeatable --attach flag, but the bytes travel with the call: the
69
- // executor writes each one to a temp file beside gog and passes that path.
69
+ // runner writes each one to a temp file beside gog and passes that path.
70
70
  // This is the only attachment route that works when the caller and gog do not
71
- // share a filesystem (hosted connector, GOG_RUNNER_URL backend). `args` is
71
+ // share a filesystem (a hosted deployment such as mcp-host). `args` is
72
72
  // passed so the size check sees the body too, which shares the same budget
73
73
  // once payloadArg has turned it into a file arg.
74
74
  args.push(...inlineAttachmentArgs('attach', f.attachInline, args));
@@ -78,8 +78,8 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
78
78
  if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
79
79
  // PINNED, not conditional: GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS in the host env
80
80
  // silently changes which address the mail goes out FROM, with nothing in the arg
81
- // array to show for it — and the remote runner's backend env is not ours to set.
82
- // An explicit flag is the only value authoritative on both transports.
81
+ // array to show for it — and a hosted deployment's env is not the caller's to set.
82
+ // An explicit flag is the only value authoritative everywhere.
83
83
  args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false');
84
84
  }
85
85
 
@@ -255,7 +255,7 @@ export function registerGmailTools(server: McpServer): void {
255
255
  replyToMessageId: z.string().optional().describe('Message ID to thread this message against — sets In-Reply-To/References only. It does NOT quote the original (pass quote for that), inherit its recipients, or prefix the subject with "Re:". For an actual reply use gog_gmail_reply.'),
256
256
  threadId: z.string().optional().describe('Thread ID to thread this message within. Same caveat as replyToMessageId: threading only, no quote and no inherited subject or recipients.'),
257
257
  quote: z.boolean().optional().describe('Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it.'),
258
- attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.'),
258
+ attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.'),
259
259
  attachInline: attachInlineParam,
260
260
  account: accountParam,
261
261
  }),
@@ -265,9 +265,9 @@ export function registerGmailTools(server: McpServer): void {
265
265
  // skipped this would tell a caller "looks fine, send it" about an
266
266
  // attachment that was always going to fail.
267
267
  //
268
- // A long body cannot ride in argv: the hosted runner caps a single arg and
269
- // Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
270
- // past the shared threshold; the executor materializes the temp file.
268
+ // A long body cannot ride in argv: Linux caps MAX_ARG_STRLEN at 128 KiB.
269
+ // payloadArg swaps it for --body-file past the shared threshold; the runner
270
+ // materializes the temp file.
271
271
  const args: GogArg[] = ['gmail', 'send', `--to=${to}`, `--subject=${subject}`, payloadArg('body', 'body-file', body)];
272
272
  if (cc) args.push(`--cc=${cc}`);
273
273
  if (bcc) args.push(`--bcc=${bcc}`);