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.
@@ -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.19.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.19.2",
18
+ "version": "2.20.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
3
  "displayName": "gogcli",
4
- "version": "2.19.2",
4
+ "version": "2.20.0",
5
5
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
6
6
  "author": {
7
7
  "name": "Chris Hall",
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.19.2" : "0.0.0";
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(makeFlyExecutor(endpoint.replace(/\/+$/, ""), key));
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.19.2" : "0.0.0";
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(makeFlyExecutor(endpoint.replace(/\/+$/, ""), key));
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.19.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.19.2",
3
+ "version": "2.20.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>",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.19.2",
10
+ "version": "2.20.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.19.2",
15
+ "version": "2.20.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -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
- export function makeFlyExecutor(endpoint: string, key: string): GogExecutor {
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) {
@@ -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
- setDefaultGogExecutor(makeFlyExecutor(endpoint.replace(/\/+$/, ''), key));
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.19.2'; // x-release-please-version
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