gogcli-mcp 2.21.1 → 2.22.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/src/tools/auth.ts CHANGED
@@ -31,7 +31,10 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
31
31
  });
32
32
 
33
33
  server.registerTool('gog_auth_status', {
34
- description: 'Show gogcli auth configuration: keyring backend, credential files, and auth setup.',
34
+ description:
35
+ 'Show gogcli auth CONFIGURATION: keyring backend, credential files, and auth setup. Despite the ' +
36
+ 'name this is not a health check — it reads local setup and does not contact Google, so it says ' +
37
+ 'nothing about whether an account can still authenticate. Use gog_auth_health for that.',
35
38
  annotations: { readOnlyHint: true },
36
39
  inputSchema: {},
37
40
  }, async () => {
@@ -50,7 +53,10 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
50
53
  'service. Reports per account: whether the token is currently valid, the mapped cause when it is ' +
51
54
  'not, how long ago it was authorized, and a warning as it approaches the 7-day refresh-token limit ' +
52
55
  'that applies to OAuth apps whose consent screen is still in "Testing" mode. Run it proactively to ' +
53
- 're-authorize on your own schedule instead of mid-task.',
56
+ 're-authorize on your own schedule instead of mid-task. On the hosted connector this is the ONLY ' +
57
+ 'check that measures Google: a connector showing "connected" or "refreshed" has verified the ' +
58
+ 'connector key that reaches the gog machine, and nothing else — the Google credential lives on ' +
59
+ 'that machine and can be dead while the connection looks perfectly healthy.',
54
60
  annotations: { readOnlyHint: true },
55
61
  inputSchema: {},
56
62
  }, async () => {
package/src/worker.ts CHANGED
@@ -16,7 +16,7 @@ import { registerExtraGmailTools } from '../../gogcli-mcp-gmail/src/tools/gmail-
16
16
  import { registerExtraDriveTools } from '../../gogcli-mcp-drive/src/tools/drive-extra.js';
17
17
  import { registerExtraDocsTools } from '../../gogcli-mcp-docs/src/tools/docs-extra.js';
18
18
  import { makeFlyExecutor, wrapServer } from './connector-runtime.js';
19
- import { gogAuth, type GogProps } from './connector-auth.js';
19
+ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth.js';
20
20
 
21
21
  // The Cloudflare remote-connector entrypoint for gogcli-mcp.
22
22
  //
@@ -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.21.1'; // x-release-please-version
41
+ const VERSION = '2.22.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.
@@ -46,7 +46,14 @@ const VERSION = '2.21.1'; // x-release-please-version
46
46
  // `agents` runtime; the node-testable helpers stay in connector-runtime.ts.)
47
47
  function makeAgent(registrars: ToolRegistrar[]): typeof McpAgent {
48
48
  class GogAgent extends McpAgent<unknown, unknown, GogProps> {
49
- server = new McpServer({ name: 'gogcli-mcp', version: VERSION });
49
+ // `instructions` is the connector's only channel to the model that is not a
50
+ // tool description, and it carries the one thing the client UI gets wrong:
51
+ // "connected"/"refreshed" is a statement about the connector key, not about
52
+ // Google. See CONNECTOR_INSTRUCTIONS for why that has to be said out loud.
53
+ server = new McpServer(
54
+ { name: 'gogcli-mcp', version: VERSION },
55
+ { instructions: CONNECTOR_INSTRUCTIONS },
56
+ );
50
57
  async init() {
51
58
  // NO third argument, deliberately: the hosted connector supplies no
52
59
  // per-caller access token, so `gog` runs as the Fly volume's own identity
@@ -56,9 +63,21 @@ function makeAgent(registrars: ToolRegistrar[]): typeof McpAgent {
56
63
  // 401 on this path stops at the `no access token was supplied` guard and
57
64
  // logs `replay.declined`. That record is the expected outcome for a
58
65
  // hosted connector, not a bug; the transport-failure classification and
59
- // the auth log itself do apply here. (docs/DEPLOY-CONNECTOR.md,
60
- // "Reading the auth log", says the same thing for whoever is reading logs
61
- // rather than code.)
66
+ // the auth log itself do apply here.
67
+ //
68
+ // Inert is not the same as unobserved. Because `gog` is spawned fresh per
69
+ // /run and re-reads the keyring each time, a Google 401 here means the
70
+ // STORED credential was refused — which no retry can repair, so no retry
71
+ // is built. Instead that same guard first takes one live reading of the
72
+ // Google layer (`GET /health/google` on the runner) and records it as
73
+ // `refusal.google-ok` / `-unhealthy` / `-unmeasured`. It is throttled,
74
+ // deadline-bounded, cannot throw, and leaves the caller's error
75
+ // byte-identical; its whole job is to answer, in the log, the question
76
+ // that could not be answered after the incident: at the moment Google
77
+ // refused, was the refresh token on the volume alive or dead?
78
+ // (docs/DEPLOY-CONNECTOR.md, "Reading the auth log" and "Why a hosted
79
+ // Google 401 is measured rather than retried", says this for whoever is
80
+ // reading logs rather than code.)
62
81
  const executor = makeFlyExecutor((this.env as { FLY_ENDPOINT: string }).FLY_ENDPOINT, this.props.key);
63
82
  const wrapped = wrapServer(this.server, executor);
64
83
  for (const register of registrars) register(wrapped);
@@ -129,6 +129,20 @@ describe('logAuthTransition', () => {
129
129
  expect(log.emitted.map((e) => e.method)).toEqual(['warn', 'error']);
130
130
  });
131
131
 
132
+ it('separates a MEASURED dead Google layer from one nobody could measure', () => {
133
+ // The connect-time probe has three honest answers, and conflating the last
134
+ // two is exactly the defect it exists to remove: "I asked Google and it said
135
+ // no" is a failure, while "I could not ask" is not evidence of anything.
136
+ const log = captureLog();
137
+ logAuthTransition('connect.google-ok', { endpoint: 'https://runner.example' });
138
+ logAuthTransition('connect.google-unhealthy', { endpoint: 'https://runner.example', reason: 'invalid_grant' });
139
+ logAuthTransition('connect.google-unmeasured', { endpoint: 'https://runner.example', reason: 'HTTP 404' });
140
+
141
+ expect(log.events()).toEqual(['connect.google-ok', 'connect.google-unhealthy', 'connect.google-unmeasured']);
142
+ expect(log.emitted.map((e) => e.method)).toEqual(['warn', 'error', 'warn']);
143
+ expect(log.toStdout).toEqual([]);
144
+ });
145
+
132
146
  it('omits absent context rather than writing nulls', () => {
133
147
  const log = captureLog();
134
148
  logAuthTransition('token.evicted', { credential: 'abc' });
@@ -370,8 +384,16 @@ describe('connector-runtime records the transitions the runner path authors', ()
370
384
  vi.stubGlobal('fetch', vi.fn(async () => gogFailed(stderr)));
371
385
  const log = captureLog();
372
386
  await expect(invoke()).rejects.toThrow();
373
- expect(log.emitted, name).toHaveLength(1);
374
- const [record] = log.records();
387
+ // The 'no token' case — the HOSTED shape — now writes a second record
388
+ // BEFORE its decision: the live reading of the Google layer this branch
389
+ // added, which here reports `refusal.google-unmeasured` because the stub
390
+ // answers /health/google with the same non-2xx it answers /run with. The
391
+ // decision is still the LAST word in every case, which is what this test
392
+ // is about.
393
+ const records = log.records();
394
+ expect(records.length, name).toBe(name === 'no token' ? 2 : 1);
395
+ if (name === 'no token') expect(records[0].event).toBe('refusal.google-unmeasured');
396
+ const record = records[records.length - 1];
375
397
  expect(record.event, name).toBe(name === 'invalid_grant' ? 'grant.dead' : 'replay.declined');
376
398
  expect(record.reason as string, name).toMatch(expected);
377
399
  }