gogcli-mcp 2.24.0 → 2.26.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/README.md +9 -4
- package/SKILL.md +10 -5
- package/dist/index.js +525 -57
- package/dist/lib.js +457 -18
- package/manifest.json +82 -2
- package/mint.yaml +106 -0
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/attachments.ts +263 -0
- package/src/gmail-results.ts +29 -2
- package/src/lib.ts +16 -0
- package/src/runner.ts +170 -15
- package/src/server.ts +6 -0
- package/src/tools/appscript.ts +173 -0
- package/src/tools/calendar.ts +56 -2
- package/src/tools/chat.ts +253 -0
- package/src/tools/gmail.ts +17 -3
- package/src/worker.ts +1 -1
- package/tests/attachments.test.ts +227 -0
- package/tests/gmail-results.test.ts +47 -0
- package/tests/runner-file-args-failure.test.ts +48 -3
- package/tests/runner.test.ts +126 -0
- package/tests/server.test.ts +2 -0
- package/tests/tools/appscript.test.ts +159 -0
- package/tests/tools/calendar.test.ts +121 -0
- package/tests/tools/chat.test.ts +284 -0
- package/tests/tools/gmail.test.ts +89 -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": "2.
|
|
6
|
+
"version": "2.26.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",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
"name": "gog_gmail_send",
|
|
113
|
-
"description": "Send an email"
|
|
113
|
+
"description": "Send an email, with attachments from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)"
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
"name": "gog_gmail_run",
|
|
@@ -423,6 +423,86 @@
|
|
|
423
423
|
{
|
|
424
424
|
"name": "gog_slides_run",
|
|
425
425
|
"description": "Run any gog slides subcommand (escape hatch)"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
"name": "gog_chat_spaces_list",
|
|
429
|
+
"description": "List the Google Chat spaces the account belongs to, with their resource names"
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
"name": "gog_chat_spaces_find",
|
|
433
|
+
"description": "Find Chat spaces by display name (substring by default, or exact)"
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
"name": "gog_chat_spaces_create",
|
|
437
|
+
"description": "Create a named Chat space, optionally seeding its membership"
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
"name": "gog_chat_threads_list",
|
|
441
|
+
"description": "List the threads in a Chat space"
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
"name": "gog_chat_messages_list",
|
|
445
|
+
"description": "Read messages in a space, with mentions and reaction summaries"
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"name": "gog_chat_messages_send",
|
|
449
|
+
"description": "Post a message to a Chat space (immediately visible; supports attachments)"
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
"name": "gog_chat_dm_send",
|
|
453
|
+
"description": "Send a direct message to one person by email address"
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"name": "gog_chat_dm_space",
|
|
457
|
+
"description": "Resolve the DM space resource name for an email address"
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
"name": "gog_chat_reactions_list",
|
|
461
|
+
"description": "List the emoji reactions on a message, with who reacted"
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"name": "gog_chat_reactions_create",
|
|
465
|
+
"description": "React to a Chat message with an emoji"
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
"name": "gog_chat_reactions_delete",
|
|
469
|
+
"description": "Remove one emoji reaction by its resource name"
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
"name": "gog_chat_run",
|
|
473
|
+
"description": "Run any gog chat subcommand not covered by the other tools"
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
"name": "gog_appscript_get",
|
|
477
|
+
"description": "Get an Apps Script project's metadata"
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
"name": "gog_appscript_content",
|
|
481
|
+
"description": "Read a project's source files and manifest inline"
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
"name": "gog_appscript_pull",
|
|
485
|
+
"description": "Write a project's files into a directory on the machine where gog runs"
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
"name": "gog_appscript_create",
|
|
489
|
+
"description": "Create a new, empty Apps Script project (optionally bound to a Drive file)"
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
"name": "gog_appscript_deployments",
|
|
493
|
+
"description": "List a project's deployments"
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
"name": "gog_appscript_versions",
|
|
497
|
+
"description": "List a project's saved versions"
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
"name": "gog_appscript_run_function",
|
|
501
|
+
"description": "Execute a function in a deployed Apps Script project"
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
"name": "gog_appscript_run",
|
|
505
|
+
"description": "Run any gog appscript subcommand not covered by the other tools"
|
|
426
506
|
}
|
|
427
507
|
],
|
|
428
508
|
"compatibility": {
|
package/mint.yaml
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
name: gogcli (Google Workspace)
|
|
3
|
+
slug: gogcli-mcp
|
|
4
|
+
summary: >-
|
|
5
|
+
MCP server wrapping gogcli for Google service access
|
|
6
|
+
command:
|
|
7
|
+
# This package publishes a single bin; naming it keeps the install
|
|
8
|
+
# unambiguous alongside its eight sibling packages.
|
|
9
|
+
bin: gogcli-mcp
|
|
10
|
+
env:
|
|
11
|
+
- name: GOG_CLIENT_ID
|
|
12
|
+
required: false
|
|
13
|
+
help: >-
|
|
14
|
+
Google OAuth client id. NOTE: on the local-spawn path the child `gog`
|
|
15
|
+
receives a sanitized env — runner.ts drops GOG_ACCESS_TOKEN and every
|
|
16
|
+
*_TOKEN / *_SECRET / *_API_KEY / *_PRIVATE_KEY variable — so the CLI
|
|
17
|
+
authenticates from its own stored credentials under $HOME (see
|
|
18
|
+
state.dataDir), not from these variables being passed through.
|
|
19
|
+
- name: GOG_CLIENT_SECRET
|
|
20
|
+
secret: true
|
|
21
|
+
required: false
|
|
22
|
+
help: >-
|
|
23
|
+
Google OAuth client secret. Stripped from the spawned CLI's environment
|
|
24
|
+
by runner.ts's *_SECRET rule — see GOG_CLIENT_ID.
|
|
25
|
+
- name: GOG_REFRESH_TOKEN
|
|
26
|
+
secret: true
|
|
27
|
+
required: false
|
|
28
|
+
help: >-
|
|
29
|
+
Google OAuth refresh token. Stripped from the spawned CLI's environment
|
|
30
|
+
by runner.ts's *_TOKEN rule — see GOG_CLIENT_ID. A hosted deployment must
|
|
31
|
+
therefore carry gog's authorised-account state in its persisted data dir,
|
|
32
|
+
or drive a remote runner via GOG_RUNNER_URL.
|
|
33
|
+
- name: GOG_ACCESS_TOKEN
|
|
34
|
+
secret: true
|
|
35
|
+
required: false
|
|
36
|
+
help: >-
|
|
37
|
+
Deliberately removed from the spawned CLI's environment by runner.ts, so
|
|
38
|
+
that a stale directly-passed token cannot shadow gog's stored refresh
|
|
39
|
+
credential. Leave unset.
|
|
40
|
+
- name: GOG_ACCOUNT
|
|
41
|
+
required: false
|
|
42
|
+
help: >-
|
|
43
|
+
Which configured Google account to act as, when more than one is
|
|
44
|
+
authorised. Defaults to the single/most recent account.
|
|
45
|
+
- name: GOG_READONLY
|
|
46
|
+
required: false
|
|
47
|
+
help: >-
|
|
48
|
+
Set to 1 to refuse every mutating operation. Recommended when the
|
|
49
|
+
connector is shared or you only need reads.
|
|
50
|
+
- name: GOG_PATH
|
|
51
|
+
required: false
|
|
52
|
+
help: >-
|
|
53
|
+
Path to the `gog` binary. Leave unset when the dependency below supplies
|
|
54
|
+
it; set it only to point at a binary you manage yourself.
|
|
55
|
+
- name: GOG_RUNNER_URL
|
|
56
|
+
required: false
|
|
57
|
+
help: >-
|
|
58
|
+
URL of a remote gog runner to execute against instead of spawning the
|
|
59
|
+
local binary. If you set this, add its host to egress.allow.
|
|
60
|
+
- name: GOG_RUNNER_KEY
|
|
61
|
+
secret: true
|
|
62
|
+
required: false
|
|
63
|
+
help: >-
|
|
64
|
+
Shared key authenticating calls to GOG_RUNNER_URL. Required whenever that
|
|
65
|
+
is set.
|
|
66
|
+
- name: GOG_TIMEZONE
|
|
67
|
+
required: false
|
|
68
|
+
help: >-
|
|
69
|
+
The IANA zone gog itself formats its naive timestamps in. The wrapper
|
|
70
|
+
reads it (naiveSourceTimeZone) to re-attach the correct offset, so it
|
|
71
|
+
should match gog's own configuration. Falls back to DISPLAY_TZ, then to
|
|
72
|
+
America/New_York.
|
|
73
|
+
- name: DISPLAY_TZ
|
|
74
|
+
required: false
|
|
75
|
+
help: >-
|
|
76
|
+
The IANA zone every rendered *Display field uses (displayTimeZone,
|
|
77
|
+
default America/New_York). It does not read GOG_TIMEZONE — the fallback
|
|
78
|
+
runs the other way, so this is also what GOG_TIMEZONE falls back to. An
|
|
79
|
+
invalid value degrades to the default rather than throwing.
|
|
80
|
+
dependencies:
|
|
81
|
+
# Every tool shells out to the `gog` CLI; without it the server starts and
|
|
82
|
+
# then fails on the first call. This tag must track
|
|
83
|
+
# packages/gogcli-mcp/src/runner.ts's MIN_GOG_VERSION (the floor the tools
|
|
84
|
+
# assume) and the fly-gog-runner/Dockerfile GOG_VERSION build arg. See
|
|
85
|
+
# CLAUDE.md "Required gog version" — bumping the floor means bumping these
|
|
86
|
+
# nine pins too.
|
|
87
|
+
- kind: github-release
|
|
88
|
+
repo: openclaw/gogcli
|
|
89
|
+
tag: v0.38.1
|
|
90
|
+
asset: "gogcli_*_linux_amd64.tar.gz"
|
|
91
|
+
bin: [gog]
|
|
92
|
+
state:
|
|
93
|
+
dataDir: true
|
|
94
|
+
reason: >-
|
|
95
|
+
`gog` keeps its authorised-account state and token cache under $HOME.
|
|
96
|
+
Without a persistent data dir every cold start has no account configured and
|
|
97
|
+
every tool call fails until the credentials are re-supplied.
|
|
98
|
+
egress:
|
|
99
|
+
allow:
|
|
100
|
+
# Google OAuth token exchange and the Google REST APIs the CLI calls.
|
|
101
|
+
- oauth2.googleapis.com
|
|
102
|
+
- www.googleapis.com
|
|
103
|
+
- googleapis.com
|
|
104
|
+
#
|
|
105
|
+
# NOTE: if you set GOG_RUNNER_URL to run against a remote gog runner, add
|
|
106
|
+
# that host here too — it is supplied at runtime and cannot be declared.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.26.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,15 +41,15 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@chrischall/mcp-utils": "^0.
|
|
44
|
+
"@chrischall/mcp-utils": "^0.15.0",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
46
|
"zod": "^4.4.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@types/node": "^26.
|
|
49
|
+
"@types/node": "^26.2.0",
|
|
50
50
|
"@vitest/coverage-v8": "^4.1.8",
|
|
51
|
-
"esbuild": "^0.28.
|
|
51
|
+
"esbuild": "^0.28.2",
|
|
52
52
|
"typescript": "^7.0.2",
|
|
53
|
-
"vitest": "^4.1.
|
|
53
|
+
"vitest": "^4.1.11"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.26.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.26.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { GogArg, GogFileArg } from './runner.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Caller-supplied attachment BYTES, for hosts that share no filesystem with gog.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this exists
|
|
8
|
+
*
|
|
9
|
+
* Every attachment input gog offers is a PATH — `gmail send --attach`,
|
|
10
|
+
* `gmail drafts create --attach`, `drive upload <localPath>` — and those paths
|
|
11
|
+
* resolve wherever gog runs. On the local stdio transport that is the caller's
|
|
12
|
+
* own machine and everything works. On the hosted connector, and on any host
|
|
13
|
+
* that reaches a backend through GOG_RUNNER_URL, gog runs somewhere else
|
|
14
|
+
* entirely: no path the caller can name exists there, so outbound attachments
|
|
15
|
+
* were simply impossible — including via Drive, whose upload takes a path too.
|
|
16
|
+
*
|
|
17
|
+
* The seam that fixes it already existed. `GogFileArg` writes a payload to a
|
|
18
|
+
* private temp file NEXT TO GOG — on the runner for the remote path, in a
|
|
19
|
+
* mkdtemp dir for the local one — and passes the resulting path. It was built
|
|
20
|
+
* for oversized text (a long HTML mail body) that could not fit in argv; all
|
|
21
|
+
* binary attachments need on top of that is a base64 spelling and control of
|
|
22
|
+
* the basename, both of which are now `GogFileArg` fields.
|
|
23
|
+
*
|
|
24
|
+
* So an inline attachment is not a new transport. It is the same temp-file
|
|
25
|
+
* hand-off, carrying bytes instead of prose.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Ceiling for ONE attachment's decoded bytes.
|
|
29
|
+
//
|
|
30
|
+
// Pinned to the Fly runner's own MAX_FILE_ARG_BYTES (fly-gog-runner/server.mjs)
|
|
31
|
+
// so the two agree. That matters: without a check here the local stdio path
|
|
32
|
+
// would accept any size while the remote path refused at 8 MiB, and the caller
|
|
33
|
+
// would meet the limit as a transport rejection from a layer they cannot see.
|
|
34
|
+
// Checked in the TOOL so the error names the file and the limit instead.
|
|
35
|
+
export const MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
// The Fly runner caps an ENTIRE /run request body at 32 MiB
|
|
38
|
+
// (fly-gog-runner/server.mjs MAX_BODY_BYTES). Restated rather than imported:
|
|
39
|
+
// that package is not a dependency of this one, and the Worker bundle must not
|
|
40
|
+
// pull it in. Keep the two in sync.
|
|
41
|
+
const RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
// Room inside that body for the JSON structure alone — key names, quoting,
|
|
44
|
+
// commas, the accessToken, and the short flag strings (`--to=…`, `--subject=…`).
|
|
45
|
+
// It does NOT have to cover the mail body: a large body is a GogFileArg, and
|
|
46
|
+
// GogFileArgs are measured explicitly below rather than absorbed here.
|
|
47
|
+
const RUNNER_BODY_JSON_RESERVE_BYTES = 256 * 1024;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How many bytes of PAYLOAD one `/run` request can carry, counted as they are
|
|
51
|
+
* spelled on the wire.
|
|
52
|
+
*
|
|
53
|
+
* The binding constraint on a message is its wire size, not the decoded size of
|
|
54
|
+
* its files, and it is tighter than Gmail's own 25 MB limit: connector-runtime
|
|
55
|
+
* sends every payload inside one `JSON.stringify({ args, accessToken })` body,
|
|
56
|
+
* where binary rides as base64 (4/3 inflation) and text rides verbatim. A limit
|
|
57
|
+
* expressed in decoded bytes must absorb that inflation or it documents a size
|
|
58
|
+
* the runner rejects with "request body too large" — a rejection from a layer
|
|
59
|
+
* the caller cannot see, which is exactly what pinning the per-file ceiling to
|
|
60
|
+
* MAX_FILE_ARG_BYTES exists to prevent.
|
|
61
|
+
*/
|
|
62
|
+
export const MAX_REQUEST_PAYLOAD_WIRE_BYTES = RUNNER_MAX_BODY_BYTES - RUNNER_BODY_JSON_RESERVE_BYTES;
|
|
63
|
+
|
|
64
|
+
// Ceiling for all inline attachments on one message, in DECODED bytes — the
|
|
65
|
+
// units a caller thinks in, derived from the wire budget above.
|
|
66
|
+
//
|
|
67
|
+
// This is the ceiling for attachments ALONE. Anything else large in the same
|
|
68
|
+
// request spends the same budget — most of all the mail body, which `payloadArg`
|
|
69
|
+
// turns into a GogFileArg past 4 KiB and which then rides in the same JSON body
|
|
70
|
+
// at roughly 1:1. `inlineAttachmentArgs` therefore measures the actual sibling
|
|
71
|
+
// args rather than trusting this number, so a 23 MiB attachment set plus a
|
|
72
|
+
// multi-MiB HTML body is refused here, with an error naming the body, instead of
|
|
73
|
+
// arriving as a bare transport rejection.
|
|
74
|
+
//
|
|
75
|
+
// Neither bound is hypothetical at the edges: three attachments at the
|
|
76
|
+
// documented 8 MiB per-file maximum is 24 MiB, which alone encodes to exactly
|
|
77
|
+
// MAX_BODY_BYTES, leaving nothing for anything else.
|
|
78
|
+
export const MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor((MAX_REQUEST_PAYLOAD_WIRE_BYTES * 3) / 4);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bytes one already-assembled arg occupies in the `/run` JSON body.
|
|
82
|
+
*
|
|
83
|
+
* A plain string costs its UTF-8 length; a file arg costs the length of its
|
|
84
|
+
* `contents` as spelled on the wire — the base64 text for binary, the UTF-8
|
|
85
|
+
* text itself otherwise. JSON quoting and key names are covered by the reserve.
|
|
86
|
+
*/
|
|
87
|
+
function wireBytesOf(arg: GogArg): number {
|
|
88
|
+
if (typeof arg === 'string') return Buffer.byteLength(arg, 'utf8');
|
|
89
|
+
return arg.encoding === 'base64' ? arg.contents.length : Buffer.byteLength(arg.contents, 'utf8');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// FLOOR, never round: this number is published to callers as a limit, so it has
|
|
93
|
+
// to be one they can actually send. Rounding 23.25 MiB up to "24 MiB" would
|
|
94
|
+
// document a size that gets rejected.
|
|
95
|
+
const formatMiB = (bytes: number): string => `${Math.floor(bytes / (1024 * 1024))} MiB`;
|
|
96
|
+
|
|
97
|
+
/** Human-readable ceilings, for tool descriptions — so the docs cannot drift. */
|
|
98
|
+
export const INLINE_ATTACHMENT_LIMITS_TEXT =
|
|
99
|
+
`up to ${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)} per file and ` +
|
|
100
|
+
`${formatMiB(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)} in total`;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One attachment supplied as bytes rather than as a path.
|
|
104
|
+
*
|
|
105
|
+
* `mimeType` is deliberately ABSENT. gog derives an attachment's MIME type from
|
|
106
|
+
* the filename extension (`mailmime.PrepareAttachments`) and exposes no flag to
|
|
107
|
+
* override it, so a `mimeType` field here could only ever be accepted and
|
|
108
|
+
* ignored. Naming the file `chart.png` is what sets the type; a parameter that
|
|
109
|
+
* silently does nothing is worse than no parameter. (`gog_drive_upload` DOES
|
|
110
|
+
* take a real `mimeType`, because `drive upload --mime-type` exists.)
|
|
111
|
+
*/
|
|
112
|
+
export const inlineAttachmentSchema = z.object({
|
|
113
|
+
filename: z.string().min(1).describe(
|
|
114
|
+
'Filename the recipient will see, e.g. "pendant-layouts.png". gog infers the attachment\'s MIME '
|
|
115
|
+
+ 'type from this extension, so give it the right one — a .png sent as "layouts" arrives as an '
|
|
116
|
+
+ 'untyped blob. Must be a single filename, not a path.',
|
|
117
|
+
),
|
|
118
|
+
contentBase64: z.string().min(1).describe(
|
|
119
|
+
'The file\'s bytes, base64-encoded (standard alphabet, with padding). This is the whole point of '
|
|
120
|
+
+ 'this parameter: the bytes travel with the request, so nothing needs to exist on the gog server\'s '
|
|
121
|
+
+ 'filesystem.',
|
|
122
|
+
),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export type InlineAttachmentInput = z.infer<typeof inlineAttachmentSchema>;
|
|
126
|
+
|
|
127
|
+
/** Reusable tool parameter — the same field on send, drafts create and update. */
|
|
128
|
+
export const attachInlineParam = z.array(inlineAttachmentSchema).optional().describe(
|
|
129
|
+
'Attachments supplied as BYTES rather than as server-side paths — use this whenever you hold a file '
|
|
130
|
+
+ 'and the gog server does not, which is always the case on the hosted connector and on any remote '
|
|
131
|
+
+ `deployment. Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). `
|
|
132
|
+
+ 'Can be combined with `attach`: the two name disjoint files (paths read on the server vs. bytes sent '
|
|
133
|
+
+ 'with the call), and both end up as ordinary attachments on the message.',
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Reject a filename that is a path, a traversal, or otherwise not one plain
|
|
138
|
+
* segment. It becomes both the temp file's basename and the name the recipient
|
|
139
|
+
* sees, so the two things it must not do are escape the temp directory and
|
|
140
|
+
* arrive misleading.
|
|
141
|
+
*/
|
|
142
|
+
function validateFilename(filename: string, where: string): void {
|
|
143
|
+
if (/[/\\]/.test(filename)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`${where}: filename ${JSON.stringify(filename)} must be a bare filename, not a path. `
|
|
146
|
+
+ 'Pass just the name the recipient should see, e.g. "report.pdf".',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
// eslint-disable-next-line no-control-regex
|
|
150
|
+
if (/[\x00-\x1f]/.test(filename) || /^\.+$/.test(filename) || filename.length > 200) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`${where}: filename ${JSON.stringify(filename)} is not a usable filename `
|
|
153
|
+
+ '(no control characters, not "."/"..", 200 characters max).',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Decoded byte length of a base64 string, or null when it is not valid base64.
|
|
160
|
+
*
|
|
161
|
+
* `Buffer.from(…, 'base64')` never throws — it silently DROPS characters it does
|
|
162
|
+
* not recognise — so a truncated or whitespace-mangled payload would otherwise
|
|
163
|
+
* be written to disk as a corrupt file and mailed out as one. The round trip is
|
|
164
|
+
* what turns that into an error the caller can act on, at the boundary where
|
|
165
|
+
* their input arrived rather than in the recipient's inbox.
|
|
166
|
+
*/
|
|
167
|
+
function decodedLength(contentBase64: string): number | null {
|
|
168
|
+
const buf = Buffer.from(contentBase64, 'base64');
|
|
169
|
+
return buf.toString('base64') === contentBase64 ? buf.length : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Validate ONE caller-supplied file and turn it into a `GogFileArg`, which the
|
|
174
|
+
* executor materializes to a temp file beside gog.
|
|
175
|
+
*
|
|
176
|
+
* Throws with an actionable message on anything invalid. The MCP layer turns a
|
|
177
|
+
* thrown handler error into an `isError` result, so every rejection here reaches
|
|
178
|
+
* the caller as a readable sentence naming the file — rather than as a base64
|
|
179
|
+
* decode producing a silently corrupt upload, or as a transport-layer size
|
|
180
|
+
* rejection from a layer the caller cannot see.
|
|
181
|
+
*
|
|
182
|
+
* `positional` emits the path as a bare argv element instead of `--flag=path`,
|
|
183
|
+
* for subcommands that take the file as a positional argument
|
|
184
|
+
* (`gog drive upload <localPath>`).
|
|
185
|
+
*
|
|
186
|
+
* @returns the arg, and the file's decoded size so callers can total it.
|
|
187
|
+
*/
|
|
188
|
+
export function inlineFileArg(
|
|
189
|
+
flag: string,
|
|
190
|
+
attachment: InlineAttachmentInput,
|
|
191
|
+
opts: { positional?: boolean; where?: string } = {},
|
|
192
|
+
): { arg: GogFileArg; bytes: number } {
|
|
193
|
+
const { filename, contentBase64 } = attachment;
|
|
194
|
+
const where = opts.where ?? `attachInline entry ${JSON.stringify(filename)}`;
|
|
195
|
+
validateFilename(filename, where);
|
|
196
|
+
const bytes = decodedLength(contentBase64);
|
|
197
|
+
if (bytes === null) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`${where}: contents are not valid base64. Send the standard alphabet with padding and no `
|
|
200
|
+
+ 'line breaks — the value must survive a decode/re-encode round trip unchanged.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (bytes > MAX_INLINE_ATTACHMENT_BYTES) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`${where}: ${bytes} bytes exceeds the ${MAX_INLINE_ATTACHMENT_BYTES}-byte `
|
|
206
|
+
+ `(${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)}) per-file limit for inline content. `
|
|
207
|
+
+ 'Upload it to Drive and link to it instead, or send it from a local (stdio) deployment '
|
|
208
|
+
+ 'using a real server-side path.',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
// `filename` (not `ext`) is what makes the delivered copy carry the caller's
|
|
212
|
+
// name: gog reads an attachment's MIME filename, and Drive's default title,
|
|
213
|
+
// off the path it is handed.
|
|
214
|
+
const arg: GogFileArg = { kind: 'file', flag, contents: contentBase64, encoding: 'base64', filename };
|
|
215
|
+
if (opts.positional) arg.positional = true;
|
|
216
|
+
return { arg, bytes };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validate inline attachments and turn them into `GogFileArg`s for `flag`
|
|
221
|
+
* (repeatable — one arg per attachment), enforcing the per-message total on top
|
|
222
|
+
* of each file's own ceiling.
|
|
223
|
+
*/
|
|
224
|
+
export function inlineAttachmentArgs(
|
|
225
|
+
flag: string,
|
|
226
|
+
attachments: readonly InlineAttachmentInput[] | undefined,
|
|
227
|
+
siblingArgs: readonly GogArg[] = [],
|
|
228
|
+
): GogArg[] {
|
|
229
|
+
if (!attachments?.length) return [];
|
|
230
|
+
const args: GogArg[] = [];
|
|
231
|
+
// What the rest of the request already spends. Overwhelmingly this is the mail
|
|
232
|
+
// body — small when inline, up to 8 MiB once payloadArg has made it a file arg
|
|
233
|
+
// — and measuring it is what keeps "every input was within its own documented
|
|
234
|
+
// limit" from still adding up to a rejected request.
|
|
235
|
+
const siblingWire = siblingArgs.reduce((sum, arg) => sum + wireBytesOf(arg), 0);
|
|
236
|
+
let attachmentWire = 0;
|
|
237
|
+
let decodedTotal = 0;
|
|
238
|
+
for (const attachment of attachments) {
|
|
239
|
+
const { arg, bytes } = inlineFileArg(flag, attachment);
|
|
240
|
+
attachmentWire += arg.contents.length;
|
|
241
|
+
decodedTotal += bytes;
|
|
242
|
+
if (siblingWire + attachmentWire > MAX_REQUEST_PAYLOAD_WIRE_BYTES) {
|
|
243
|
+
// Report in the units the caller supplied — decoded file bytes — and say
|
|
244
|
+
// so explicitly when the attachments would have fit on their own, because
|
|
245
|
+
// then it is the body that tipped the balance and shrinking the files is
|
|
246
|
+
// the wrong response.
|
|
247
|
+
const blame = attachmentWire <= MAX_REQUEST_PAYLOAD_WIRE_BYTES
|
|
248
|
+
? ` These attachments would fit on their own; the rest of the message (its body, mostly) `
|
|
249
|
+
+ `spends ${siblingWire} bytes of the same budget.`
|
|
250
|
+
: '';
|
|
251
|
+
throw new Error(
|
|
252
|
+
`This message is too large to send: ${decodedTotal} bytes of attachments `
|
|
253
|
+
+ `(${attachmentWire} bytes once base64-encoded for transit) exceed the `
|
|
254
|
+
+ `${MAX_REQUEST_PAYLOAD_WIRE_BYTES}-byte request limit.${blame} The ceiling for attachments `
|
|
255
|
+
+ `alone is ${MAX_INLINE_ATTACHMENT_TOTAL_BYTES} bytes `
|
|
256
|
+
+ `(${formatMiB(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)}); a long body lowers it. Send fewer or `
|
|
257
|
+
+ 'smaller files per message, shorten the body, or upload the large files to Drive and link them.',
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
args.push(arg);
|
|
261
|
+
}
|
|
262
|
+
return args;
|
|
263
|
+
}
|
package/src/gmail-results.ts
CHANGED
|
@@ -177,6 +177,16 @@ export async function finalizeGmailSearch(
|
|
|
177
177
|
// The merged payload keeps a nextPageToken only if pages remain when the cap is
|
|
178
178
|
// hit, so finalizeGmailSearch still marks it truncated — running out of budget
|
|
179
179
|
// is not the same as reaching the end, and must not read like it.
|
|
180
|
+
//
|
|
181
|
+
// A REPEATED CURSOR ENDS THE WALK TOO. Google can hand back a nextPageToken it
|
|
182
|
+
// has already issued, and gog 0.38.0 fixed exactly that for its own `--all`
|
|
183
|
+
// (openclaw/gogcli#1004) — but this walk is not gog's: it makes N separate
|
|
184
|
+
// single-page calls, so a repeated cursor arrives here untouched. Following it
|
|
185
|
+
// re-fetches a page already merged, so the caller gets the same threads twice
|
|
186
|
+
// over, presented as more results and looking perfectly well-formed. The token
|
|
187
|
+
// is KEPT when that happens, for the same reason the cap keeps it: the walk
|
|
188
|
+
// never established an end, and a set that reads complete is precisely how mail
|
|
189
|
+
// that exists gets reported as missing.
|
|
180
190
|
export async function fetchGmailPages(
|
|
181
191
|
runPage: (token: string | undefined) => Promise<CallToolResult>,
|
|
182
192
|
itemsKey: 'threads' | 'messages',
|
|
@@ -186,6 +196,10 @@ export async function fetchGmailPages(
|
|
|
186
196
|
const merged: unknown[] = [];
|
|
187
197
|
let base: Record<string, unknown> | undefined;
|
|
188
198
|
let token = startToken;
|
|
199
|
+
// Every cursor this walk has already fetched with, so one Google repeats is
|
|
200
|
+
// recognised rather than followed. Seeded with the caller's own cursor: a
|
|
201
|
+
// response echoing that back would re-fetch the page just merged.
|
|
202
|
+
const fetched = new Set<string>(startToken === undefined ? [] : [startToken]);
|
|
189
203
|
|
|
190
204
|
for (let pages = 0; pages < maxPages; pages++) {
|
|
191
205
|
const result = await runPage(token);
|
|
@@ -199,10 +213,23 @@ export async function fetchGmailPages(
|
|
|
199
213
|
}
|
|
200
214
|
base = parsed;
|
|
201
215
|
merged.push(...(parsed[itemsKey] as unknown[]));
|
|
202
|
-
|
|
216
|
+
const next = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== ''
|
|
203
217
|
? parsed.nextPageToken
|
|
204
218
|
: undefined;
|
|
205
|
-
if (
|
|
219
|
+
if (next === undefined) {
|
|
220
|
+
token = undefined;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
// Checked BEFORE the next fetch, so the duplicate page is never requested
|
|
224
|
+
// and never merged. `token` keeps the repeated cursor so the result still
|
|
225
|
+
// reads as truncated — it is the honest answer, and handing it back lets a
|
|
226
|
+
// caller retry later rather than concluding there is nothing more.
|
|
227
|
+
if (fetched.has(next)) {
|
|
228
|
+
token = next;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
fetched.add(next);
|
|
232
|
+
token = next;
|
|
206
233
|
}
|
|
207
234
|
|
|
208
235
|
return finish(base as Record<string, unknown>, itemsKey, merged, token);
|
package/src/lib.ts
CHANGED
|
@@ -2,9 +2,11 @@ export {
|
|
|
2
2
|
BASE_TOOL_REGISTRARS,
|
|
3
3
|
VERSION,
|
|
4
4
|
registerApiTools,
|
|
5
|
+
registerAppScriptTools,
|
|
5
6
|
registerAuthTools,
|
|
6
7
|
authToolsFor,
|
|
7
8
|
registerCalendarTools,
|
|
9
|
+
registerChatTools,
|
|
8
10
|
registerClassroomTools,
|
|
9
11
|
registerContactsTools,
|
|
10
12
|
registerDocsTools,
|
|
@@ -27,6 +29,20 @@ export { finalizeGmailSearch, fetchGmailPages } from './gmail-results.js';
|
|
|
27
29
|
export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
|
|
28
30
|
export { useRemoteGogRunner } from './remote-runner.js';
|
|
29
31
|
export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
|
|
32
|
+
// Caller-supplied attachment bytes — the only outbound attachment path that
|
|
33
|
+
// works when the caller and gog share no filesystem (hosted connector, or any
|
|
34
|
+
// GOG_RUNNER_URL backend). See src/attachments.ts.
|
|
35
|
+
export {
|
|
36
|
+
attachInlineParam,
|
|
37
|
+
inlineAttachmentSchema,
|
|
38
|
+
inlineAttachmentArgs,
|
|
39
|
+
inlineFileArg,
|
|
40
|
+
INLINE_ATTACHMENT_LIMITS_TEXT,
|
|
41
|
+
MAX_INLINE_ATTACHMENT_BYTES,
|
|
42
|
+
MAX_INLINE_ATTACHMENT_TOTAL_BYTES,
|
|
43
|
+
MAX_REQUEST_PAYLOAD_WIRE_BYTES,
|
|
44
|
+
} from './attachments.js';
|
|
45
|
+
export type { InlineAttachmentInput } from './attachments.js';
|
|
30
46
|
export {
|
|
31
47
|
PAYLOAD_INLINE_MAX,
|
|
32
48
|
payloadArg,
|