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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +474 -947
- package/dist/lib.js +575 -983
- package/manifest.json +2 -2
- package/mint.yaml +41 -37
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/attachments.ts +28 -34
- package/src/blob-upload.ts +165 -134
- package/src/blob-urls.ts +3 -5
- package/src/bootstrap-auth.ts +97 -0
- package/src/index.ts +3 -4
- package/src/lib.ts +14 -9
- package/src/runner.ts +27 -176
- package/src/tools/appscript.ts +1 -1
- package/src/tools/auth.ts +5 -5
- package/src/tools/drive.ts +1 -3
- package/src/tools/gmail.ts +9 -9
- package/src/tools/utils.ts +12 -58
- package/tests/attachments.test.ts +11 -14
- package/tests/blob-upload.test.ts +235 -160
- package/tests/bootstrap-auth.test.ts +245 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +53 -96
- package/tests/sdk-single-copy.test.ts +4 -12
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +2 -2
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +6 -14
- package/tsconfig.json +1 -2
- package/vitest.config.ts +2 -12
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -319
- package/src/connector-login.ts +0 -87
- package/src/connector-runtime.ts +0 -910
- package/src/google-probe.ts +0 -113
- package/src/google-token.ts +0 -391
- package/src/remote-runner.ts +0 -77
- package/src/worker.ts +0 -117
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-login.test.ts +0 -151
- package/tests/connector-runtime.test.ts +0 -1664
- package/tests/google-probe.test.ts +0 -116
- package/tests/google-token.test.ts +0 -425
- package/tests/remote-runner.test.ts +0 -202
- 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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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 {
|
|
4
|
+
import { bootstrapGogAuth } from './bootstrap-auth.js';
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
|
|
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,
|
|
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 {
|
|
46
|
-
export type {
|
|
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
|
|
49
|
-
//
|
|
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:
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
54
|
-
*
|
|
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
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// control on that store — and
|
|
245
|
-
//
|
|
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
|
|
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
|
|
481
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
624
|
-
//
|
|
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
|
}
|
package/src/tools/appscript.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
'
|
|
73
|
-
'that
|
|
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
|
|
133
|
-
'
|
|
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 ' +
|
package/src/tools/drive.ts
CHANGED
|
@@ -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.
|
|
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'),
|
package/src/tools/gmail.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
82
|
-
// An explicit flag is the only value authoritative
|
|
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
|
|
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:
|
|
269
|
-
//
|
|
270
|
-
//
|
|
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}`);
|