gogcli-mcp 4.2.5 → 4.3.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 +714 -216
- package/dist/lib.js +750 -216
- package/manifest.json +9 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +56 -16
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +6 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +86 -18
- package/src/tools/api.ts +39 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +10 -8
- package/src/tools/chat.ts +16 -13
- package/src/tools/classroom.ts +27 -25
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +42 -18
- package/src/tools/gmail.ts +71 -11
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +46 -0
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +29 -27
- package/tests/tools/chat.test.ts +46 -20
- package/tests/tools/classroom.test.ts +39 -38
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +106 -19
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- package/vitest.config.ts +5 -0
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp",
|
|
5
5
|
"display_name": "gogcli",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.3.0",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
],
|
|
38
38
|
"env": {
|
|
39
39
|
"GOG_ACCOUNT": "${user_config.gog_account}",
|
|
40
|
-
"GOG_PATH": "${user_config.gog_path}"
|
|
40
|
+
"GOG_PATH": "${user_config.gog_path}",
|
|
41
|
+
"GOG_FILE_ROOTS": "${user_config.gog_file_roots}"
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
},
|
|
@@ -53,6 +54,12 @@
|
|
|
53
54
|
"title": "gog Executable Path",
|
|
54
55
|
"description": "Path to the gog executable (optional — defaults to 'gog' on your PATH)",
|
|
55
56
|
"required": false
|
|
57
|
+
},
|
|
58
|
+
"gog_file_roots": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"title": "Allowed file directories",
|
|
61
|
+
"description": "Directories tools may read files from (attachments, uploads) or write files to (exports, downloads), separated by ':' (';' on Windows). Optional — defaults to ~/gogcli-mcp-files",
|
|
62
|
+
"required": false
|
|
56
63
|
}
|
|
57
64
|
},
|
|
58
65
|
"tools": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp",
|
|
5
5
|
"description": "MCP server wrapping gogcli for Google service access",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@chrischall/mcp-utils": "^2.
|
|
44
|
+
"@chrischall/mcp-utils": "^2.4.0",
|
|
45
45
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
46
46
|
"zod": "^4.6.1"
|
|
47
47
|
},
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "4.
|
|
10
|
+
"version": "4.3.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "4.
|
|
15
|
+
"version": "4.3.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/arg-guard.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Guards for argv this wrapper does NOT build itself.
|
|
2
|
+
//
|
|
3
|
+
// The `gog_<service>_run` escape hatches forward a model-supplied string[]
|
|
4
|
+
// verbatim. gog (kong) takes the LAST value of a repeated flag, so an arg like
|
|
5
|
+
// `--readonly=false` placed after the runner's injected `--readonly` silently
|
|
6
|
+
// re-enables writes on a deployment the operator locked read-only — the one
|
|
7
|
+
// kill switch an operator can set (audit SEC-1). The same holds for every
|
|
8
|
+
// other global that is a control rather than an option: the command allow/deny
|
|
9
|
+
// lists, the credential source (`--access-token`, `--home`, `--client`), the
|
|
10
|
+
// account, `--gmail-no-send` and `--no-input`.
|
|
11
|
+
//
|
|
12
|
+
// A bare `--` is refused as well. It ends flag parsing, so anything the wrapper
|
|
13
|
+
// appends AFTER it (a safety flag appended last, for instance) would be read as
|
|
14
|
+
// a positional and quietly dropped.
|
|
15
|
+
//
|
|
16
|
+
// Kept out of runner.ts on purpose: tool tests automock the runner module, and
|
|
17
|
+
// these checks have to run for real inside the tool handlers.
|
|
18
|
+
|
|
19
|
+
// Long-flag names whose override the model must never control, matched as
|
|
20
|
+
// EXACT names: the flag alone (`--readonly`) or with an attached value
|
|
21
|
+
// (`--readonly=false`), case-insensitively. Not as bare prefixes — that refused
|
|
22
|
+
// legitimate command flags that merely share a leading word, such as
|
|
23
|
+
// gog_zoom_auth_setup's --account-id / --client-id / --client-secret. gog has
|
|
24
|
+
// no flag abbreviation, so a longer spelling is a different flag, not an
|
|
25
|
+
// override.
|
|
26
|
+
const FORBIDDEN_LONG_FLAG = /^--(readonly|enable-commands|enable-commands-exact|disable-commands|access-token|home|account|acct|client|gmail-no-send|no-input|non-interactive|noninteractive)(=|$)/i;
|
|
27
|
+
|
|
28
|
+
// `-a` is the short form of --account. kong accepts short-flag CLUSTERS
|
|
29
|
+
// (`-ja` = `-j -a`) and an attached value (`-aother@x`), so any single-dash
|
|
30
|
+
// token whose letters include `a` is refused. `-5` (a negative number) and
|
|
31
|
+
// `-y` (force) are unaffected.
|
|
32
|
+
const SHORT_ACCOUNT_CLUSTER = /^-(?!-)[A-Za-z]*a/;
|
|
33
|
+
|
|
34
|
+
/** Why `arg` may not be forwarded to gog, or undefined when it is fine. */
|
|
35
|
+
export function forbiddenArgReason(arg: string): string | undefined {
|
|
36
|
+
if (arg === '--') {
|
|
37
|
+
return 'The argument "--" is not allowed: it ends flag parsing and would disable safety flags appended after it.';
|
|
38
|
+
}
|
|
39
|
+
const match = FORBIDDEN_LONG_FLAG.exec(arg);
|
|
40
|
+
if (match) {
|
|
41
|
+
return `The flag ${arg} is not allowed here: --${match[1].toLowerCase()} is a safety/credential control set by the server operator, not a per-call option.`;
|
|
42
|
+
}
|
|
43
|
+
if (SHORT_ACCOUNT_CLUSTER.test(arg)) {
|
|
44
|
+
return `The flag ${arg} is not allowed here: -a selects the account. Use the tool's account parameter instead.`;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Throw on the first model-supplied arg that would override a gog safety control. */
|
|
50
|
+
export function assertSafeForwardedArgs(args: readonly string[]): void {
|
|
51
|
+
for (const arg of args) {
|
|
52
|
+
const reason = forbiddenArgReason(arg);
|
|
53
|
+
if (reason) throw new Error(reason);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A gog subcommand is a lower-case word, possibly hyphenated (`mark-read`).
|
|
58
|
+
// Anything else — a flag, an empty string, a path — is a smuggling attempt or a
|
|
59
|
+
// mistake, and gog would parse it as something other than a subcommand.
|
|
60
|
+
const SUBCOMMAND_SHAPE = /^[a-z][a-z0-9-]*$/;
|
|
61
|
+
|
|
62
|
+
export function assertSafeSubcommand(subcommand: string): void {
|
|
63
|
+
if (!SUBCOMMAND_SHAPE.test(subcommand)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Invalid subcommand ${JSON.stringify(subcommand)}: pass a single gog subcommand name such as "list" or "mark-read", and put its arguments in args.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/argv.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Positional-argument marking for gog argv (audit BUG-1).
|
|
2
|
+
//
|
|
3
|
+
// A tool's positional values — a Gmail query, a file ID, a range, a name — are
|
|
4
|
+
// chosen by the model. Pushed as bare argv elements, one that starts with '-'
|
|
5
|
+
// is parsed by gog (kong) as a flag: `gog gmail search "-in:spam"` fails with
|
|
6
|
+
// "unknown flag -i", and a value starting with `--` becomes a real gog flag.
|
|
7
|
+
// Wrapping each value in pos() tells the runner to place it after a single
|
|
8
|
+
// `--`, after every flag, which is how upstream gogcli's own MCP tools call it
|
|
9
|
+
// (internal/cmd/mcp_tools.go).
|
|
10
|
+
//
|
|
11
|
+
// Lives outside runner.ts on purpose: tool tests automock the runner module,
|
|
12
|
+
// and pos() must stay a real function there.
|
|
13
|
+
|
|
14
|
+
export interface GogPositional {
|
|
15
|
+
/** Discriminant separating this from a plain argv string and a GogFileArg. */
|
|
16
|
+
kind: 'positional';
|
|
17
|
+
value: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Mark a model-supplied positional value so it is passed after `--`. */
|
|
21
|
+
export function pos(value: string): GogPositional {
|
|
22
|
+
return { kind: 'positional', value };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isGogPositional(arg: unknown): arg is GogPositional {
|
|
26
|
+
return typeof arg === 'object' && arg !== null && (arg as { kind?: unknown }).kind === 'positional';
|
|
27
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The private, self-cleaning directory attachment downloads land in (audit SEC-6).
|
|
2
|
+
//
|
|
3
|
+
// It used to be a fixed `/tmp/gog-attachments` that nothing ever emptied, so
|
|
4
|
+
// every downloaded attachment — medical records, statements, custody documents
|
|
5
|
+
// — piled up in world-traversable /tmp for the life of the host. And because
|
|
6
|
+
// the name was predictable and shared, another local user could pre-create it
|
|
7
|
+
// or plant a symlink there, redefining where the blob-upload "confinement"
|
|
8
|
+
// root pointed.
|
|
9
|
+
//
|
|
10
|
+
// Now the root is per-user (named for the uid, under the OS temp dir), created
|
|
11
|
+
// owner-only (0700), and verified before every use: a symlink, a non-directory
|
|
12
|
+
// or a directory owned by someone else is refused rather than trusted. Files a
|
|
13
|
+
// caller never sees again (inline, Drive and URL deliveries) are deleted as
|
|
14
|
+
// soon as they are delivered; files returned BY PATH are kept for the caller to
|
|
15
|
+
// read and swept once they are older than ATTACHMENT_TTL_MS.
|
|
16
|
+
|
|
17
|
+
import { tmpdir, userInfo } from 'node:os';
|
|
18
|
+
import { dirname, join, relative, resolve, isAbsolute, sep } from 'node:path';
|
|
19
|
+
|
|
20
|
+
/** Where gmail attachment downloads are written, and the only tree the blob upload reads from. */
|
|
21
|
+
export const ATTACHMENT_DOWNLOAD_ROOT = join(tmpdir(), `gogcli-mcp-attachments-${userInfo().uid}`);
|
|
22
|
+
|
|
23
|
+
/** How long a download returned by path is kept before a later download sweeps it. */
|
|
24
|
+
export const ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
function isInside(root: string, target: string): boolean {
|
|
27
|
+
const rel = relative(root, target);
|
|
28
|
+
return rel !== '' && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create `dir` owner-only if missing, then verify it: a real directory (not a
|
|
33
|
+
* symlink), owned by this user, with no group/other permissions (tightened to
|
|
34
|
+
* 0700 when it is ours but looser). `opts.uid` is injected by tests.
|
|
35
|
+
*/
|
|
36
|
+
export async function ensurePrivateDir(dir: string, opts: { uid?: number } = {}): Promise<void> {
|
|
37
|
+
const { mkdir, lstat, chmod } = await import('node:fs/promises');
|
|
38
|
+
try {
|
|
39
|
+
await mkdir(dir, { mode: 0o700 });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
|
42
|
+
}
|
|
43
|
+
const info = await lstat(dir);
|
|
44
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
45
|
+
throw new Error(`refusing to use ${dir} for attachment downloads: it is not a real directory`);
|
|
46
|
+
}
|
|
47
|
+
const uid = opts.uid ?? userInfo().uid;
|
|
48
|
+
if (info.uid !== uid) {
|
|
49
|
+
throw new Error(`refusing to use ${dir} for attachment downloads: it is owned by another user`);
|
|
50
|
+
}
|
|
51
|
+
if ((info.mode & 0o077) !== 0) await chmod(dir, 0o700);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Delete top-level entries of `root` last modified more than `ttlMs` ago.
|
|
56
|
+
* Best-effort: a missing root sweeps nothing, and one entry failing never stops
|
|
57
|
+
* the rest. Returns how many entries were removed.
|
|
58
|
+
*/
|
|
59
|
+
export async function sweepExpiredDownloads(
|
|
60
|
+
root: string = ATTACHMENT_DOWNLOAD_ROOT,
|
|
61
|
+
ttlMs: number = ATTACHMENT_TTL_MS,
|
|
62
|
+
now: number = Date.now(),
|
|
63
|
+
): Promise<number> {
|
|
64
|
+
const { readdir, lstat, rm } = await import('node:fs/promises');
|
|
65
|
+
let entries: string[];
|
|
66
|
+
try {
|
|
67
|
+
entries = await readdir(root);
|
|
68
|
+
} catch {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
let removed = 0;
|
|
72
|
+
for (const name of entries) {
|
|
73
|
+
const path = join(root, name);
|
|
74
|
+
try {
|
|
75
|
+
const info = await lstat(path);
|
|
76
|
+
if (now - info.mtimeMs > ttlMs) {
|
|
77
|
+
await rm(path, { recursive: true, force: true });
|
|
78
|
+
removed += 1;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// raced with another sweep or a delivery cleanup — nothing to do
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return removed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Delete one delivered download, then any directories it leaves empty, up to
|
|
89
|
+
* (never including) `root`. A path outside `root` is left alone: this only
|
|
90
|
+
* ever cleans up after the server's own downloads, never a caller's `out`.
|
|
91
|
+
*/
|
|
92
|
+
export async function removeDownload(path: string, root: string = ATTACHMENT_DOWNLOAD_ROOT): Promise<void> {
|
|
93
|
+
const { rm, rmdir } = await import('node:fs/promises');
|
|
94
|
+
const base = resolve(root);
|
|
95
|
+
const target = resolve(base, path);
|
|
96
|
+
if (!isInside(base, target)) return;
|
|
97
|
+
await rm(target, { force: true });
|
|
98
|
+
for (let dir = dirname(target); isInside(base, dir); dir = dirname(dir)) {
|
|
99
|
+
try {
|
|
100
|
+
await rmdir(dir);
|
|
101
|
+
} catch {
|
|
102
|
+
break; // not empty (or already gone): stop climbing
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Make the download root private and sweep expired downloads out of it. */
|
|
108
|
+
export async function prepareDownloadRoot(root: string = ATTACHMENT_DOWNLOAD_ROOT): Promise<void> {
|
|
109
|
+
await ensurePrivateDir(root);
|
|
110
|
+
await sweepExpiredDownloads(root);
|
|
111
|
+
}
|
package/src/attachments.ts
CHANGED
|
@@ -80,6 +80,7 @@ export const MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor((MAX_REQUEST_PAYLOAD
|
|
|
80
80
|
*/
|
|
81
81
|
function wireBytesOf(arg: GogArg): number {
|
|
82
82
|
if (typeof arg === 'string') return Buffer.byteLength(arg, 'utf8');
|
|
83
|
+
if (arg.kind === 'positional') return Buffer.byteLength(arg.value, 'utf8');
|
|
83
84
|
return arg.encoding === 'base64' ? arg.contents.length : Buffer.byteLength(arg.contents, 'utf8');
|
|
84
85
|
}
|
|
85
86
|
|
package/src/blob-upload.ts
CHANGED
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
/**
|
|
26
26
|
* Where gmail's attachment download writes (`defaultOutPath` in
|
|
27
27
|
* packages/gogcli-mcp-gmail/src/tools/gmail-extra.ts) and the only tree this
|
|
28
|
-
* module will read back from.
|
|
28
|
+
* module will read back from. Per-user and private — see attachment-root.ts.
|
|
29
29
|
*/
|
|
30
|
-
|
|
30
|
+
import { ATTACHMENT_DOWNLOAD_ROOT } from './attachment-root.js';
|
|
31
|
+
|
|
32
|
+
export { ATTACHMENT_DOWNLOAD_ROOT };
|
|
31
33
|
|
|
32
34
|
/** mcp-host's blob store caps one object at 100 MiB and answers 413 past it. */
|
|
33
35
|
export const MAX_BLOB_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Operator-configured confinement for every server-side path a tool accepts
|
|
2
|
+
// (audit SEC-3/SEC-4).
|
|
3
|
+
//
|
|
4
|
+
// `attach`, `localPath`, `file`, `out`, `outDir` and friends are resolved on the
|
|
5
|
+
// machine gog runs on, and the model chooses them. Unconfined, each is a read
|
|
6
|
+
// primitive (attach ~/.ssh/id_rsa, gog's credentials, /proc/<pid>/environ to an
|
|
7
|
+
// email) or a write primitive (drop attacker-chosen attachment bytes on
|
|
8
|
+
// ~/Library/LaunchAgents/x.plist or ~/.zshrc). Every such path must now resolve
|
|
9
|
+
// — through symlinks, via mcp-utils' assertPathWithinRoots — inside one of the
|
|
10
|
+
// directories the operator lists in GOG_FILE_ROOTS, or the server's own private
|
|
11
|
+
// attachment download directory.
|
|
12
|
+
//
|
|
13
|
+
// GOG_FILE_ROOTS is a PATH-style list (':' on POSIX). Unset, it defaults to
|
|
14
|
+
// ~/gogcli-mcp-files: files to attach or upload go there, and exports and
|
|
15
|
+
// downloads with an explicit path must be written there. Set it to a wider
|
|
16
|
+
// directory (e.g. your home) deliberately, not by default.
|
|
17
|
+
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { delimiter, join } from 'node:path';
|
|
20
|
+
import { assertPathWithinRoots, readEnvVar } from '@chrischall/mcp-utils';
|
|
21
|
+
import { ATTACHMENT_DOWNLOAD_ROOT } from './attachment-root.js';
|
|
22
|
+
|
|
23
|
+
export const FILE_ROOTS_ENV = 'GOG_FILE_ROOTS';
|
|
24
|
+
|
|
25
|
+
/** The default root when GOG_FILE_ROOTS is unset. */
|
|
26
|
+
export function defaultFileRoot(): string {
|
|
27
|
+
return join(homedir(), 'gogcli-mcp-files');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The operator's configured roots, or the default one. */
|
|
31
|
+
export function fileRoots(): string[] {
|
|
32
|
+
const raw = readEnvVar(FILE_ROOTS_ENV);
|
|
33
|
+
const roots = (raw ?? '').split(delimiter).map((r) => r.trim()).filter(Boolean);
|
|
34
|
+
return roots.length > 0 ? roots : [defaultFileRoot()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Require `path` (a model-supplied server path, named `param` in the error) to
|
|
39
|
+
* lie inside an allowed root. Returns it unchanged. `allowDash` admits gog's
|
|
40
|
+
* `-` for tools where it means stdout; anywhere else `-` means stdin, which this
|
|
41
|
+
* server never writes to, so it is refused like any other stray path.
|
|
42
|
+
*/
|
|
43
|
+
export function confinePath(path: string, param: string, opts: { allowDash?: boolean } = {}): string {
|
|
44
|
+
if (opts.allowDash && path === '-') return path;
|
|
45
|
+
const roots = [...fileRoots(), ATTACHMENT_DOWNLOAD_ROOT];
|
|
46
|
+
try {
|
|
47
|
+
assertPathWithinRoots(path, roots);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`${param} ${JSON.stringify(path)} is outside the directories this server may read or write `
|
|
51
|
+
+ `(${roots.join(', ')}). Put the file in one of them, or ask the server operator to widen ${FILE_ROOTS_ENV}.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** confinePath for every element of an optional list. */
|
|
58
|
+
export function confinePaths(paths: readonly string[] | undefined, param: string): void {
|
|
59
|
+
for (const path of paths ?? []) confinePath(path, param);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** For JSON-or-`@file` inputs: confine the path of an `@file` value, pass inline JSON through. */
|
|
63
|
+
export function confineAtFile(value: string, param: string): string {
|
|
64
|
+
if (value.startsWith('@')) confinePath(value.slice(1), param);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
@@ -2,9 +2,9 @@ import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelc
|
|
|
2
2
|
import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
|
|
3
3
|
|
|
4
4
|
// ============================================================================
|
|
5
|
-
// THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply
|
|
6
|
-
// the
|
|
7
|
-
// else's mailbox
|
|
5
|
+
// THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply /
|
|
6
|
+
// drafts_send, and a filter that forwards, are the tools in this fleet that put
|
|
7
|
+
// a message irreversibly into someone else's mailbox. Every other Gmail write either stages
|
|
8
8
|
// something (drafts) or acts on mail already in this account (labels,
|
|
9
9
|
// archive, trash). A caller that meant "save a draft" and picked the wrong
|
|
10
10
|
// tool — or an agent that inherited the wrong reply target — used to find out
|
|
@@ -26,7 +26,7 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
|
|
|
26
26
|
// ============================================================================
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* The
|
|
29
|
+
* The dispatches this rail guards, spelled once.
|
|
30
30
|
*
|
|
31
31
|
* A UNION rather than `string`, because the staging-twin table below is keyed
|
|
32
32
|
* by these values and a key that matches no call site is silent: it costs the
|
|
@@ -41,7 +41,14 @@ export type GmailDispatchOp =
|
|
|
41
41
|
| 'gmail.reply'
|
|
42
42
|
| 'gmail.reply-all'
|
|
43
43
|
| 'gmail.forward'
|
|
44
|
-
| 'gmail.autoreply'
|
|
44
|
+
| 'gmail.autoreply'
|
|
45
|
+
// Sending a staged draft dispatches mail just as irreversibly as a direct
|
|
46
|
+
// send; draft-create then drafts-send was an unconfirmed two-step around the
|
|
47
|
+
// rail (audit SEC-2).
|
|
48
|
+
| 'gmail.drafts-send'
|
|
49
|
+
// A filter with a forward action sends every FUTURE matching message to
|
|
50
|
+
// another address — persistent exfiltration, not a one-off send.
|
|
51
|
+
| 'gmail.filter-forward';
|
|
45
52
|
|
|
46
53
|
/** Every op, for tests that must cover the set rather than a chosen member. */
|
|
47
54
|
export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
|
|
@@ -50,6 +57,8 @@ export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
|
|
|
50
57
|
'gmail.reply-all',
|
|
51
58
|
'gmail.forward',
|
|
52
59
|
'gmail.autoreply',
|
|
60
|
+
'gmail.drafts-send',
|
|
61
|
+
'gmail.filter-forward',
|
|
53
62
|
];
|
|
54
63
|
|
|
55
64
|
/**
|
|
@@ -66,9 +75,9 @@ export function replyDispatchOp(kind: 'reply' | 'reply-all'): GmailDispatchOp {
|
|
|
66
75
|
|
|
67
76
|
/**
|
|
68
77
|
* The staging twin of each dispatch, named in the refusal above. Every one of
|
|
69
|
-
* these saves without sending
|
|
70
|
-
*
|
|
71
|
-
*
|
|
78
|
+
* these saves without sending. `gog_gmail_drafts_send` asks for confirmation
|
|
79
|
+
* too, so on a client that cannot show a prompt the way through is the USER
|
|
80
|
+
* sending the saved draft from Gmail — a human in the loop either way.
|
|
72
81
|
*
|
|
73
82
|
* `Partial<Record<…>>` and not an index signature: a key outside the union is
|
|
74
83
|
* now rejected by the compiler, which is the whole point, while `autoreply`
|
|
@@ -83,6 +92,34 @@ const STAGING_TWIN: Partial<Record<GmailDispatchOp, string>> = {
|
|
|
83
92
|
'gmail.send': 'gog_gmail_drafts_create',
|
|
84
93
|
};
|
|
85
94
|
|
|
95
|
+
// What to say when there is no staging twin but there IS still a way through.
|
|
96
|
+
const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
|
|
97
|
+
'gmail.drafts-send': 'The draft is still saved: ask the user to review it and send it from Gmail.',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Bound on the body text shown in a confirmation prompt. Enough to read what is
|
|
101
|
+
// actually being sent (the point of SEC-5), small enough to keep the prompt a
|
|
102
|
+
// prompt rather than a copy of the message.
|
|
103
|
+
export const BODY_PREVIEW_MAX = 2048;
|
|
104
|
+
|
|
105
|
+
/** The first BODY_PREVIEW_MAX characters of a body, marked when cut. */
|
|
106
|
+
export function bodyPreview(text: string | undefined): string | undefined {
|
|
107
|
+
if (!text) return undefined;
|
|
108
|
+
if (text.length <= BODY_PREVIEW_MAX) return text;
|
|
109
|
+
return `${text.slice(0, BODY_PREVIEW_MAX)}… [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every attachment a dispatch will carry: a server path in full (so the user
|
|
114
|
+
* sees WHICH file on the gog host is leaving), an inline one by its filename.
|
|
115
|
+
*/
|
|
116
|
+
export function attachmentNames(
|
|
117
|
+
paths: readonly string[] | undefined,
|
|
118
|
+
inline: ReadonlyArray<{ filename: string }> | undefined,
|
|
119
|
+
): string[] {
|
|
120
|
+
return [...(paths ?? []), ...(inline ?? []).map((a) => a.filename)];
|
|
121
|
+
}
|
|
122
|
+
|
|
86
123
|
/** Apply the shared stateless confirmation flow with Gmail-specific copy. */
|
|
87
124
|
export function requireGmailDispatchConfirmation(
|
|
88
125
|
ctx: ServerContext,
|
|
@@ -90,17 +127,20 @@ export function requireGmailDispatchConfirmation(
|
|
|
90
127
|
details: Record<string, unknown>,
|
|
91
128
|
): InputRequiredResult | CallToolResult | undefined {
|
|
92
129
|
const twin = STAGING_TWIN[op];
|
|
130
|
+
const note = twin
|
|
131
|
+
? `Stage it with ${twin} instead; the user can review the draft and send it from Gmail `
|
|
132
|
+
+ '(gog_gmail_drafts_send also asks for confirmation).'
|
|
133
|
+
: UNSUPPORTED_NOTE[op];
|
|
93
134
|
return requireConfirmation(ctx, {
|
|
94
135
|
action: op,
|
|
95
|
-
message:
|
|
136
|
+
message: op === 'gmail.filter-forward'
|
|
137
|
+
? 'Review and confirm this mail-forwarding filter:'
|
|
138
|
+
: 'Review and confirm this email dispatch:',
|
|
96
139
|
details,
|
|
97
|
-
confirmationLabel:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
+ 'and send it with gog_gmail_drafts_send.',
|
|
102
|
-
}
|
|
103
|
-
: {}),
|
|
140
|
+
confirmationLabel: op === 'gmail.filter-forward'
|
|
141
|
+
? 'Confirm that matching mail should be forwarded automatically from now on.'
|
|
142
|
+
: 'Confirm that this email should be sent now.',
|
|
143
|
+
...(note ? { unsupportedNote: note } : {}),
|
|
104
144
|
});
|
|
105
145
|
}
|
|
106
146
|
|
package/src/gmail-results.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { rawTextResult } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { run } from './runner.js';
|
|
4
4
|
import { annotateTruncation, hasMorePages } from './pagination.js';
|
|
5
5
|
import type { MatchCount } from './pagination.js';
|
|
6
|
+
import { pos } from './argv.js';
|
|
6
7
|
|
|
7
8
|
// Post-processing for Gmail search output, on the seam between gog's JSON and
|
|
8
9
|
// the model client. Two guarantees live here, both of which exist because a
|
|
@@ -102,7 +103,7 @@ async function countMatches(
|
|
|
102
103
|
maxResults: COUNT_PROBE_PAGE_SIZE,
|
|
103
104
|
fields: `${itemsKey}/id,nextPageToken`,
|
|
104
105
|
});
|
|
105
|
-
const raw = await run(['api', 'call', 'gmail', 'v1', method, `--params=${params}`], { account });
|
|
106
|
+
const raw = await run(['api', 'call', 'gmail', 'v1', pos(method), `--params=${params}`], { account });
|
|
106
107
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
107
108
|
const items = parsed[itemsKey];
|
|
108
109
|
if (!Array.isArray(items)) return {};
|
|
@@ -208,8 +209,13 @@ export async function fetchGmailPages(
|
|
|
208
209
|
// Nothing collected yet means the caller should just see that result; once
|
|
209
210
|
// pages ARE collected, return them WITH the cursor that was about to be
|
|
210
211
|
// consumed, so the set still reads as truncated rather than complete.
|
|
212
|
+
// The failing page's own text rides along as `pageError` (audit QUAL-1):
|
|
213
|
+
// without it the caller sees only "more exist", retries the same failing
|
|
214
|
+
// page, and never learns WHY — e.g. that the account needs re-auth.
|
|
211
215
|
if (parsed === undefined) {
|
|
212
|
-
return base === undefined
|
|
216
|
+
return base === undefined
|
|
217
|
+
? result
|
|
218
|
+
: finish(base, itemsKey, merged, token, pageErrorText(result, itemsKey, pages + 1));
|
|
213
219
|
}
|
|
214
220
|
base = parsed;
|
|
215
221
|
merged.push(...(parsed[itemsKey] as unknown[]));
|
|
@@ -253,14 +259,27 @@ function parsePage(
|
|
|
253
259
|
return Array.isArray(obj[itemsKey]) ? obj : undefined;
|
|
254
260
|
}
|
|
255
261
|
|
|
262
|
+
// What went wrong on page `n` of a walk, bounded so a stray HTML page cannot
|
|
263
|
+
// become the payload. An error result is already diagnosed text (hints and
|
|
264
|
+
// all); anything else is output this walk could not read as a list.
|
|
265
|
+
const PAGE_ERROR_MAX = 2000;
|
|
266
|
+
function pageErrorText(result: CallToolResult, itemsKey: string, n: number): string {
|
|
267
|
+
const first = result.content[0];
|
|
268
|
+
const text = first?.type === 'text' ? first.text.slice(0, PAGE_ERROR_MAX) : '';
|
|
269
|
+
if (result.isError) return `page ${n} failed${text ? `: ${text}` : ''}`;
|
|
270
|
+
return `page ${n} returned output that is not a ${itemsKey} list`;
|
|
271
|
+
}
|
|
272
|
+
|
|
256
273
|
function finish(
|
|
257
274
|
base: Record<string, unknown>,
|
|
258
275
|
itemsKey: string,
|
|
259
276
|
merged: unknown[],
|
|
260
277
|
token: string | undefined,
|
|
278
|
+
pageError?: string,
|
|
261
279
|
): CallToolResult {
|
|
262
280
|
const out: Record<string, unknown> = { ...base, [itemsKey]: merged };
|
|
263
281
|
if (token === undefined) delete out.nextPageToken;
|
|
264
282
|
else out.nextPageToken = token;
|
|
283
|
+
if (pageError !== undefined) out.pageError = pageError;
|
|
265
284
|
return rawTextResult(JSON.stringify(out));
|
|
266
285
|
}
|
package/src/lib.ts
CHANGED
|
@@ -26,6 +26,8 @@ export type { ReplyFlags } from './tools/gmail.js';
|
|
|
26
26
|
// gmail sub-package's send-side forward/autoreply tools reuse these directly
|
|
27
27
|
// rather than re-declaring the gate; the draft-side twins never import them.
|
|
28
28
|
export {
|
|
29
|
+
attachmentNames,
|
|
30
|
+
bodyPreview,
|
|
29
31
|
extractEmails,
|
|
30
32
|
logGmailDispatch,
|
|
31
33
|
requireGmailDispatchConfirmation,
|
|
@@ -45,6 +47,10 @@ export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
|
|
|
45
47
|
export { bootstrapGogAuth, AUTH_BOOTSTRAP_MARKER } from './bootstrap-auth.js';
|
|
46
48
|
export type { AuthBootstrapStatus, AuthBootstrapOptions } from './bootstrap-auth.js';
|
|
47
49
|
export type { RunOptions, Spawner, GogArg, GogFileArg } from './runner.js';
|
|
50
|
+
export { pos } from './argv.js';
|
|
51
|
+
export { confinePath, confinePaths, confineAtFile, fileRoots, defaultFileRoot, FILE_ROOTS_ENV } from './file-roots.js';
|
|
52
|
+
export { prepareDownloadRoot, removeDownload, ATTACHMENT_TTL_MS } from './attachment-root.js';
|
|
53
|
+
export type { GogPositional } from './argv.js';
|
|
48
54
|
// Caller-supplied attachment bytes — the only outbound attachment path that
|
|
49
55
|
// works when the caller and gog share no filesystem (a hosted deployment such
|
|
50
56
|
// as mcp-host). See src/attachments.ts.
|