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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +15345 -18417
- package/dist/lib.js +13720 -9481
- package/manifest.json +2 -2
- package/mint.yaml +37 -33
- package/package.json +5 -5
- 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/gmail-dispatch-guard.ts +106 -0
- package/src/gmail-results.ts +1 -1
- package/src/index.ts +3 -4
- package/src/lib.ts +24 -9
- package/src/pagination.ts +1 -1
- package/src/runner.ts +23 -175
- package/src/tools/api.ts +7 -7
- package/src/tools/appscript.ts +16 -16
- package/src/tools/auth.ts +16 -16
- package/src/tools/calendar.ts +13 -13
- package/src/tools/chat.ts +25 -25
- package/src/tools/classroom.ts +49 -49
- package/src/tools/contacts.ts +9 -9
- package/src/tools/docs.ts +13 -13
- package/src/tools/drive.ts +23 -25
- package/src/tools/gmail.ts +145 -32
- package/src/tools/sheets.ts +15 -15
- package/src/tools/slides.ts +13 -13
- package/src/tools/tasks.ts +13 -13
- package/src/tools/utils.ts +14 -61
- 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/gmail-dispatch-guard.test.ts +132 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +8 -95
- package/tests/sdk-single-copy.test.ts +11 -37
- package/tests/tools/appscript.test.ts +1 -1
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/chat.test.ts +1 -1
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +244 -14
- package/tests/tools/sheets.test.ts +1 -1
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +8 -16
- package/tsconfig.json +1 -4
- package/vitest.config.ts +2 -14
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -303
- package/src/connector-runtime.ts +0 -887
- 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 -129
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-runtime.test.ts +0 -1644
- 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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/gmail-results.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CallToolResult } from '@modelcontextprotocol/
|
|
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 {
|
|
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
|
@@ -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
|
-
|
|
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 {
|
|
36
|
-
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';
|
|
37
48
|
// Caller-supplied attachment bytes — the only outbound attachment path that
|
|
38
|
-
// works when the caller and gog share no filesystem (hosted
|
|
39
|
-
//
|
|
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:
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
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/
|
|
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.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
54
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
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}`);
|