gogcli-mcp 2.19.2 → 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 +7 -4
- package/dist/lib.js +7 -4
- 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 +12 -1
- package/src/worker.ts +1 -1
- package/tests/connector-runtime.test.ts +51 -0
- package/tests/remote-runner.test.ts +41 -0
|
@@ -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
|
@@ -33079,7 +33079,7 @@ function registerTasksTools(server) {
|
|
|
33079
33079
|
}
|
|
33080
33080
|
|
|
33081
33081
|
// src/server.ts
|
|
33082
|
-
var VERSION = true ? "2.
|
|
33082
|
+
var VERSION = true ? "2.20.0" : "0.0.0";
|
|
33083
33083
|
var BASE_TOOL_REGISTRARS = [
|
|
33084
33084
|
registerApiTools,
|
|
33085
33085
|
registerAuthTools,
|
|
@@ -33099,9 +33099,10 @@ var DEFAULT_TIMEOUT_MS = 3e4;
|
|
|
33099
33099
|
var DEADLINE_GRACE_MS = 5e3;
|
|
33100
33100
|
var RUNNER_GOG_FAILED = 422;
|
|
33101
33101
|
var RUNNER_DRAINING = 503;
|
|
33102
|
-
function makeFlyExecutor(endpoint, key) {
|
|
33102
|
+
function makeFlyExecutor(endpoint, key, readAccessToken) {
|
|
33103
33103
|
return async (args, opts) => {
|
|
33104
33104
|
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
33105
|
+
const accessToken = readAccessToken?.();
|
|
33105
33106
|
let res;
|
|
33106
33107
|
try {
|
|
33107
33108
|
res = await fetch(endpoint + "/run", {
|
|
@@ -33110,7 +33111,7 @@ function makeFlyExecutor(endpoint, key) {
|
|
|
33110
33111
|
Authorization: "Bearer " + key,
|
|
33111
33112
|
"Content-Type": "application/json"
|
|
33112
33113
|
},
|
|
33113
|
-
body: JSON.stringify({ args }),
|
|
33114
|
+
body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
|
|
33114
33115
|
signal: AbortSignal.timeout(deadlineMs)
|
|
33115
33116
|
});
|
|
33116
33117
|
} catch (err) {
|
|
@@ -33151,7 +33152,9 @@ function useRemoteGogRunner(env = process.env) {
|
|
|
33151
33152
|
const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
|
|
33152
33153
|
const key = readEnvVar("GOG_RUNNER_KEY", { env });
|
|
33153
33154
|
if (!endpoint || !key) return false;
|
|
33154
|
-
setDefaultGogExecutor(
|
|
33155
|
+
setDefaultGogExecutor(
|
|
33156
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, () => readEnvVar("GOG_ACCESS_TOKEN", { env }))
|
|
33157
|
+
);
|
|
33155
33158
|
return true;
|
|
33156
33159
|
}
|
|
33157
33160
|
|
package/dist/lib.js
CHANGED
|
@@ -24985,7 +24985,7 @@ function registerTasksTools(server) {
|
|
|
24985
24985
|
}
|
|
24986
24986
|
|
|
24987
24987
|
// src/server.ts
|
|
24988
|
-
var VERSION = true ? "2.
|
|
24988
|
+
var VERSION = true ? "2.20.0" : "0.0.0";
|
|
24989
24989
|
var BASE_TOOL_REGISTRARS = [
|
|
24990
24990
|
registerApiTools,
|
|
24991
24991
|
registerAuthTools,
|
|
@@ -25005,9 +25005,10 @@ var DEFAULT_TIMEOUT_MS = 3e4;
|
|
|
25005
25005
|
var DEADLINE_GRACE_MS = 5e3;
|
|
25006
25006
|
var RUNNER_GOG_FAILED = 422;
|
|
25007
25007
|
var RUNNER_DRAINING = 503;
|
|
25008
|
-
function makeFlyExecutor(endpoint, key) {
|
|
25008
|
+
function makeFlyExecutor(endpoint, key, readAccessToken) {
|
|
25009
25009
|
return async (args, opts) => {
|
|
25010
25010
|
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
25011
|
+
const accessToken = readAccessToken?.();
|
|
25011
25012
|
let res;
|
|
25012
25013
|
try {
|
|
25013
25014
|
res = await fetch(endpoint + "/run", {
|
|
@@ -25016,7 +25017,7 @@ function makeFlyExecutor(endpoint, key) {
|
|
|
25016
25017
|
Authorization: "Bearer " + key,
|
|
25017
25018
|
"Content-Type": "application/json"
|
|
25018
25019
|
},
|
|
25019
|
-
body: JSON.stringify({ args }),
|
|
25020
|
+
body: JSON.stringify(accessToken ? { args, accessToken } : { args }),
|
|
25020
25021
|
signal: AbortSignal.timeout(deadlineMs)
|
|
25021
25022
|
});
|
|
25022
25023
|
} catch (err) {
|
|
@@ -25057,7 +25058,9 @@ function useRemoteGogRunner(env = process.env) {
|
|
|
25057
25058
|
const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
|
|
25058
25059
|
const key = readEnvVar("GOG_RUNNER_KEY", { env });
|
|
25059
25060
|
if (!endpoint || !key) return false;
|
|
25060
|
-
setDefaultGogExecutor(
|
|
25061
|
+
setDefaultGogExecutor(
|
|
25062
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, () => readEnvVar("GOG_ACCESS_TOKEN", { env }))
|
|
25063
|
+
);
|
|
25061
25064
|
return true;
|
|
25062
25065
|
}
|
|
25063
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
|
@@ -54,6 +54,17 @@ export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolea
|
|
|
54
54
|
// nothing — either alone is a misconfiguration, and silently spawning
|
|
55
55
|
// instead would hide it until someone wondered why the binary was needed.
|
|
56
56
|
if (!endpoint || !key) return false;
|
|
57
|
-
|
|
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
|
+
);
|
|
58
69
|
return true;
|
|
59
70
|
}
|
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
|
|
@@ -101,6 +101,47 @@ describe('useRemoteGogRunner', () => {
|
|
|
101
101
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
102
102
|
});
|
|
103
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
|
+
|
|
104
145
|
it('lets a per-request store beat the process-wide default', async () => {
|
|
105
146
|
// The Worker path scopes every request in `runExecutor.run({ executor })`
|
|
106
147
|
// because one isolate serves many callers (connector-runtime.ts). A
|