gogcli-mcp 2.8.0 → 2.18.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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +20218 -19700
  4. package/dist/lib.js +15845 -23331
  5. package/manifest.json +33 -1
  6. package/package.json +7 -6
  7. package/server.json +2 -2
  8. package/src/connector-auth.ts +46 -0
  9. package/src/connector-runtime.ts +177 -0
  10. package/src/index.ts +7 -5
  11. package/src/lib.ts +8 -7
  12. package/src/runner.ts +225 -30
  13. package/src/server.ts +21 -25
  14. package/src/tools/api.ts +65 -0
  15. package/src/tools/auth.ts +112 -15
  16. package/src/tools/calendar.ts +24 -9
  17. package/src/tools/docs.ts +28 -3
  18. package/src/tools/drive.ts +124 -1
  19. package/src/tools/gmail.ts +7 -3
  20. package/src/tools/sheets.ts +6 -3
  21. package/src/tools/slides.ts +9 -4
  22. package/src/tools/tasks.ts +3 -1
  23. package/src/tools/utils.ts +163 -27
  24. package/src/worker.ts +99 -0
  25. package/tests/connector-auth.test.ts +28 -0
  26. package/tests/connector-runtime.test.ts +474 -0
  27. package/tests/runner-file-args-failure.test.ts +94 -0
  28. package/tests/runner-file-args.test.ts +232 -0
  29. package/tests/runner.test.ts +221 -13
  30. package/tests/server.test.ts +28 -28
  31. package/tests/tools/api.test.ts +107 -0
  32. package/tests/tools/auth.test.ts +187 -31
  33. package/tests/tools/calendar.test.ts +115 -52
  34. package/tests/tools/classroom.test.ts +77 -77
  35. package/tests/tools/contacts.test.ts +24 -24
  36. package/tests/tools/docs.test.ts +84 -46
  37. package/tests/tools/drive.test.ts +226 -55
  38. package/tests/tools/gmail.test.ts +61 -28
  39. package/tests/tools/sheets.test.ts +81 -70
  40. package/tests/tools/slides.test.ts +56 -36
  41. package/tests/tools/tasks.test.ts +33 -33
  42. package/tests/tools/utils.test.ts +116 -2
  43. package/tests/worker.test.ts +142 -0
  44. package/tsconfig.json +4 -1
  45. package/vitest.config.ts +33 -2
  46. package/tests/helpers/test-harness.ts +0 -27
@@ -1,8 +1,47 @@
1
1
  import { z } from 'zod';
2
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
+ import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
3
5
  import { run } from '../runner.js';
6
+ import type { GogArg } from '../runner.js';
4
7
 
5
- export type ToolResult = { content: [{ type: 'text'; text: string }] };
8
+ // Byte size at or below which a payload stays on the plain inline flag.
9
+ //
10
+ // Two reasons not to route everything through a file. First, gog's semantics
11
+ // differ between the two forms: reading a body from a file strips ALL trailing
12
+ // newlines (measured on gog 0.34.1 — a 5-byte payload padded to 6/7/8 bytes all
13
+ // came back as 5; tracked upstream at openclaw/gogcli#936), so a body ending in
14
+ // "\n" cannot round-trip byte-for-byte through the file path. Second, the file
15
+ // path costs a temp dir, a write, and a delete per call.
16
+ //
17
+ // The value matches the per-arg byte limit the Fly runner enforced BEFORE large
18
+ // payloads could leave argv (the old MAX_ARG_LEN, 4096). That is deliberate:
19
+ // every body that used to round-trip inline byte-for-byte still does, so this
20
+ // change adds no trailing-newline regression for any body that already worked —
21
+ // only bodies that previously exceeded the cap and hard-failed ("each arg must
22
+ // be at most 4096 chars") now take the file path and its newline trim. The
23
+ // runner's plain-arg cap is now 64 KiB, so a 4096-byte inline value is nowhere
24
+ // near being rejected.
25
+ export const PAYLOAD_INLINE_MAX = 4096;
26
+
27
+ // The ONE place the inline-vs-file decision is made. Every tool that has a
28
+ // gog `--x` / `--x-file` flag pair routes its value through here so the
29
+ // threshold cannot drift between tools.
30
+ //
31
+ // Measures BYTES, not characters: the Fly runner's cap and the Linux kernel's
32
+ // MAX_ARG_STRLEN are both byte-based, so a multibyte-heavy body (CJK, emoji)
33
+ // would slip past a `.length` check at up to 4x its real argv cost.
34
+ export function payloadArg(
35
+ inlineFlag: string,
36
+ fileFlag: string,
37
+ value: string,
38
+ ext?: string,
39
+ ): GogArg {
40
+ if (Buffer.byteLength(value, 'utf8') <= PAYLOAD_INLINE_MAX) {
41
+ return `--${inlineFlag}=${value}`;
42
+ }
43
+ return { kind: 'file', flag: fileFlag, contents: value, ext };
44
+ }
6
45
 
