gogcli-mcp 2.19.1 → 2.20.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 +16 -6
- package/dist/lib.js +16 -6
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/connector-runtime.ts +22 -2
- package/src/remote-runner.ts +32 -11
- package/src/runner.ts +36 -2
- package/src/worker.ts +1 -1
- package/tests/connector-runtime.test.ts +51 -0
- package/tests/remote-runner.test.ts +124 -12
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.20.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "gogcli",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
18
|
-
"version": "2.
|
|
18
|
+
"version": "2.20.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/dist/index.js
CHANGED
|
@@ -31119,6 +31119,13 @@ function isGogFileArg(arg) {
|
|
|
31119
31119
|
return typeof arg !== "string";
|
|
31120
31120
|
}
|
|
31121
31121
|
var runExecutor = new AsyncLocalStorage();
|
|
31122
|
+
var defaultExecutor;
|
|
31123
|
+
function setDefaultGogExecutor(executor) {
|
|
31124
|
+
defaultExecutor = executor ? { executor } : void 0;
|
|
31125
|
+
}
|
|
31126
|
+
function activeExecutor() {
|
|
31127
|
+
return runExecutor.getStore() ?? defaultExecutor;
|
|
31128
|
+
}
|
|
31122
31129
|
var TIMEOUT_MS = 3e4;
|
|
31123
31130
|
function readonlyEnvEnabled() {
|
|
31124
31131
|
return readEnvVar("GOG_READONLY") !== void 0 && parseBoolEnv("GOG_READONLY", { default: true });
|
|
@@ -31279,7 +31286,7 @@ async function run(args, options = {}) {
|
|
|
31279
31286
|
const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full" } = options;
|
|
31280
31287
|
const redact = redactMode === "tokens" ? redactGoogleTokens : redactSecrets2;
|
|
31281
31288
|
const fullArgs = assembleArgs(args, { account, interactive, readonly: readonly2 });
|
|
31282
|
-
const store =
|
|
31289
|
+
const store = activeExecutor();
|
|
31283
31290
|
try {
|
|
31284
31291
|
let output;
|
|
31285
31292
|
if (spawner) {
|
|
@@ -31296,7 +31303,7 @@ async function run(args, options = {}) {
|
|
|
31296
31303
|
}
|
|
31297
31304
|
async function runBinary(args, options = {}) {
|
|
31298
31305
|
const { account, spawner, timeout, readonly: readonly2 = false } = options;
|
|
31299
|
-
if (!spawner &&
|
|
31306
|
+
if (!spawner && activeExecutor()) {
|
|
31300
31307
|
throw new Error(
|
|
31301
31308
|
"Raw byte retrieval is not available over the hosted connector (its transport is text-only). Use the text-extraction path instead, or run the local stdio server to fetch bytes."
|
|
31302
31309
|
);
|
|
@@ -33072,7 +33079,7 @@ function registerTasksTools(server) {
|
|
|
33072
33079
|
}
|
|
33073
33080
|
|
|
33074
33081
|
// src/server.ts
|
|
33075
|
-
var VERSION = true ? "2.
|
|
33082
|
+
var VERSION = true ? "2.20.0" : "0.0.0";
|
|
33076
33083
|
var BASE_TOOL_REGISTRARS = [
|
|
33077
33084
|
registerApiTools,
|
|
33078
33085
|
registerAuthTools,
|
|
@@ -33092,9 +33099,10 @@ var DEFAULT_TIMEOUT_MS = 3e4;
|
|
|
33092
33099
|
var DEADLINE_GRACE_MS = 5e3;
|
|
33093
33100
|
var RUNNER_GOG_FAILED = 422;
|
|
33094
33101
|
var RUNNER_DRAINING = 503;
|
|
33095
|
-
function makeFlyExecutor(endpoint, key) {
|
|
33102
|
+
function makeFlyExecutor(endpoint, key, readAccessToken) {
|
|
33096
33103
|
return async (args, opts) => {
|
|
33097
33104
|
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
33105
|
+
const accessToken = readAccessToken?.();
|
|
33098
33106
|
let res;
|
|
33099
33107
|
try {
|
|
33100
33108
|
res = await fetch(endpoint + "/run", {
|
|
@@ -33103,7 +33111,7 @@ function makeFlyExecutor(endpoint, key) {
|
|
|
33103
33111
|
Authorization: "Bearer " + key,
|
|
33104
33112
|
"Content-Type": "application/json"
|
|
33105
33113
|
},
|
|
33106
|
-
body: JSON.stringify({ args }),
|
|
33114
|
+
body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
|
|
33107
33115
|
signal: AbortSignal.timeout(deadlineMs)
|
|
33108
33116
|
});
|
|
33109
33117
|
} catch (err) {
|
|
@@ -33144,7 +33152,9 @@ function useRemoteGogRunner(env = process.env) {
|
|
|
33144
33152
|
const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
|
|
33145
33153
|
const key = readEnvVar("GOG_RUNNER_KEY", { env });
|
|
33146
33154
|
if (!endpoint || !key) return false;
|
|
33147
|
-
|
|
33155
|
+
setDefaultGogExecutor(
|
|
33156
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, () => readEnvVar("GOG_ACCESS_TOKEN", { env }))
|
|
33157
|
+
);
|
|
33148
33158
|
return true;
|
|
33149
33159
|
}
|
|
33150
33160
|
|
package/dist/lib.js
CHANGED
|
@@ -23016,6 +23016,13 @@ function isGogFileArg(arg) {
|
|
|
23016
23016
|
return typeof arg !== "string";
|
|
23017
23017
|
}
|
|
23018
23018
|
var runExecutor = new AsyncLocalStorage();
|
|
23019
|
+
var defaultExecutor;
|
|
23020
|
+
function setDefaultGogExecutor(executor) {
|
|
23021
|
+
defaultExecutor = executor ? { executor } : void 0;
|
|
23022
|
+
}
|
|
23023
|
+
function activeExecutor() {
|
|
23024
|
+
return runExecutor.getStore() ?? defaultExecutor;
|
|
23025
|
+
}
|
|
23019
23026
|
var TIMEOUT_MS = 3e4;
|
|
23020
23027
|
var MIN_GOG_VERSION = "0.34.1";
|
|
23021
23028
|
function readonlyEnvEnabled() {
|
|
@@ -23177,7 +23184,7 @@ async function run(args, options = {}) {
|
|
|
23177
23184
|
const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full" } = options;
|
|
23178
23185
|
const redact = redactMode === "tokens" ? redactGoogleTokens : redactSecrets2;
|
|
23179
23186
|
const fullArgs = assembleArgs(args, { account, interactive, readonly: readonly2 });
|
|
23180
|
-
const store =
|
|
23187
|
+
const store = activeExecutor();
|
|
23181
23188
|
try {
|
|
23182
23189
|
let output;
|
|
23183
23190
|
if (spawner) {
|
|
@@ -23194,7 +23201,7 @@ async function run(args, options = {}) {
|
|
|
23194
23201
|
}
|
|
23195
23202
|
async function runBinary(args, options = {}) {
|
|
23196
23203
|
const { account, spawner, timeout, readonly: readonly2 = false } = options;
|
|
23197
|
-
if (!spawner &&
|
|
23204
|
+
if (!spawner && activeExecutor()) {
|
|
23198
23205
|
throw new Error(
|
|
23199
23206
|
"Raw byte retrieval is not available over the hosted connector (its transport is text-only). Use the text-extraction path instead, or run the local stdio server to fetch bytes."
|
|
23200
23207
|
);
|
|
@@ -24978,7 +24985,7 @@ function registerTasksTools(server) {
|
|
|
24978
24985
|
}
|
|
24979
24986
|
|
|
24980
24987
|
// src/server.ts
|
|
24981
|
-
var VERSION = true ? "2.
|
|
24988
|
+
var VERSION = true ? "2.20.0" : "0.0.0";
|
|
24982
24989
|
var BASE_TOOL_REGISTRARS = [
|
|
24983
24990
|
registerApiTools,
|
|
24984
24991
|
registerAuthTools,
|
|
@@ -24998,9 +25005,10 @@ var DEFAULT_TIMEOUT_MS = 3e4;
|
|
|
24998
25005
|
var DEADLINE_GRACE_MS = 5e3;
|
|
24999
25006
|
var RUNNER_GOG_FAILED = 422;
|
|
25000
25007
|
var RUNNER_DRAINING = 503;
|
|
25001
|
-
function makeFlyExecutor(endpoint, key) {
|
|
25008
|
+
function makeFlyExecutor(endpoint, key, readAccessToken) {
|
|
25002
25009
|
return async (args, opts) => {
|
|
25003
25010
|
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
25011
|
+
const accessToken = readAccessToken?.();
|
|
25004
25012
|
let res;
|
|
25005
25013
|
try {
|
|
25006
25014
|
res = await fetch(endpoint + "/run", {
|
|
@@ -25009,7 +25017,7 @@ function makeFlyExecutor(endpoint, key) {
|
|
|
25009
25017
|
Authorization: "Bearer " + key,
|
|
25010
25018
|
"Content-Type": "application/json"
|
|
25011
25019
|
},
|
|
25012
|
-
body: JSON.stringify({ args }),
|
|
25020
|
+
body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
|
|
25013
25021
|
signal: AbortSignal.timeout(deadlineMs)
|
|
25014
25022
|
});
|
|
25015
25023
|
} catch (err) {
|
|
@@ -25050,7 +25058,9 @@ function useRemoteGogRunner(env = process.env) {
|
|
|
25050
25058
|
const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
|
|
25051
25059
|
const key = readEnvVar("GOG_RUNNER_KEY", { env });
|
|
25052
25060
|
if (!endpoint || !key) return false;
|
|
25053
|
-
|
|
25061
|
+
setDefaultGogExecutor(
|
|
25062
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, () => readEnvVar("GOG_ACCESS_TOKEN", { env }))
|
|
25063
|
+
);
|
|
25054
25064
|
return true;
|
|
25055
25065
|
}
|
|
25056
25066
|
export {
|
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.20.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",
|
package/package.json
CHANGED
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.20.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.20.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/connector-runtime.ts
CHANGED
|
@@ -60,9 +60,29 @@ const RUNNER_DRAINING = 503;
|
|
|
60
60
|
// clever here (flattening to `--flag=<inline>`, truncating, re-encoding) would
|
|
61
61
|
// put the payload straight back into argv and re-create the size cap this whole
|
|
62
62
|
// change exists to escape.
|
|
63
|
-
|
|
63
|
+
// `readAccessToken` is how a hosted gog acts as its CALLER rather than as
|
|
64
|
+
// whoever seeded the backend's volume (#230). The backend holds one Google
|
|
65
|
+
// identity; a token supplied with the request overrides it for that one `gog`
|
|
66
|
+
// invocation, and `gog` already prefers a directly-passed token over its store.
|
|
67
|
+
//
|
|
68
|
+
// A FUNCTION, read per call, not a string captured at construction. The whole
|
|
69
|
+
// claim being made is "this token belongs to this request", and an executor
|
|
70
|
+
// outlives any one request — on the Worker path a single isolate serves many
|
|
71
|
+
// callers, so a captured token would pin the first caller's identity onto
|
|
72
|
+
// everyone who followed. That is the same shared-identity bug this closes,
|
|
73
|
+
// rebuilt one layer up.
|
|
74
|
+
//
|
|
75
|
+
// Absent when there is no token, rather than null or "": the backend has to
|
|
76
|
+
// tell "act as this caller" from "act as the box", and an empty third state is
|
|
77
|
+
// one neither side has a meaning for.
|
|
78
|
+
export function makeFlyExecutor(
|
|
79
|
+
endpoint: string,
|
|
80
|
+
key: string,
|
|
81
|
+
readAccessToken?: () => string | undefined,
|
|
82
|
+
): GogExecutor {
|
|
64
83
|
return async (args: GogArg[], opts) => {
|
|
65
84
|
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
85
|
+
const accessToken = readAccessToken?.();
|
|
66
86
|
let res: Response;
|
|
67
87
|
try {
|
|
68
88
|
res = await fetch(endpoint + '/run', {
|
|
@@ -71,7 +91,7 @@ export function makeFlyExecutor(endpoint: string, key: string): GogExecutor {
|
|
|
71
91
|
Authorization: 'Bearer ' + key,
|
|
72
92
|
'Content-Type': 'application/json',
|
|
73
93
|
},
|
|
74
|
-
body: JSON.stringify({ args }),
|
|
94
|
+
body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
|
|
75
95
|
signal: AbortSignal.timeout(deadlineMs),
|
|
76
96
|
});
|
|
77
97
|
} catch (err) {
|
package/src/remote-runner.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
-
import {
|
|
2
|
+
import { setDefaultGogExecutor } from './runner.js';
|
|
3
3
|
import { makeFlyExecutor } from './connector-runtime.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -19,16 +19,26 @@ import { makeFlyExecutor } from './connector-runtime.js';
|
|
|
19
19
|
* executes remotely; leave either unset and nothing changes, which is what
|
|
20
20
|
* keeps every existing local install on the binary it already has.
|
|
21
21
|
*
|
|
22
|
-
* ## Why
|
|
22
|
+
* ## Why a process-wide default and not the AsyncLocalStorage
|
|
23
23
|
*
|
|
24
|
-
* `runExecutor` is an AsyncLocalStorage
|
|
25
|
-
* `runExecutor.run(...)` because
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
24
|
+
* `runExecutor` is an AsyncLocalStorage, and the Worker wraps each REQUEST in
|
|
25
|
+
* `runExecutor.run(...)` because one isolate serves many callers whose backend
|
|
26
|
+
* credentials differ. A stdio process is the opposite: one backend for its
|
|
27
|
+
* whole life.
|
|
28
|
+
*
|
|
29
|
+
* This used to reach for `enterWith` on the theory that it "sets the store for
|
|
30
|
+
* the whole process". It does not. `enterWith` sets the store on the async
|
|
31
|
+
* resource that is CURRENT when it runs — here, module evaluation — and the
|
|
32
|
+
* tool calls arrive later as I/O events on the transport's own resources, which
|
|
33
|
+
* do not descend from that. So `getStore()` was undefined at exactly the moment
|
|
34
|
+
* `run()` asked, and the seam reverted to spawning a binary the host does not
|
|
35
|
+
* have. Every hosted gog MCP answered "gog executable not found" while pointed
|
|
36
|
+
* at a healthy backend, and the unit test missed it because a test that calls
|
|
37
|
+
* this function itself awaits inside the resource it just mutated.
|
|
38
|
+
*
|
|
39
|
+
* A process-lifetime value is not a scoped value, so it does not live in a
|
|
40
|
+
* scope: `setDefaultGogExecutor` holds it, and a per-request store still beats
|
|
41
|
+
* it (runner.ts `activeExecutor`) so the Worker path is unchanged.
|
|
32
42
|
*
|
|
33
43
|
* Call before the server starts, so no tool can be serviced ahead of it.
|
|
34
44
|
*/
|
|
@@ -44,6 +54,17 @@ export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolea
|
|
|
44
54
|
// nothing — either alone is a misconfiguration, and silently spawning
|
|
45
55
|
// instead would hide it until someone wondered why the binary was needed.
|
|
46
56
|
if (!endpoint || !key) return false;
|
|
47
|
-
|
|
57
|
+
// Whose Google identity this process acts as (#230). The backend holds ONE
|
|
58
|
+
// identity on its volume, so without this every caller of a hosted gog acts
|
|
59
|
+
// as whoever seeded it. Under mcp-host's `perUserChild` this process belongs
|
|
60
|
+
// to a single caller and its environment carries that caller's token, so
|
|
61
|
+
// forwarding it is the whole of "act as the person calling you".
|
|
62
|
+
//
|
|
63
|
+
// Read per call rather than captured here, because the executor outlives any
|
|
64
|
+
// one request and the claim being made is about a request. Absent when unset,
|
|
65
|
+
// which is every registration that predates per-caller auth.
|
|
66
|
+
setDefaultGogExecutor(
|
|
67
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, () => readEnvVar('GOG_ACCESS_TOKEN', { env })),
|
|
68
|
+
);
|
|
48
69
|
return true;
|
|
49
70
|
}
|
package/src/runner.ts
CHANGED
|
@@ -50,6 +50,40 @@ export type GogExecutor = (
|
|
|
50
50
|
// `runExecutor.run({ executor }, ...)`; unset, `run()` falls back to spawning.
|
|
51
51
|
export const runExecutor = new AsyncLocalStorage<{ executor: GogExecutor }>();
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The PROCESS-WIDE executor, for a host that has exactly one backend for the
|
|
55
|
+
* whole process — a stdio bin pointed at a Fly runner (`useRemoteGogRunner`).
|
|
56
|
+
*
|
|
57
|
+
* It exists because AsyncLocalStorage cannot express that. `enterWith` sets the
|
|
58
|
+
* store on the async resource that is current when it runs, and a bin runs it
|
|
59
|
+
* during module evaluation; the tool calls arrive later as I/O events on the
|
|
60
|
+
* transport's own resources, which are not descendants of that evaluation, so
|
|
61
|
+
* `getStore()` is undefined exactly where it is needed. That is not a bug in
|
|
62
|
+
* `enterWith` — a process-lifetime default is simply not a scoped value, and
|
|
63
|
+
* storing it in a scope meant the seam silently reverted to spawning a binary
|
|
64
|
+
* the host does not have.
|
|
65
|
+
*
|
|
66
|
+
* A per-request store still WINS over this (see `activeExecutor`), because the
|
|
67
|
+
* Worker serves many callers from one isolate and each has its own backend
|
|
68
|
+
* credential; this is the fallback for the one-backend case, never a second
|
|
69
|
+
* answer to "whose backend is this".
|
|
70
|
+
*/
|
|
71
|
+
let defaultExecutor: { executor: GogExecutor } | undefined;
|
|
72
|
+
|
|
73
|
+
/** Install the process-wide executor. Passing undefined clears it (tests). */
|
|
74
|
+
export function setDefaultGogExecutor(executor: GogExecutor | undefined): void {
|
|
75
|
+
defaultExecutor = executor ? { executor } : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whose executor applies right now: the request's, else the process's, else
|
|
80
|
+
* none (meaning `run()` spawns the local binary). Both call sites ask through
|
|
81
|
+
* here so they can never disagree about which of the three it is.
|
|
82
|
+
*/
|
|
83
|
+
function activeExecutor(): { executor: GogExecutor } | undefined {
|
|
84
|
+
return runExecutor.getStore() ?? defaultExecutor;
|
|
85
|
+
}
|
|
86
|
+
|
|
53
87
|
export interface RunOptions {
|
|
54
88
|
account?: string;
|
|
55
89
|
spawner?: Spawner;
|
|
@@ -328,7 +362,7 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
|
|
|
328
362
|
// successful `gog auth tokens` (or any command echoing a credential) would
|
|
329
363
|
// otherwise return raw Google tokens (ya29.…/1//…) into model context, where
|
|
330
364
|
// a sibling tool (gog_gmail_send) could exfiltrate them.
|
|
331
|
-
const store =
|
|
365
|
+
const store = activeExecutor();
|
|
332
366
|
try {
|
|
333
367
|
let output: string;
|
|
334
368
|
if (spawner) {
|
|
@@ -358,7 +392,7 @@ export async function runBinary(args: GogArg[], options: RunOptions = {}): Promi
|
|
|
358
392
|
// An injected spawner is the stdio/test path and always wins. Otherwise, if an
|
|
359
393
|
// ambient forward executor is installed (the Worker/Fly connector), refuse:
|
|
360
394
|
// its text-only transport can't carry bytes intact.
|
|
361
|
-
if (!spawner &&
|
|
395
|
+
if (!spawner && activeExecutor()) {
|
|
362
396
|
throw new Error(
|
|
363
397
|
'Raw byte retrieval is not available over the hosted connector (its transport is text-only). ' +
|
|
364
398
|
'Use the text-extraction path instead, or run the local stdio server to fetch bytes.',
|
package/src/worker.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { gogAuth, type GogProps } from './connector-auth.js';
|
|
|
38
38
|
// connector with all ~360 tools at once. Add whichever paths you want as separate
|
|
39
39
|
// connectors in claude.ai (each authorizes with the same connector key).
|
|
40
40
|
|
|
41
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.20.0'; // x-release-please-version
|
|
42
42
|
|
|
43
43
|
// Build an McpAgent subclass whose init() registers `registrars` onto its server,
|
|
44
44
|
// each handler wrapped in the ALS scope carrying the per-session Fly executor.
|
|
@@ -120,6 +120,57 @@ describe('makeFlyExecutor', () => {
|
|
|
120
120
|
expect(init.body).toBe(JSON.stringify({ args: ['sheets', 'get', 'A1'] }));
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
// A hosted gog authenticates as whoever seeded the Fly volume, not as the
|
|
124
|
+
// person calling it (#230). mcp-host can give each caller their own child
|
|
125
|
+
// carrying their own GOG_ACCESS_TOKEN, so the missing link is the child
|
|
126
|
+
// handing that token to the backend for ITS call only — never as something
|
|
127
|
+
// ambient on the box, which would be the same shared identity from the other
|
|
128
|
+
// direction.
|
|
129
|
+
describe('per-request access token', () => {
|
|
130
|
+
function okFetch() {
|
|
131
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ stdout: 'ok' }) }));
|
|
132
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
133
|
+
return fetchMock;
|
|
134
|
+
}
|
|
135
|
+
function bodyOf(fetchMock: { mock: { calls: unknown[][] } }, i = 0): Record<string, unknown> {
|
|
136
|
+
const [, init] = fetchMock.mock.calls[i] as [string, RequestInit];
|
|
137
|
+
return JSON.parse(init.body as string) as Record<string, unknown>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it('sends the token with the request when one is available', async () => {
|
|
141
|
+
const fetchMock = okFetch();
|
|
142
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, () => 'ya29.caller-token');
|
|
143
|
+
await exec(['auth', 'status'], {});
|
|
144
|
+
expect(bodyOf(fetchMock)).toEqual({ args: ['auth', 'status'], accessToken: 'ya29.caller-token' });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('omits the field entirely when there is no token', async () => {
|
|
148
|
+
// Absent, not null or "": the backend distinguishes "act as the caller"
|
|
149
|
+
// from "act as the box", and a present-but-empty field would be a third
|
|
150
|
+
// state neither side has a meaning for.
|
|
151
|
+
const fetchMock = okFetch();
|
|
152
|
+
for (const provider of [undefined, () => undefined, () => '']) {
|
|
153
|
+
vi.clearAllMocks();
|
|
154
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, provider as (() => string | undefined) | undefined);
|
|
155
|
+
await exec(['auth', 'status'], {});
|
|
156
|
+
expect(bodyOf(fetchMock)).toEqual({ args: ['auth', 'status'] });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reads the token per CALL, not once when the executor is built', async () => {
|
|
161
|
+
// The whole contract is "this token belongs to this request". Reading it
|
|
162
|
+
// once at construction would pin the first caller's identity onto an
|
|
163
|
+
// executor that outlives them — exactly the bug this closes, rebuilt.
|
|
164
|
+
const fetchMock = okFetch();
|
|
165
|
+
const tokens = ['first', 'second'];
|
|
166
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, () => tokens.shift());
|
|
167
|
+
await exec(['auth', 'status'], {});
|
|
168
|
+
await exec(['auth', 'status'], {});
|
|
169
|
+
expect(bodyOf(fetchMock, 0).accessToken).toBe('first');
|
|
170
|
+
expect(bodyOf(fetchMock, 1).accessToken).toBe('second');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
123
174
|
// Everything below is the file-arg wire contract. The Worker has no filesystem
|
|
124
175
|
// and no gog binary, so a GogFileArg must cross the wire STRUCTURED and be
|
|
125
176
|
// materialized on the Fly runner. If this layer ever flattened it back into an
|
|
@@ -3,16 +3,30 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
5
5
|
import { useRemoteGogRunner } from '../src/remote-runner.js';
|
|
6
|
-
import { runExecutor } from '../src/runner.js';
|
|
6
|
+
import { run, runBinary, runExecutor, setDefaultGogExecutor } from '../src/runner.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The whole point of this seam is that a host without the `gog` binary can
|
|
10
|
-
* still serve.
|
|
11
|
-
* back to spawning
|
|
12
|
-
*
|
|
10
|
+
* still serve. Every way it has silently failed so far: a half-configured env
|
|
11
|
+
* that falls back to spawning; a bin that never calls `useRemoteGogRunner()`;
|
|
12
|
+
* and — the one that shipped in 2.19.x — parking the executor in an
|
|
13
|
+
* AsyncLocalStorage that the tool calls never inherit, so the seam reported
|
|
14
|
+
* success and then spawned anyway.
|
|
15
|
+
*
|
|
16
|
+
* They share a shape worth naming: every one of them fails by falling back to
|
|
17
|
+
* the local binary, which on a laptop is invisible and on a host is total. So
|
|
18
|
+
* these tests assert on where a call ACTUALLY went, never on whether the wiring
|
|
19
|
+
* step was performed.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
|
-
afterEach(() =>
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.unstubAllGlobals();
|
|
24
|
+
vi.unstubAllEnvs();
|
|
25
|
+
// The executor is process-wide BY DESIGN now, so unlike an ALS store it does
|
|
26
|
+
// not unwind when a test ends — without this, the first test to install one
|
|
27
|
+
// would silently hand it to every test that ran after it.
|
|
28
|
+
setDefaultGogExecutor(undefined);
|
|
29
|
+
});
|
|
16
30
|
|
|
17
31
|
describe('useRemoteGogRunner', () => {
|
|
18
32
|
it('does nothing unless BOTH variables are set, so local installs are untouched', () => {
|
|
@@ -33,26 +47,124 @@ describe('useRemoteGogRunner', () => {
|
|
|
33
47
|
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'null', GOG_RUNNER_KEY: 'k' })).toBe(false);
|
|
34
48
|
});
|
|
35
49
|
|
|
36
|
-
it('
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
50
|
+
it('addresses and authenticates the backend correctly', async () => {
|
|
51
|
+
// Asserted THROUGH run(), not by reading whichever slot the executor is
|
|
52
|
+
// parked in. The previous version of this test fetched
|
|
53
|
+
// `runExecutor.getStore()` and called what it found, so it was really a
|
|
54
|
+
// test that `enterWith` had been called — which stayed green while the
|
|
55
|
+
// shipped bins routed every call to a local binary (see the next test).
|
|
40
56
|
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
|
|
41
57
|
vi.stubGlobal('fetch', fetchMock);
|
|
58
|
+
vi.stubEnv('GOG_PATH', '/nonexistent/gog');
|
|
42
59
|
|
|
43
60
|
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test/', GOG_RUNNER_KEY: 'secret' })).toBe(true);
|
|
44
61
|
|
|
45
62
|
// Cross a macrotask boundary, the way a stdio tool call does.
|
|
46
63
|
await new Promise((r) => setTimeout(r, 0));
|
|
47
|
-
|
|
48
|
-
expect(store?.executor).toBeTypeOf('function');
|
|
64
|
+
expect(await run(['--version'])).toBe('ok');
|
|
49
65
|
|
|
50
|
-
await store!.executor(['--version'], {});
|
|
51
66
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
52
67
|
// Trailing slash trimmed, so the endpoint is never `…//run`.
|
|
53
68
|
expect(url).toBe('https://r.test/run');
|
|
54
69
|
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer secret');
|
|
55
70
|
});
|
|
71
|
+
|
|
72
|
+
it('still routes remotely from a context the store never reached', async () => {
|
|
73
|
+
// THE ONE THE TEST ABOVE CANNOT CATCH, and it shipped broken.
|
|
74
|
+
//
|
|
75
|
+
// `enterWith` mutates the store of the async resource that is current when
|
|
76
|
+
// it runs. The test above calls useRemoteGogRunner() itself, so everything
|
|
77
|
+
// it awaits afterwards is a descendant of that resource and inherits the
|
|
78
|
+
// store — it passes whether or not the seam works where it matters.
|
|
79
|
+
//
|
|
80
|
+
// A bin does something different: it calls useRemoteGogRunner() while the
|
|
81
|
+
// ES module is evaluating, and then `await runMcp(...)` starts serving. The
|
|
82
|
+
// tool calls arrive later as I/O events on the transport's own async
|
|
83
|
+
// resources, which are NOT descendants of module evaluation, so
|
|
84
|
+
// `getStore()` is undefined by the time `run()` asks. Verified against the
|
|
85
|
+
// published 2.19.1 bin: `useRemoteGogRunner()` returned true, and at call
|
|
86
|
+
// time the store was undefined — every hosted gog MCP answered
|
|
87
|
+
// "gog executable not found" while pointed at a healthy backend.
|
|
88
|
+
//
|
|
89
|
+
// `runExecutor.exit()` is that condition exactly, and deterministically:
|
|
90
|
+
// run the call where the ALS store does not apply.
|
|
91
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'remote' }), { status: 200 }));
|
|
92
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
93
|
+
// So the spawn fallback cannot accidentally succeed on a machine that has
|
|
94
|
+
// gog installed and make this pass for the wrong reason.
|
|
95
|
+
vi.stubEnv('GOG_PATH', '/nonexistent/gog');
|
|
96
|
+
|
|
97
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
|
|
98
|
+
|
|
99
|
+
const output = await runExecutor.exit(() => run(['auth', 'status']));
|
|
100
|
+
expect(output).toBe('remote');
|
|
101
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('forwards its own GOG_ACCESS_TOKEN so a hosted gog acts as its caller', async () => {
|
|
105
|
+
// Under mcp-host's perUserChild, THIS PROCESS belongs to one caller and
|
|
106
|
+
// holds their token (#230). The backend has one Google identity on its
|
|
107
|
+
// volume, so without forwarding, every caller of a hosted gog acts as
|
|
108
|
+
// whoever seeded it.
|
|
109
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
|
|
110
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
111
|
+
vi.stubEnv('GOG_PATH', '/nonexistent/gog');
|
|
112
|
+
|
|
113
|
+
expect(
|
|
114
|
+
useRemoteGogRunner({
|
|
115
|
+
GOG_RUNNER_URL: 'https://r.test',
|
|
116
|
+
GOG_RUNNER_KEY: 'secret',
|
|
117
|
+
GOG_ACCESS_TOKEN: 'ya29.the-caller',
|
|
118
|
+
}),
|
|
119
|
+
).toBe(true);
|
|
120
|
+
|
|
121
|
+
await runExecutor.exit(() => run(['auth', 'status']));
|
|
122
|
+
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
123
|
+
expect(JSON.parse(init.body as string).accessToken).toBe('ya29.the-caller');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('sends no token when the caller has not supplied one', async () => {
|
|
127
|
+
// Absent, not empty: the backend distinguishes "act as this caller" from
|
|
128
|
+
// "act as the box", and every registration that predates per-caller auth is
|
|
129
|
+
// the second case.
|
|
130
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
|
|
131
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
132
|
+
vi.stubEnv('GOG_PATH', '/nonexistent/gog');
|
|
133
|
+
|
|
134
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
|
|
135
|
+
|
|
136
|
+
await runExecutor.exit(() => run(['auth', 'status']));
|
|
137
|
+
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
138
|
+
// The KEY must be absent, not present-and-empty. Asserted on its own rather
|
|
139
|
+
// than by matching the whole body: `assembleArgs` folds in ambient settings
|
|
140
|
+
// like GOG_ACCOUNT, so a whole-body match passes or fails depending on the
|
|
141
|
+
// developer's shell.
|
|
142
|
+
expect(JSON.parse(init.body as string)).not.toHaveProperty('accessToken');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('lets a per-request store beat the process-wide default', async () => {
|
|
146
|
+
// The Worker path scopes every request in `runExecutor.run({ executor })`
|
|
147
|
+
// because one isolate serves many callers (connector-runtime.ts). A
|
|
148
|
+
// process-wide default must never shadow that: it is the fallback for a
|
|
149
|
+
// stdio bin, not a second answer to "whose backend is this".
|
|
150
|
+
const perRequest = vi.fn(async () => 'per-request');
|
|
151
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ stdout: 'default' }), { status: 200 })));
|
|
152
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
|
|
153
|
+
|
|
154
|
+
const output = await runExecutor.run({ executor: perRequest }, () => run(['auth', 'status']));
|
|
155
|
+
expect(output).toBe('per-request');
|
|
156
|
+
expect(perRequest).toHaveBeenCalledTimes(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('still refuses binary retrieval when the default is a remote executor', async () => {
|
|
160
|
+
// runBinary's guard asks the same question ("is a text-only forward
|
|
161
|
+
// installed?") and must read the same answer, or a hosted bin would fall
|
|
162
|
+
// through to spawning for bytes while every text call went remote.
|
|
163
|
+
vi.stubEnv('GOG_PATH', '/nonexistent/gog');
|
|
164
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
|
|
165
|
+
|
|
166
|
+
await expect(runExecutor.exit(() => runBinary(['drive', 'read']))).rejects.toThrow(/text-only/);
|
|
167
|
+
});
|
|
56
168
|
});
|
|
57
169
|
|
|
58
170
|
/**
|