gogcli-mcp 3.0.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 +273 -748
- package/dist/lib.js +375 -785
- package/manifest.json +2 -2
- package/mint.yaml +37 -33
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/attachments.ts +28 -34
- package/src/blob-upload.ts +165 -134
- package/src/blob-urls.ts +3 -5
- package/src/bootstrap-auth.ts +97 -0
- package/src/index.ts +3 -4
- package/src/lib.ts +14 -9
- package/src/runner.ts +23 -175
- package/src/tools/appscript.ts +1 -1
- package/src/tools/auth.ts +5 -5
- package/src/tools/drive.ts +1 -3
- package/src/tools/gmail.ts +9 -9
- package/src/tools/utils.ts +12 -58
- package/tests/attachments.test.ts +11 -14
- package/tests/blob-upload.test.ts +235 -160
- package/tests/bootstrap-auth.test.ts +245 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +8 -95
- package/tests/sdk-single-copy.test.ts +4 -12
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +2 -2
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +6 -14
- package/tsconfig.json +1 -2
- package/vitest.config.ts +2 -12
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -319
- package/src/connector-login.ts +0 -87
- package/src/connector-runtime.ts +0 -910
- package/src/google-probe.ts +0 -113
- package/src/google-token.ts +0 -391
- package/src/remote-runner.ts +0 -77
- package/src/worker.ts +0 -117
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-login.test.ts +0 -151
- package/tests/connector-runtime.test.ts +0 -1664
- package/tests/google-probe.test.ts +0 -116
- package/tests/google-token.test.ts +0 -425
- package/tests/remote-runner.test.ts +0 -202
- package/tests/worker.test.ts +0 -167
package/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": "
|
|
6
|
+
"version": "4.0.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",
|
|
@@ -266,7 +266,7 @@
|
|
|
266
266
|
},
|
|
267
267
|
{
|
|
268
268
|
"name": "gog_drive_read_bytes",
|
|
269
|
-
"description": "Fetch a Drive file's raw bytes as a base64 resource
|
|
269
|
+
"description": "Fetch a Drive file's raw bytes as a base64 resource"
|
|
270
270
|
},
|
|
271
271
|
{
|
|
272
272
|
"name": "gog_drive_get",
|
package/mint.yaml
CHANGED
|
@@ -11,25 +11,29 @@ env:
|
|
|
11
11
|
- name: GOG_CLIENT_ID
|
|
12
12
|
required: false
|
|
13
13
|
help: >-
|
|
14
|
-
Google OAuth client id.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
Google OAuth client id. Read by the Node wrapper at startup
|
|
15
|
+
(bootstrap-auth.ts), which imports it into gog's keyring via a temp file
|
|
16
|
+
together with GOG_CLIENT_SECRET, GOG_REFRESH_TOKEN and GOG_ACCOUNT — set
|
|
17
|
+
all four or none. Not a secret, so unlike GOG_CLIENT_SECRET and
|
|
18
|
+
GOG_REFRESH_TOKEN it is not stripped from the spawned `gog`'s
|
|
19
|
+
environment; gog authenticates from its keyring under $HOME (see
|
|
20
|
+
state.dataDir).
|
|
19
21
|
- name: GOG_CLIENT_SECRET
|
|
20
22
|
secret: true
|
|
21
23
|
required: false
|
|
22
24
|
help: >-
|
|
23
|
-
Google OAuth client secret.
|
|
24
|
-
by runner.ts's
|
|
25
|
+
Google OAuth client secret. Imported into gog's keyring at startup with
|
|
26
|
+
GOG_CLIENT_ID; stripped from the spawned CLI's environment by runner.ts's
|
|
27
|
+
*_SECRET rule.
|
|
25
28
|
- name: GOG_REFRESH_TOKEN
|
|
26
29
|
secret: true
|
|
27
30
|
required: false
|
|
28
31
|
help: >-
|
|
29
|
-
Google OAuth refresh token
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
Google OAuth refresh token (`gog auth tokens export` prints one). Imported
|
|
33
|
+
into gog's keyring at startup with GOG_CLIENT_ID; stripped from the
|
|
34
|
+
spawned CLI's environment by runner.ts's *_TOKEN rule. Changing it
|
|
35
|
+
re-imports on the next start; an in-connector re-auth
|
|
36
|
+
(gog_auth_add_url/complete) stays in effect until it changes.
|
|
33
37
|
- name: GOG_ACCESS_TOKEN
|
|
34
38
|
secret: true
|
|
35
39
|
required: false
|
|
@@ -40,8 +44,21 @@ env:
|
|
|
40
44
|
- name: GOG_ACCOUNT
|
|
41
45
|
required: false
|
|
42
46
|
help: >-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
The Google account to act as. Required for the startup auth bootstrap
|
|
48
|
+
(the refresh token is imported under this email); otherwise defaults to
|
|
49
|
+
gog's single/most recent account.
|
|
50
|
+
- name: GOG_KEYRING_BACKEND
|
|
51
|
+
required: false
|
|
52
|
+
help: >-
|
|
53
|
+
gog's credential store. Set to "file" on a headless host — there is no OS
|
|
54
|
+
keychain — so the keyring lives on the persistent data dir.
|
|
55
|
+
- name: GOG_KEYRING_PASSWORD
|
|
56
|
+
secret: true
|
|
57
|
+
required: false
|
|
58
|
+
help: >-
|
|
59
|
+
Encrypts gog's file keyring on the data dir. Required with
|
|
60
|
+
GOG_KEYRING_BACKEND=file. Deliberately not stripped from the spawned
|
|
61
|
+
CLI's environment — it is the one credential gog itself reads.
|
|
45
62
|
- name: GOG_READONLY
|
|
46
63
|
required: false
|
|
47
64
|
help: >-
|
|
@@ -52,17 +69,6 @@ env:
|
|
|
52
69
|
help: >-
|
|
53
70
|
Path to the `gog` binary. Leave unset when the dependency below supplies
|
|
54
71
|
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
72
|
- name: GOG_TIMEZONE
|
|
67
73
|
required: false
|
|
68
74
|
help: >-
|
|
@@ -81,9 +87,8 @@ dependencies:
|
|
|
81
87
|
# Every tool shells out to the `gog` CLI; without it the server starts and
|
|
82
88
|
# then fails on the first call. This tag must track
|
|
83
89
|
# packages/gogcli-mcp/src/runner.ts's MIN_GOG_VERSION (the floor the tools
|
|
84
|
-
# assume)
|
|
85
|
-
#
|
|
86
|
-
# nine pins too.
|
|
90
|
+
# assume). See CLAUDE.md "Required gog version" — bumping the floor means
|
|
91
|
+
# bumping these nine pins, and the pin stored on each mcp-host registration.
|
|
87
92
|
- kind: github-release
|
|
88
93
|
repo: openclaw/gogcli
|
|
89
94
|
tag: v0.40.0
|
|
@@ -92,15 +97,14 @@ dependencies:
|
|
|
92
97
|
state:
|
|
93
98
|
dataDir: true
|
|
94
99
|
reason: >-
|
|
95
|
-
`gog` keeps its authorised-account state and
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
`gog` keeps its authorised-account state and file keyring under $HOME, and
|
|
101
|
+
the wrapper keeps its auth-bootstrap marker (~/.gogcli-mcp) there, so a
|
|
102
|
+
restart neither re-imports an unchanged secret nor discards an
|
|
103
|
+
in-connector re-auth. Without a persistent data dir every cold start has
|
|
104
|
+
only what the env secrets can re-seed.
|
|
98
105
|
egress:
|
|
99
106
|
allow:
|
|
100
107
|
# Google OAuth token exchange and the Google REST APIs the CLI calls.
|
|
101
108
|
- oauth2.googleapis.com
|
|
102
109
|
- www.googleapis.com
|
|
103
110
|
- 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": "
|
|
3
|
+
"version": "4.0.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>",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/node": "^26.5.1",
|
|
50
|
-
"@vitest/coverage-v8": "^
|
|
50
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
51
51
|
"esbuild": "^0.28.2",
|
|
52
52
|
"typescript": "^7.0.2",
|
|
53
|
-
"vitest": "^
|
|
53
|
+
"vitest": "^5.0.1"
|
|
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": "
|
|
10
|
+
"version": "4.0.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "
|
|
15
|
+
"version": "4.0.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/attachments.ts
CHANGED
|
@@ -9,14 +9,13 @@ import type { GogArg, GogFileArg } from './runner.js';
|
|
|
9
9
|
* Every attachment input gog offers is a PATH — `gmail send --attach`,
|
|
10
10
|
* `gmail drafts create --attach`, `drive upload <localPath>` — and those paths
|
|
11
11
|
* resolve wherever gog runs. On the local stdio transport that is the caller's
|
|
12
|
-
* own machine and everything works. On
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* own machine and everything works. On a hosted deployment (mcp-host), gog
|
|
13
|
+
* runs somewhere else entirely: no path the caller can name exists there, so
|
|
14
|
+
* outbound attachments would be impossible — including via Drive, whose upload
|
|
15
|
+
* takes a path too.
|
|
16
16
|
*
|
|
17
17
|
* The seam that fixes it already existed. `GogFileArg` writes a payload to a
|
|
18
|
-
* private temp file NEXT TO GOG
|
|
19
|
-
* mkdtemp dir for the local one — and passes the resulting path. It was built
|
|
18
|
+
* private temp file NEXT TO GOG (a mkdtemp dir) and passes the resulting path. It was built
|
|
20
19
|
* for oversized text (a long HTML mail body) that could not fit in argv; all
|
|
21
20
|
* binary attachments need on top of that is a base64 spelling and control of
|
|
22
21
|
* the basename, both of which are now `GogFileArg` fields.
|
|
@@ -27,39 +26,34 @@ import type { GogArg, GogFileArg } from './runner.js';
|
|
|
27
26
|
|
|
28
27
|
// Ceiling for ONE attachment's decoded bytes.
|
|
29
28
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// Checked in the TOOL so the error names the file and the limit instead.
|
|
29
|
+
// A published, stable limit: callers plan around it, so it stays fixed. Checked
|
|
30
|
+
// in the TOOL so the error names the file and the limit, rather than the caller
|
|
31
|
+
// meeting an oversized request as an opaque failure from a layer they cannot
|
|
32
|
+
// see.
|
|
35
33
|
export const MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
36
34
|
|
|
37
|
-
// The
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
const RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
35
|
+
// The budget for an ENTIRE request's payload, as spelled on the wire (32 MiB).
|
|
36
|
+
// An MCP tool call carries every attachment as base64 inside one JSON-RPC
|
|
37
|
+
// message, so the whole request — not each file — is what has to stay bounded.
|
|
38
|
+
const REQUEST_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
42
39
|
|
|
43
40
|
// Room inside that body for the JSON structure alone — key names, quoting,
|
|
44
|
-
// commas,
|
|
41
|
+
// commas, and the short flag strings (`--to=…`, `--subject=…`).
|
|
45
42
|
// It does NOT have to cover the mail body: a large body is a GogFileArg, and
|
|
46
43
|
// GogFileArgs are measured explicitly below rather than absorbed here.
|
|
47
|
-
const
|
|
44
|
+
const REQUEST_BODY_JSON_RESERVE_BYTES = 256 * 1024;
|
|
48
45
|
|
|
49
46
|
/**
|
|
50
|
-
* How many bytes of PAYLOAD one
|
|
51
|
-
*
|
|
47
|
+
* How many bytes of PAYLOAD one request can carry, counted as they are spelled
|
|
48
|
+
* on the wire.
|
|
52
49
|
*
|
|
53
50
|
* 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:
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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.
|
|
51
|
+
* its files, and it is tighter than Gmail's own 25 MB limit: every payload rides
|
|
52
|
+
* inside one JSON body, where binary rides as base64 (4/3 inflation) and text
|
|
53
|
+
* rides verbatim. A limit expressed in decoded bytes must absorb that inflation
|
|
54
|
+
* or it documents a size that cannot actually be sent.
|
|
61
55
|
*/
|
|
62
|
-
export const MAX_REQUEST_PAYLOAD_WIRE_BYTES =
|
|
56
|
+
export const MAX_REQUEST_PAYLOAD_WIRE_BYTES = REQUEST_MAX_BODY_BYTES - REQUEST_BODY_JSON_RESERVE_BYTES;
|
|
63
57
|
|
|
64
58
|
// Ceiling for all inline attachments on one message, in DECODED bytes — the
|
|
65
59
|
// units a caller thinks in, derived from the wire budget above.
|
|
@@ -70,15 +64,15 @@ export const MAX_REQUEST_PAYLOAD_WIRE_BYTES = RUNNER_MAX_BODY_BYTES - RUNNER_BOD
|
|
|
70
64
|
// at roughly 1:1. `inlineAttachmentArgs` therefore measures the actual sibling
|
|
71
65
|
// args rather than trusting this number, so a 23 MiB attachment set plus a
|
|
72
66
|
// multi-MiB HTML body is refused here, with an error naming the body, instead of
|
|
73
|
-
// arriving as
|
|
67
|
+
// arriving as an opaque failure.
|
|
74
68
|
//
|
|
75
69
|
// Neither bound is hypothetical at the edges: three attachments at the
|
|
76
70
|
// documented 8 MiB per-file maximum is 24 MiB, which alone encodes to exactly
|
|
77
|
-
//
|
|
71
|
+
// the 32 MiB request budget, leaving nothing for anything else.
|
|
78
72
|
export const MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor((MAX_REQUEST_PAYLOAD_WIRE_BYTES * 3) / 4);
|
|
79
73
|
|
|
80
74
|
/**
|
|
81
|
-
* Bytes one already-assembled arg occupies in the
|
|
75
|
+
* Bytes one already-assembled arg occupies in the request's JSON body.
|
|
82
76
|
*
|
|
83
77
|
* A plain string costs its UTF-8 length; a file arg costs the length of its
|
|
84
78
|
* `contents` as spelled on the wire — the base64 text for binary, the UTF-8
|
|
@@ -127,8 +121,8 @@ export type InlineAttachmentInput = z.infer<typeof inlineAttachmentSchema>;
|
|
|
127
121
|
/** Reusable tool parameter — the same field on send, drafts create and update. */
|
|
128
122
|
export const attachInlineParam = z.array(inlineAttachmentSchema).optional().describe(
|
|
129
123
|
'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
|
|
131
|
-
+ `
|
|
124
|
+
+ 'and the gog server does not, which is always the case on a hosted deployment (e.g. mcp-host). '
|
|
125
|
+
+ `Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). `
|
|
132
126
|
+ 'Can be combined with `attach`: the two name disjoint files (paths read on the server vs. bytes sent '
|
|
133
127
|
+ 'with the call), and both end up as ordinary attachments on the message.',
|
|
134
128
|
);
|
|
@@ -171,7 +165,7 @@ function decodedLength(contentBase64: string): number | null {
|
|
|
171
165
|
|
|
172
166
|
/**
|
|
173
167
|
* Validate ONE caller-supplied file and turn it into a `GogFileArg`, which the
|
|
174
|
-
*
|
|
168
|
+
* runner materializes to a temp file beside gog.
|
|
175
169
|
*
|
|
176
170
|
* Throws with an actionable message on anything invalid. The MCP layer turns a
|
|
177
171
|
* thrown handler error into an `isError` result, so every rejection here reaches
|
package/src/blob-upload.ts
CHANGED
|
@@ -1,66 +1,46 @@
|
|
|
1
|
-
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* ## Why the runner and not this process
|
|
7
|
-
*
|
|
8
|
-
* Under the hosted connector this child is a FORWARDER: `run()` POSTs an
|
|
9
|
-
* arg-array to fly-gog-runner, the Google refresh token lives in that app's
|
|
10
|
-
* keyring, and `gog gmail attachment --out` writes the file to THAT box's disk.
|
|
11
|
-
* The child never sees a byte, so the runner is the only party that can send
|
|
12
|
-
* one anywhere. That is the same seam `deliverViaDrive` already uses — `gog
|
|
13
|
-
* drive upload <path>` pushes bytes from the runner's disk to a remote
|
|
14
|
-
* destination with the child out of the way — with a different destination.
|
|
2
|
+
* Stream a downloaded attachment off this machine's disk to a signed
|
|
3
|
+
* blob-store URL.
|
|
15
4
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
5
|
+
* gog runs in this process tree (mcp-host installs it onto the child's PATH),
|
|
6
|
+
* so `gog gmail attachment --out` has just written the file HERE, and this
|
|
7
|
+
* process is the party that sends it. The rules are the ones the retired Fly
|
|
8
|
+
* runner's `POST /upload` enforced, now applied locally:
|
|
19
9
|
*
|
|
20
|
-
*
|
|
10
|
+
* - The path is confined to the attachment download root, checked lexically
|
|
11
|
+
* and again on the real path (a symlink inside the root is a second way out).
|
|
12
|
+
* Unconfined, this is a file-read primitive aimed at a signed URL of the
|
|
13
|
+
* caller's choosing — and `$HOME` holds gog's keyring.
|
|
14
|
+
* - A file over the blob store's 100 MiB ceiling is refused BEFORE dialling:
|
|
15
|
+
* the gateway's 413 would arrive only after the whole transfer had run.
|
|
16
|
+
* - The PUT carries `Content-Length` from the file's own size (a chunked PUT is
|
|
17
|
+
* a 411) and `Content-Type` byte for byte as given — the signature commits to
|
|
18
|
+
* it, and a normalised header reads as a missing object.
|
|
19
|
+
* - One total deadline bounds the exchange, so a wedged transfer cannot leave
|
|
20
|
+
* the MCP request unanswered.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
* directory, refuses anything past the blob store's own 100 MiB ceiling before
|
|
24
|
-
* dialling, and PUTs the file with `Content-Length` from its own size and the
|
|
25
|
-
* `Content-Type` header byte for byte as given — the PUT signature commits to
|
|
26
|
-
* it. Its statuses carry the classification: `422` is deterministic (re-mint,
|
|
27
|
-
* do not retry), `502` is the far side or the transfer, `400`/`404` are this
|
|
28
|
-
* request. All of them are failures here; none of them is retried from this
|
|
29
|
-
* side, because a fresh URL is the only repair for the common one.
|
|
22
|
+
* Every message thrown has the signed URL and its bare `sig=` value scrubbed.
|
|
30
23
|
*/
|
|
31
24
|
|
|
32
25
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* INACTIVITY timer spent through `req.setTimeout` — it bounds a socket that has
|
|
37
|
-
* gone quiet, never a transfer that keeps dribbling — so nothing else stands
|
|
38
|
-
* between a wedged upload and an MCP request that never answers.
|
|
39
|
-
*
|
|
40
|
-
* Longer than `/run`'s 30 s because this is not a `gog` invocation: it is up to
|
|
41
|
-
* 100 MiB leaving a Fly machine, and firing before the runner can report a real
|
|
42
|
-
* failure would turn "the blob store refused the signature" into an opaque
|
|
43
|
-
* timeout — the same reason `makeFlyExecutor` sits its deadline above the
|
|
44
|
-
* backend's.
|
|
45
|
-
*
|
|
46
|
-
* It is `120 s + 5 s`, and it was 90 s: the doc block above cited that rule
|
|
47
|
-
* while the number inverted it, so a socket that went quiet was aborted HERE
|
|
48
|
-
* 30 s before the runner's own timer could say "the upload timed out after
|
|
49
|
-
* 120000ms". Exactly the opaque client abort the rule exists to prevent, and
|
|
50
|
-
* the one shape where the runner has something useful to say. The grace is
|
|
51
|
-
* `DEADLINE_GRACE_MS`'s 5 s for `DEADLINE_GRACE_MS`'s reason: enough for the
|
|
52
|
-
* answer to travel, and not a second more, because every extra second is a
|
|
53
|
-
* second of opaque timeout replacing a real error.
|
|
54
|
-
*
|
|
55
|
-
* Restated rather than imported — `fly-gog-runner/server.mjs` must not be
|
|
56
|
-
* pulled into the Worker bundle, the same trade `attachments.ts` makes for the
|
|
57
|
-
* runner's size ceilings — so the two move by hand, and a test asserts the
|
|
58
|
-
* ordering rather than leaving it to this comment.
|
|
26
|
+
* Where gmail's attachment download writes (`defaultOutPath` in
|
|
27
|
+
* packages/gogcli-mcp-gmail/src/tools/gmail-extra.ts) and the only tree this
|
|
28
|
+
* module will read back from.
|
|
59
29
|
*/
|
|
60
|
-
export const
|
|
30
|
+
export const ATTACHMENT_DOWNLOAD_ROOT = '/tmp/gog-attachments';
|
|
31
|
+
|
|
32
|
+
/** mcp-host's blob store caps one object at 100 MiB and answers 413 past it. */
|
|
33
|
+
export const MAX_BLOB_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/** Total deadline on one PUT, from dial to the blob store's last byte. */
|
|
36
|
+
export const BLOB_UPLOAD_TIMEOUT_MS = 120_000;
|
|
37
|
+
|
|
38
|
+
// Enough of the blob store's error body to quote its `{"error":"…"}`, bounded
|
|
39
|
+
// so a stray HTML page cannot become the message.
|
|
40
|
+
const ERROR_BODY_SNIPPET = 512;
|
|
61
41
|
|
|
62
42
|
export interface BlobUploadRequest {
|
|
63
|
-
/** The file to send,
|
|
43
|
+
/** The file to send, on this machine, inside the attachment download root. */
|
|
64
44
|
path: string;
|
|
65
45
|
/** The signed PUT URL. A credential — never log it, never echo it. */
|
|
66
46
|
url: string;
|
|
@@ -69,30 +49,24 @@ export interface BlobUploadRequest {
|
|
|
69
49
|
}
|
|
70
50
|
|
|
71
51
|
export interface BlobUploadOutcome {
|
|
72
|
-
/** Bytes
|
|
73
|
-
bytes
|
|
74
|
-
/** The blob store's status
|
|
75
|
-
status
|
|
52
|
+
/** Bytes streamed — the file's own size, not a claim. */
|
|
53
|
+
bytes: number;
|
|
54
|
+
/** The blob store's (2xx) status. */
|
|
55
|
+
status: number;
|
|
76
56
|
}
|
|
77
57
|
|
|
78
58
|
export interface BlobUploadOptions {
|
|
79
|
-
/**
|
|
80
|
-
|
|
59
|
+
/** The confinement root. Injected by tests. */
|
|
60
|
+
root?: string;
|
|
61
|
+
/** The total deadline. Injected by tests. */
|
|
62
|
+
timeoutMs?: number;
|
|
81
63
|
}
|
|
82
64
|
|
|
83
65
|
/**
|
|
84
66
|
* Remove a signed URL, and the bare signature inside it, from a message.
|
|
85
67
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* store's error is quoted through it and an HTTP client's rejection may name
|
|
89
|
-
* the URL it was dialling. Both shapes are removed, because either left
|
|
90
|
-
* standing is the whole credential — the signature is what `sig=` carries, and
|
|
91
|
-
* the rest of the URL is public.
|
|
92
|
-
*
|
|
93
|
-
* The empty needle is guarded rather than assumed away: `split('')` cuts a
|
|
94
|
-
* message into single characters and interleaves the replacement between every
|
|
95
|
-
* one of them.
|
|
68
|
+
* The empty needle is guarded: `split('')` cuts a message into single
|
|
69
|
+
* characters and interleaves the replacement between every one of them.
|
|
96
70
|
*/
|
|
97
71
|
function withoutUrlSecrets(message: string, url: string): string {
|
|
98
72
|
let out = url ? message.split(url).join('<signed url>') : message;
|
|
@@ -101,79 +75,136 @@ function withoutUrlSecrets(message: string, url: string): string {
|
|
|
101
75
|
return out;
|
|
102
76
|
}
|
|
103
77
|
|
|
78
|
+
class UploadError extends Error {}
|
|
79
|
+
|
|
80
|
+
async function resolveConfined(root: string, requested: string): Promise<{ file: string; bytes: number }> {
|
|
81
|
+
const path = await import('node:path');
|
|
82
|
+
const { realpath, stat } = await import('node:fs/promises');
|
|
83
|
+
// Separator-terminated, so `/tmp/gog-attachments-evil` is not "inside".
|
|
84
|
+
const isInside = (base: string, candidate: string) =>
|
|
85
|
+
candidate === base || candidate.startsWith(base + path.sep);
|
|
86
|
+
|
|
87
|
+
const lexicalRoot = path.resolve(root);
|
|
88
|
+
const candidate = path.resolve(lexicalRoot, requested);
|
|
89
|
+
if (!isInside(lexicalRoot, candidate)) {
|
|
90
|
+
throw new UploadError(`the attachment path must be inside ${lexicalRoot}`);
|
|
91
|
+
}
|
|
92
|
+
let realFile: string;
|
|
93
|
+
let realRoot: string;
|
|
94
|
+
try {
|
|
95
|
+
realFile = await realpath(candidate);
|
|
96
|
+
// /tmp is itself a symlink on macOS, so the root is compared real-to-real.
|
|
97
|
+
realRoot = await realpath(lexicalRoot);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new UploadError(`the downloaded attachment could not be found: ${(err as Error).message}`);
|
|
100
|
+
}
|
|
101
|
+
if (!isInside(realRoot, realFile)) {
|
|
102
|
+
throw new UploadError(`the attachment path must be inside ${realRoot}`);
|
|
103
|
+
}
|
|
104
|
+
const info = await stat(realFile);
|
|
105
|
+
if (!info.isFile()) throw new UploadError('the attachment path must name a regular file');
|
|
106
|
+
if (info.size > MAX_BLOB_UPLOAD_BYTES) {
|
|
107
|
+
throw new UploadError(
|
|
108
|
+
`the attachment is ${info.size} bytes; the blob store's maximum is ${MAX_BLOB_UPLOAD_BYTES} bytes`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return { file: realFile, bytes: info.size };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseTarget(url: string): URL {
|
|
115
|
+
let target: URL | undefined;
|
|
116
|
+
try {
|
|
117
|
+
target = new URL(url);
|
|
118
|
+
} catch {
|
|
119
|
+
// refused below
|
|
120
|
+
}
|
|
121
|
+
if (!target || (target.protocol !== 'https:' && target.protocol !== 'http:')) {
|
|
122
|
+
throw new UploadError('the upload target must be an absolute http(s) URL');
|
|
123
|
+
}
|
|
124
|
+
return target;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// `http.request` rather than `fetch`: undici drops a caller-set Content-Length
|
|
128
|
+
// from a streamed body and sends it chunked, which the gateway refuses. The
|
|
129
|
+
// body is a pipe, so a 100 MiB file is never held in memory.
|
|
130
|
+
async function putFile(
|
|
131
|
+
target: URL,
|
|
132
|
+
file: string,
|
|
133
|
+
bytes: number,
|
|
134
|
+
contentType: string,
|
|
135
|
+
timeoutMs: number,
|
|
136
|
+
): Promise<{ status: number; body: string }> {
|
|
137
|
+
const { createReadStream } = await import('node:fs');
|
|
138
|
+
const { pipeline } = await import('node:stream');
|
|
139
|
+
const transport = target.protocol === 'https:' ? await import('node:https') : await import('node:http');
|
|
140
|
+
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
let settled = false;
|
|
143
|
+
const source = createReadStream(file);
|
|
144
|
+
const req = transport.request(target, {
|
|
145
|
+
method: 'PUT',
|
|
146
|
+
headers: { 'content-type': contentType, 'content-length': String(bytes) },
|
|
147
|
+
});
|
|
148
|
+
// Release both ends either way: the blob store may answer (and refuse)
|
|
149
|
+
// before the body is sent, and an unfinished request holds its socket.
|
|
150
|
+
const finish = (settle: () => void) => {
|
|
151
|
+
if (settled) return;
|
|
152
|
+
settled = true;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
source.destroy();
|
|
155
|
+
req.destroy();
|
|
156
|
+
settle();
|
|
157
|
+
};
|
|
158
|
+
const fail = (err: unknown) => finish(() => reject(err));
|
|
159
|
+
const timer = setTimeout(
|
|
160
|
+
() => fail(new UploadError(`the upload to the blob store did not finish within ${timeoutMs}ms`)),
|
|
161
|
+
timeoutMs,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
req.on('error', fail);
|
|
165
|
+
req.on('response', (res) => {
|
|
166
|
+
const chunks: Buffer[] = [];
|
|
167
|
+
let held = 0;
|
|
168
|
+
res.on('data', (chunk: Buffer) => {
|
|
169
|
+
if (held >= ERROR_BODY_SNIPPET) return;
|
|
170
|
+
chunks.push(chunk);
|
|
171
|
+
held += chunk.length;
|
|
172
|
+
});
|
|
173
|
+
res.on('error', fail);
|
|
174
|
+
res.on('end', () => finish(() => resolve({
|
|
175
|
+
status: res.statusCode as number,
|
|
176
|
+
body: Buffer.concat(chunks).toString().slice(0, ERROR_BODY_SNIPPET),
|
|
177
|
+
})));
|
|
178
|
+
});
|
|
179
|
+
pipeline(source, req, (err) => { if (err) fail(err); });
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
104
183
|
/**
|
|
105
|
-
*
|
|
184
|
+
* PUT the file at `request.path` to `request.url`.
|
|
106
185
|
*
|
|
107
|
-
* Resolves with
|
|
108
|
-
* message safe to log.
|
|
109
|
-
*
|
|
110
|
-
* sends unauthenticated requests the runner rejects, and a key with no URL is a
|
|
111
|
-
* credential configured for nothing.
|
|
186
|
+
* Resolves with the bytes streamed and the blob store's status; THROWS on every
|
|
187
|
+
* failure, with a message safe to log. Nothing is retried: a fresh URL is the
|
|
188
|
+
* only repair for the common failure (a stale or mismatched signature).
|
|
112
189
|
*/
|
|
113
190
|
export async function uploadToBlobStore(
|
|
114
191
|
request: BlobUploadRequest,
|
|
115
192
|
options: BlobUploadOptions = {},
|
|
116
193
|
): Promise<BlobUploadOutcome> {
|
|
117
|
-
const env = options.env ?? process.env;
|
|
118
|
-
// The shared reader, as everywhere else: blanks, unexpanded `${...}`
|
|
119
|
-
// placeholders and the literal "undefined"/"null" are all unset.
|
|
120
|
-
const endpoint = readEnvVar('GOG_RUNNER_URL', { env });
|
|
121
|
-
const key = readEnvVar('GOG_RUNNER_KEY', { env });
|
|
122
|
-
if (!endpoint || !key) {
|
|
123
|
-
throw new Error(
|
|
124
|
-
'no gog runner is configured (GOG_RUNNER_URL + GOG_RUNNER_KEY, both or neither), and the ' +
|
|
125
|
-
'attachment bytes are on the runner\'s disk rather than here — so nothing on this side can send them',
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
let response: { ok: boolean; status: number; text(): Promise<string> };
|
|
130
|
-
try {
|
|
131
|
-
response = await fetch(`${endpoint.replace(/\/+$/, '')}/upload`, {
|
|
132
|
-
method: 'POST',
|
|
133
|
-
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
134
|
-
body: JSON.stringify({ path: request.path, url: request.url, contentType: request.contentType }),
|
|
135
|
-
signal: AbortSignal.timeout(RUNNER_UPLOAD_TIMEOUT_MS),
|
|
136
|
-
});
|
|
137
|
-
} catch (err) {
|
|
138
|
-
throw new Error(withoutUrlSecrets(
|
|
139
|
-
`the upload to the blob store did not complete: ${err instanceof Error ? err.message : String(err)}`,
|
|
140
|
-
request.url,
|
|
141
|
-
));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Inside a try of its own: the response ARRIVING is not the body arriving,
|
|
145
|
-
// and a severed or truncated one rejects here. That rejection is a transport
|
|
146
|
-
// failure like the dial above and is scrubbed like one — this module's
|
|
147
|
-
// contract is that nothing it throws carries the URL, and a guarantee with a
|
|
148
|
-
// hole in it is not one.
|
|
149
|
-
let raw: string;
|
|
150
194
|
try {
|
|
151
|
-
|
|
195
|
+
const target = parseTarget(request.url);
|
|
196
|
+
const { file, bytes } = await resolveConfined(options.root ?? ATTACHMENT_DOWNLOAD_ROOT, request.path);
|
|
197
|
+
const answer = await putFile(
|
|
198
|
+
target, file, bytes, request.contentType, options.timeoutMs ?? BLOB_UPLOAD_TIMEOUT_MS,
|
|
199
|
+
);
|
|
200
|
+
if (answer.status < 200 || answer.status >= 300) {
|
|
201
|
+
throw new UploadError(`the blob store refused the upload with ${answer.status}: ${answer.body}`.trim());
|
|
202
|
+
}
|
|
203
|
+
return { bytes, status: answer.status };
|
|
152
204
|
} catch (err) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
));
|
|
158
|
-
}
|
|
159
|
-
let body: { error?: string; bytes?: number; status?: number } = {};
|
|
160
|
-
try {
|
|
161
|
-
body = JSON.parse(raw) as typeof body;
|
|
162
|
-
} catch {
|
|
163
|
-
// A proxy's HTML error page, or a truncated answer. Quoted, never parsed
|
|
164
|
-
// into a decision — the status is the part that classifies.
|
|
165
|
-
body = { error: raw };
|
|
166
|
-
}
|
|
167
|
-
if (!response.ok) {
|
|
168
|
-
// The runner's own status says which layer failed (422 refused / 502 far
|
|
169
|
-
// side / 400 this request); `body.status` is the blob store's verdict when
|
|
170
|
-
// there was one. Both are reported, because "403 inside a 422" is what
|
|
171
|
-
// tells a stale signature from a broken runner.
|
|
172
|
-
throw new Error(withoutUrlSecrets(
|
|
173
|
-
`the runner could not store the attachment (HTTP ${response.status}` +
|
|
174
|
-
`${body.status ? `, blob store ${body.status}` : ''}): ${body.error ?? 'no reason given'}`,
|
|
175
|
-
request.url,
|
|
176
|
-
));
|
|
205
|
+
const message = err instanceof UploadError
|
|
206
|
+
? err.message
|
|
207
|
+
: `the upload to the blob store did not complete: ${(err as Error).message}`;
|
|
208
|
+
throw new Error(withoutUrlSecrets(message, request.url));
|
|
177
209
|
}
|
|
178
|
-
return { bytes: body.bytes, status: body.status };
|
|
179
210
|
}
|
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
|
/**
|