7
46
  export const accountParam = z.string().optional().describe(
8
47
  'Google account email to use, e.g. you@gmail.com — must be the full address, not a bare username. ' +
@@ -88,16 +127,21 @@ export function registerRunTool(
88
127
  });
89
128
  }
90
129
 
91
- export function toText(output: string): ToolResult {
92
- return { content: [{ type: 'text' as const, text: output }] };
93
- }
94
-
95
- export function toError(err: unknown): ToolResult {
96
- return toText(err instanceof Error ? `Error: ${err.message}` : String(err));
130
+ // The fleet-standard error text for a thrown value ("Error: <message>").
131
+ // Pair with mcp-utils errorResult (which redacts secrets and sets
132
+ // `isError: true`) when the text is the whole tool result.
133
+ export function errorText(err: unknown): string {
134
+ return err instanceof Error ? `Error: ${err.message}` : String(err);
97
135
  }
98
136
 
99
137
  const AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
100
138
 
139
+ // A DEAD refresh token — the whole account is signed out, not just a stale
140
+ // access token that would refresh silently. This is the recurring account-wide
141
+ // failure, so it gets its own plain-English cause + durable fix, distinct from
142
+ // the generic 401/unauthorized case.
143
+ const INVALID_GRANT_PATTERN = /invalid_grant|token has been expired or revoked/i;
144
+
101
145
  const TRANSIENT_ERROR_PATTERN =
102
146
  /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
103
147
 
@@ -110,6 +154,20 @@ const AUTH_HINT =
110
154
  '\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. ' +
111
155
  'Ask the user if they would like to re-authenticate.';
112
156
 
157
+ // invalid_grant means the stored REFRESH token was rejected, so re-auth is
158
+ // mandatory (no silent recovery) and it will recur unless the root cause is
159
+ // fixed. Name the most common cause (the 7-day limit Google puts on OAuth apps
160
+ // still in "Testing"), offer both re-auth paths (browser + remote/headless),
161
+ // and point at the durable fix. Keeps the literal `gog_auth_add` token so the
162
+ // generic auth-recovery guidance still applies.
163
+ const INVALID_GRANT_HINT =
164
+ '\n\nThe stored refresh token was rejected (invalid_grant): it has expired or been revoked, so the ' +
165
+ 'whole account is signed out and re-authorization is required. The most common cause is the 7-day ' +
166
+ 'refresh-token limit Google applies to OAuth apps whose consent screen is still in "Testing" mode. ' +
167
+ 'Re-authorize with gog_auth_add (opens a browser) or gog_auth_add_url + gog_auth_add_complete (remote/headless). ' +
168
+ 'To stop this recurring, publish the OAuth consent screen to "In production" in the Google Cloud project ' +
169
+ 'that owns the OAuth client. Ask the user if they would like to re-authenticate.';
170
+
113
171
  const TRANSIENT_HINT =
114
172
  '\n\nThis error is often transient. Retry the same call before trying a different approach ' +
115
173
  '(do not fall back to smaller writes or row-by-row operations).';
@@ -139,39 +197,117 @@ export function formatAccountList(raw: string): string {
139
197
  return raw.trim();
140
198
  }
141
199
 
142
- // Turn a thrown error into a diagnosed ToolResult: the error text, an
143
- // actionable hint when the failure class is recognised (auth / transient /
144
- // off-grid write), and the list of configured accounts. Callers that need to
145
- // surface a failure without going through runOrDiagnose (e.g. a pre-write
146
- // verification read that must abort) can reuse this so the error keeps the
147
- // same diagnostic quality as everywhere else.
148
- export async function diagnose(err: unknown): Promise<ToolResult> {
149
- const errText = toError(err).content[0].text;
200
+ // Turn a thrown error into a diagnosed error result (`isError: true`): the
201
+ // error text, an actionable hint when the failure class is recognised (auth /
202
+ // transient / off-grid write), and the list of configured accounts. Callers
203
+ // that need to surface a failure without going through runOrDiagnose (e.g. a
204
+ // pre-write verification read that must abort) can reuse this so the error
205
+ // keeps the same diagnostic quality as everywhere else.
206
+ export async function diagnose(err: unknown): Promise<CallToolResult> {
207
+ const errText = errorText(err);
208
+ const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
150
209
  const isAuthError = AUTH_ERROR_PATTERN.test(errText);
151
210
  const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
152
211
  const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
153
- const hint = isAuthError
154
- ? AUTH_HINT
155
- : isTransientError
156
- ? TRANSIENT_HINT
157
- : isGridLimitError
158
- ? GRID_LIMIT_HINT
159
- : '';
212
+ const hint = isInvalidGrant
213
+ ? INVALID_GRANT_HINT
214
+ : isAuthError
215
+ ? AUTH_HINT
216
+ : isTransientError
217
+ ? TRANSIENT_HINT
218
+ : isGridLimitError
219
+ ? GRID_LIMIT_HINT
220
+ : '';
160
221
  try {
161
222
  const accounts = formatAccountList(await run(['auth', 'list']));
162
- return toText(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
223
+ return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
163
224
  } catch {
164
- return toText(`${errText}${hint}`);
225
+ return errorResult(`${errText}${hint}`);
165
226
  }
166
227
  }
167
228
 
168
229
  export async function runOrDiagnose(
169
- args: string[],
230
+ args: GogArg[],
170
231
  options: { account?: string },
171
- ): Promise<ToolResult> {
232
+ ): Promise<CallToolResult> {
172
233
  try {
173
- return toText(await run(args, options));
234
+ return rawTextResult(await run(args, options));
174
235
  } catch (err) {
175
236
  return diagnose(err);
176
237
  }
177
238
  }
239
+
240
+ // Google puts a 7-day cap on refresh tokens issued by OAuth apps whose consent
241
+ // screen is still in "Testing" — the recurring account-wide sign-out this tool
242
+ // exists to warn about. Once the token is this old and still valid, expiry is
243
+ // imminent, so surface it before it becomes a hard failure mid-task.
244
+ const REFRESH_TOKEN_TESTING_TTL_DAYS = 7;
245
+ const HEALTH_WARN_AGE_DAYS = 6;
246
+ const MS_PER_DAY = 86_400_000;
247
+
248
+ interface AuthHealthAccount {
249
+ email?: string;
250
+ created_at?: string;
251
+ valid?: boolean;
252
+ error?: string;
253
+ }
254
+
255
+ // Days between an ISO timestamp and `now`, or null if unparseable/absent.
256
+ function ageInDays(createdAt: string | undefined, now: number): number | null {
257
+ if (!createdAt) return null;
258
+ const t = Date.parse(createdAt);
259
+ if (Number.isNaN(t)) return null;
260
+ return (now - t) / MS_PER_DAY;
261
+ }
262
+
263
+ function formatOneAccountHealth(a: AuthHealthAccount, now: number): string {
264
+ const email = a.email ?? '(unknown account)';
265
+ const age = ageInDays(a.created_at, now);
266
+ const ageStr = age === null ? '' : ` Authorized ${age.toFixed(1)} day(s) ago.`;
267
+
268
+ if (a.valid === false) {
269
+ // Map the dead-refresh-token error to a plain cause; fall back to the raw
270
+ // (already token-redacted by run()) error for anything else.
271
+ const cause = INVALID_GRANT_PATTERN.test(a.error ?? '')
272
+ ? 'refresh token expired or revoked — commonly the 7-day limit on OAuth consent screens still in "Testing" mode'
273
+ : (a.error?.trim() || 'unknown error');
274
+ return `✗ ${email}: NEEDS RE-AUTH — ${cause}.${ageStr} ` +
275
+ 'Re-authorize with gog_auth_add (browser) or gog_auth_add_url + gog_auth_add_complete (remote/headless).';
276
+ }
277
+
278
+ if (a.valid === true) {
279
+ let line = `✓ ${email}: token valid.${ageStr}`;
280
+ if (age !== null && age >= HEALTH_WARN_AGE_DAYS) {
281
+ const est = new Date(Date.parse(a.created_at as string) + REFRESH_TOKEN_TESTING_TTL_DAYS * MS_PER_DAY)
282
+ .toISOString()
283
+ .slice(0, 10);
284
+ line += ` ⚠ Approaching the 7-day refresh-token limit for OAuth apps in "Testing" mode ` +
285
+ `(if that applies, expect expiry around ${est}). Re-authorize soon, or publish the OAuth ` +
286
+ 'consent screen to "In production" to stop the weekly expiry.';
287
+ }
288
+ return line;
289
+ }
290
+
291
+ // valid absent — the caller ran without --check (or gog omitted the field).
292
+ return `? ${email}: token validity unknown.${ageStr} Run this check again to probe it live.`;
293
+ }
294
+
295
+ // Turn `gog auth list --check --json` into a per-account health summary:
296
+ // validity, a mapped cause for dead tokens, token age, and a pre-expiry warning
297
+ // near the 7-day testing-mode cliff. Only email/created_at/valid/error are read
298
+ // — scopes/subject never appear in the summary. Falls back to trimmed raw text
299
+ // when the output isn't the expected JSON shape.
300
+ export function formatAuthHealth(raw: string, now: number): string {
301
+ let accounts: AuthHealthAccount[];
302
+ try {
303
+ const parsed = JSON.parse(raw) as { accounts?: unknown };
304
+ if (!Array.isArray(parsed?.accounts)) return raw.trim();
305
+ accounts = parsed.accounts as AuthHealthAccount[];
306
+ } catch {
307
+ return raw.trim();
308
+ }
309
+ if (accounts.length === 0) {
310
+ return 'No Google accounts are configured. Use gog_auth_add to authorize one.';
311
+ }
312
+ return accounts.map((a) => formatOneAccountHealth(a, now)).join('\n\n');
313
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { McpAgent } from 'agents/mcp';
3
+ import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
4
+ import { handleAuthorize } from '@chrischall/mcp-connector';
5
+ import type { ToolRegistrar } from '@chrischall/mcp-utils';
6
+ import {
7
+ BASE_TOOL_REGISTRARS,
8
+ authToolsFor,
9
+ registerSheetsTools,
10
+ registerGmailTools,
11
+ registerDriveTools,
12
+ registerDocsTools,
13
+ } from './lib.js';
14
+ import { registerExtraSheetsTools } from '../../gogcli-mcp-sheets/src/tools/sheets-extra.js';
15
+ import { registerExtraGmailTools } from '../../gogcli-mcp-gmail/src/tools/gmail-extra.js';
16
+ import { registerExtraDriveTools } from '../../gogcli-mcp-drive/src/tools/drive-extra.js';
17
+ import { registerExtraDocsTools } from '../../gogcli-mcp-docs/src/tools/docs-extra.js';
18
+ import { makeFlyExecutor, wrapServer } from './connector-runtime.js';
19
+ import { gogAuth, type GogProps } from './connector-auth.js';
20
+
21
+ // The Cloudflare remote-connector entrypoint for gogcli-mcp.
22
+ //
23
+ // It reuses the EXISTING transport-neutral tool registrars UNCHANGED and executes
24
+ // every assembled `gog` arg-array by forwarding it to a Fly.io backend (a Worker
25
+ // cannot spawn processes). The bridge is the `runExecutor` AsyncLocalStorage seam
26
+ // in `runner.ts`: `wrapServer` scopes each tool handler in `runExecutor.run(...)`
27
+ // so the handler's `run()` forwards to the per-session Fly executor.
28
+ //
29
+ // One Worker serves several MCP endpoints under one OAuth login, each a distinct
30
+ // tool set backed by its own Durable Object:
31
+ // /mcp all-services base (BASE_TOOL_REGISTRARS)
32
+ // /mcp/sheets auth + Sheets base + Sheets extras
33
+ // /mcp/gmail auth + Gmail base + Gmail extras
34
+ // /mcp/drive auth + Drive base + Drive extras
35
+ // /mcp/docs auth + Docs base + Docs extras
36
+ // Each per-service path exposes the SAME tool set as that sub-package's stdio
37
+ // server, so the ~50-70 extras per service are reachable without swamping one
38
+ // connector with all ~360 tools at once. Add whichever paths you want as separate
39
+ // connectors in claude.ai (each authorizes with the same connector key).
40
+
41
+ const VERSION = '2.18.0'; // x-release-please-version
42
+
43
+ // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
+ // each handler wrapped in the ALS scope carrying the per-session Fly executor.
45
+ // (Kept in worker.ts, not connector-runtime.ts, because it imports the Worker-only
46
+ // `agents` runtime; the node-testable helpers stay in connector-runtime.ts.)
47
+ function makeAgent(registrars: ToolRegistrar[]): typeof McpAgent {
48
+ class GogAgent extends McpAgent<unknown, unknown, GogProps> {
49
+ server = new McpServer({ name: 'gogcli-mcp', version: VERSION });
50
+ async init() {
51
+ const executor = makeFlyExecutor((this.env as { FLY_ENDPOINT: string }).FLY_ENDPOINT, this.props.key);
52
+ const wrapped = wrapServer(this.server, executor);
53
+ for (const register of registrars) register(wrapped);
54
+ }
55
+ }
56
+ return GogAgent as unknown as typeof McpAgent;
57
+ }
58
+
59
+ // auth + <service> base + <service> extras — the exact set each sub-package's
60
+ // stdio server exposes. The auth registrar is bound to THIS service's default
61
+ // `services` (least-privilege): a per-service connector re-auths requesting only
62
+ // its own scopes, so one unregistered scope for a service it doesn't wrap can't
63
+ // poison its re-auth with invalid_scope. The base /mcp agent keeps 'all'.
64
+ const svc = (service: string, base: ToolRegistrar, extra: ToolRegistrar): ToolRegistrar[] =>
65
+ [authToolsFor(service), base, extra];
66
+
67
+ export class GogcliMcpAgent extends makeAgent(BASE_TOOL_REGISTRARS) {}
68
+ export class GogcliSheetsAgent extends makeAgent(svc('sheets', registerSheetsTools, registerExtraSheetsTools)) {}
69
+ export class GogcliGmailAgent extends makeAgent(svc('gmail', registerGmailTools, registerExtraGmailTools)) {}
70
+ export class GogcliDriveAgent extends makeAgent(svc('drive,driveactivity,drivelabels', registerDriveTools, registerExtraDriveTools)) {}
71
+ export class GogcliDocsAgent extends makeAgent(svc('docs', registerDocsTools, registerExtraDocsTools)) {}
72
+
73
+ const defaultHandler = {
74
+ fetch(request: Request, env: unknown): Response | Promise<Response> {
75
+ const url = new URL(request.url);
76
+ if (url.pathname === '/authorize') return handleAuthorize(request, env, gogAuth);
77
+ return new Response('Not found', { status: 404 });
78
+ },
79
+ };
80
+
81
+ // NOTE: OAuthProvider matches apiHandlers by PREFIX and returns the FIRST match,
82
+ // so the specific per-service paths MUST be listed before the base `/mcp`
83
+ // (otherwise `/mcp` greedily swallows `/mcp/sheets`).
84
+ const handler = new OAuthProvider({
85
+ apiHandlers: {
86
+ '/mcp/sheets': GogcliSheetsAgent.serve('/mcp/sheets', { binding: 'SHEETS_MCP' }) as never,
87
+ '/mcp/gmail': GogcliGmailAgent.serve('/mcp/gmail', { binding: 'GMAIL_MCP' }) as never,
88
+ '/mcp/drive': GogcliDriveAgent.serve('/mcp/drive', { binding: 'DRIVE_MCP' }) as never,
89
+ '/mcp/docs': GogcliDocsAgent.serve('/mcp/docs', { binding: 'DOCS_MCP' }) as never,
90
+ '/mcp': GogcliMcpAgent.serve('/mcp') as never,
91
+ '/sse': GogcliMcpAgent.serveSSE('/sse') as never,
92
+ },
93
+ defaultHandler: defaultHandler as never,
94
+ authorizeEndpoint: '/authorize',
95
+ tokenEndpoint: '/token',
96
+ clientRegistrationEndpoint: '/register',
97
+ });
98
+
99
+ export default handler;
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { gogAuth } from '../src/connector-auth.js';
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ });
7
+
8
+ describe('gogAuth.login', () => {
9
+ const env = { FLY_ENDPOINT: 'https://runner.example' };
10
+
11
+ it('verifies the key against the backend /health and returns the props', async () => {
12
+ const fetchMock = vi.fn(async () => ({ ok: true }));
13
+ vi.stubGlobal('fetch', fetchMock);
14
+
15
+ const props = await gogAuth.login({ key: 'my-key' }, env);
16
+ expect(props).toEqual({ key: 'my-key' });
17
+ expect(fetchMock).toHaveBeenCalledWith('https://runner.example/health', {
18
+ headers: { Authorization: 'Bearer my-key' },
19
+ });
20
+ });
21
+
22
+ it('throws when the backend rejects the key', async () => {
23
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false })));
24
+ await expect(gogAuth.login({ key: 'bad' }, env)).rejects.toThrow(
25
+ 'Invalid connector key',
26
+ );
27
+ });
28
+ });