gogcli-mcp 2.8.0 → 2.18.1

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
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.8.0",
6
+ "version": "2.18.1",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -56,10 +56,34 @@
56
56
  }
57
57
  },
58
58
  "tools": [
59
+ {
60
+ "name": "gog_api_list",
61
+ "description": "List the Google Discovery APIs available for generic api calls."
62
+ },
63
+ {
64
+ "name": "gog_api_describe",
65
+ "description": "Describe a Google Discovery API or one of its methods (params, schema, scopes)."
66
+ },
67
+ {
68
+ "name": "gog_api_call",
69
+ "description": "Call any Discovery-described Google API method (escape hatch; write opt-in + dry-run)."
70
+ },
59
71
  {
60
72
  "name": "gog_auth_add",
61
73
  "description": "Authorize a Google account via browser-based OAuth"
62
74
  },
75
+ {
76
+ "name": "gog_auth_health",
77
+ "description": "Live per-account token health: validity, age, and pre-expiry warnings (detects invalid_grant)"
78
+ },
79
+ {
80
+ "name": "gog_auth_add_url",
81
+ "description": "Begin remote/headless OAuth (step 1): return a sign-in URL to open in any browser"
82
+ },
83
+ {
84
+ "name": "gog_auth_add_complete",
85
+ "description": "Complete remote/headless OAuth (step 2): exchange the pasted redirect URL and store the token"
86
+ },
63
87
  {
64
88
  "name": "gog_auth_list",
65
89
  "description": "List all Google accounts stored in gogcli"
@@ -228,6 +252,14 @@
228
252
  "name": "gog_drive_search",
229
253
  "description": "Search Google Drive files"
230
254
  },
255
+ {
256
+ "name": "gog_drive_extract_text",
257
+ "description": "Extract readable text from a Drive PDF/image/doc (OCR-converts, returns text; no host FS)"
258
+ },
259
+ {
260
+ "name": "gog_drive_read_bytes",
261
+ "description": "Fetch a Drive file's raw bytes as a base64 resource (local stdio only; connector returns text-only notice)"
262
+ },
231
263
  {
232
264
  "name": "gog_drive_get",
233
265
  "description": "Get Google Drive file metadata"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.8.0",
3
+ "version": "2.18.1",
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>",
@@ -36,19 +36,20 @@
36
36
  "build": "tsc --noEmit && npm run bundle",
37
37
  "bundle": "node ../../scripts/bundle.js src/index.ts dist/index.js && node ../../scripts/bundle.js src/lib.ts dist/lib.js",
38
38
  "typecheck": "tsc --noEmit",
39
- "test": "vitest run",
39
+ "test": "vitest run --coverage",
40
40
  "test:watch": "vitest",
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
+ "@chrischall/mcp-utils": "^0.13.3",
44
45
  "@modelcontextprotocol/sdk": "^1.29.0",
45
46
  "zod": "^4.4.3"
46
47
  },
47
48
  "devDependencies": {
48
- "@types/node": "^25.9.2",
49
+ "@types/node": "^26.1.1",
49
50
  "@vitest/coverage-v8": "^4.1.8",
50
- "esbuild": "^0.28.0",
51
- "typescript": "^6.0.2",
52
- "vitest": "^4.1.8"
51
+ "esbuild": "^0.28.1",
52
+ "typescript": "^7.0.2",
53
+ "vitest": "^4.1.10"
53
54
  }
54
55
  }
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.8.0",
10
+ "version": "2.18.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.8.0",
15
+ "version": "2.18.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -0,0 +1,46 @@
1
+ import type { ConnectorAuth } from '@chrischall/mcp-connector';
2
+
3
+ /**
4
+ * OAuth props stored per user by the Cloudflare connector's OAuth provider.
5
+ *
6
+ * The gogcli remote connector authenticates each user with a single long-lived
7
+ * personal "connector key" — a shared secret (the Fly backend's `RUNNER_KEY`)
8
+ * that authorizes calls to that user's own `gog` backend on Fly.io. There is no
9
+ * refresh cycle: `worker.ts`'s `buildClient` turns this key straight into a
10
+ * per-session Fly executor. These props are encrypted at rest in `OAUTH_KV` by
11
+ * the OAuth provider.
12
+ *
13
+ * NOTE: this is a FIELD LOGIN (a personal key), NOT Google OAuth. The Google
14
+ * OAuth handshake lives entirely inside the Fly backend's `gog` install; the
15
+ * connector never sees a Google token.
16
+ *
17
+ * The index signature satisfies `createConnector`'s
18
+ * `Props extends Record<string, unknown>` constraint.
19
+ */
20
+ export interface GogProps {
21
+ key: string;
22
+ [k: string]: unknown;
23
+ }
24
+
25
+ /**
26
+ * `ConnectorAuth` for the gogcli remote connector: the login page collects the
27
+ * user's connector key, verifies it by hitting the Fly backend's `/health`
28
+ * endpoint with the key as a bearer token (a bad key makes the backend answer
29
+ * non-2xx, which surfaces back on the login page), and stores `{ key }` as the
30
+ * OAuth props that `worker.ts`'s `buildClient` turns into a per-session Fly
31
+ * executor.
32
+ */
33
+ export const gogAuth: ConnectorAuth<GogProps> = {
34
+ service: 'gogcli (Google Workspace)',
35
+ accent: '#4285F4',
36
+ privacyNote:
37
+ 'Your connector key is stored encrypted and used only to reach your own gog backend.',
38
+ fields: [{ name: 'key', label: 'gogcli connector key', type: 'password' }],
39
+ async login(fields, env) {
40
+ const res = await fetch(`${(env as any).FLY_ENDPOINT}/health`, {
41
+ headers: { Authorization: `Bearer ${fields.key}` },
42
+ });
43
+ if (!res.ok) throw new Error('Invalid connector key (backend rejected it)');
44
+ return { key: fields.key };
45
+ },
46
+ };
@@ -0,0 +1,177 @@
1
+ import { runExecutor } from './runner.js';
2
+ import type { GogArg, GogExecutor } from './runner.js';
3
+
4
+ // Runtime helpers for the Cloudflare connector (worker.ts), split out here so
5
+ // they can be unit-tested under the node pool — worker.ts itself imports the
6
+ // Worker-only `@chrischall/mcp-connector`/`agents` runtime and cannot load in
7
+ // node. These helpers touch only the `runExecutor` seam and global `fetch`.
8
+
9
+ // Mirrors runner.ts's TIMEOUT_MS: the budget the stdio path gives a `gog` call
10
+ // before it kills the child. Duplicated rather than imported to keep this module
11
+ // free of the spawn-side surface.
12
+ const DEFAULT_TIMEOUT_MS = 30_000;
13
+
14
+ // Headroom on top of the backend's own budget. The Fly runner kills `gog` and
15
+ // returns a real error; we only want the client deadline to fire when the
16
+ // backend cannot answer at all (scale-to-zero cold start that never wakes, or a
17
+ // wedged machine). Firing first would turn a useful "gog exited 1" into an
18
+ // opaque timeout.
19
+ //
20
+ // This grace is deliberately NOT widened for GogFileArg payloads. The deadline
21
+ // now covers a request body that carries the payload inline, so upload happens
22
+ // before `gog` starts — but the payloads that take this path are text (a mail
23
+ // body, slide notes), bounded by the MCP request that carried them, and the hop
24
+ // is Cloudflare edge → Fly datacenter, so upload is milliseconds, not seconds.
25
+ // Widening the grace would weaken the property this constant exists to protect:
26
+ // the backend must lose the race only when it genuinely cannot answer, and every
27
+ // extra second is a second of an opaque client timeout replacing a real error.
28
+ // If a payload class ever appears that does NOT upload in well under 5s, the fix
29
+ // is to raise the caller's `opts.timeout` (which this deadline tracks) rather
30
+ // than to inflate the grace, so the runner still gets to answer first.
31
+ const DEADLINE_GRACE_MS = 5_000;
32
+
33
+ // Status codes the Fly runner uses to classify its OWN failures. These must stay
34
+ // in sync with fly-gog-runner/server.mjs — they are the contract that lets this
35
+ // side tell "gog ran and failed" apart from "the request never arrived", without
36
+ // having to guess from the response body.
37
+ //
38
+ // 422: `gog` executed and exited non-zero. Deterministic — never retry.
39
+ // 503: the runner is draining (SIGINT from Fly's autostop). Transient — retry.
40
+ // Any other non-2xx: infrastructure, i.e. Fly's edge, not us.
41
+ const RUNNER_GOG_FAILED = 422;
42
+ const RUNNER_DRAINING = 503;
43
+
44
+ // Build a GogExecutor that forwards a fully-assembled `gog` arg-array to the Fly
45
+ // backend's `/run` endpoint.
46
+ //
47
+ // The backend (and the `gog` process it spawns) own timeout/interactive
48
+ // behaviour, so `opts` does not change what the backend does — but `timeout`
49
+ // still has to be honoured HERE as a client-side deadline. Workers' `fetch` has
50
+ // no default timeout, and the stdio path's kill lives in a child process this
51
+ // path never spawns, so without an explicit signal a cold or wedged backend
52
+ // hangs the MCP request indefinitely with nothing to interrupt it.
53
+ //
54
+ // GogFileArg elements pass through STRUCTURED, as objects inside the JSON body.
55
+ // Every other executor materializes them to a temp file itself; this one cannot
56
+ // and must not. A Worker has no filesystem and does not host the `gog` binary,
57
+ // so there is nowhere to put a file that the process which needs it could read.
58
+ // The Fly runner is the only box with both, so it owns materialization — this
59
+ // layer's whole job is to hand the elements over unaltered, in order. Anything
60
+ // clever here (flattening to `--flag=<inline>`, truncating, re-encoding) would
61
+ // put the payload straight back into argv and re-create the size cap this whole
62
+ // change exists to escape.
63
+ export function makeFlyExecutor(endpoint: string, key: string): GogExecutor {
64
+ return async (args: GogArg[], opts) => {
65
+ const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
66
+ let res: Response;
67
+ try {
68
+ res = await fetch(endpoint + '/run', {
69
+ method: 'POST',
70
+ headers: {
71
+ Authorization: 'Bearer ' + key,
72
+ 'Content-Type': 'application/json',
73
+ },
74
+ body: JSON.stringify({ args }),
75
+ signal: AbortSignal.timeout(deadlineMs),
76
+ });
77
+ } catch (err) {
78
+ // AbortSignal.timeout rejects with a TimeoutError; a caller-supplied abort
79
+ // surfaces as AbortError. Either way the bare message ("The operation was
80
+ // aborted") says nothing about which backend failed to answer.
81
+ const name = err instanceof Error ? err.name : '';
82
+ if (name === 'TimeoutError' || name === 'AbortError') {
83
+ throw new Error(
84
+ `gog-runner did not respond within ${deadlineMs}ms (${endpoint}) — the Fly backend may be cold or wedged`,
85
+ );
86
+ }
87
+ throw err;
88
+ }
89
+ if (!res.ok) {
90
+ // Two very different failures arrive as non-2xx, and collapsing them (as
91
+ // this used to) is what made a real bug look like random flakiness:
92
+ //
93
+ // a) The runner answered with its own JSON — `gog` actually ran on the
94
+ // box and failed. Deterministic: the same call will fail the same way.
95
+ // b) The body is NOT the runner's JSON (Fly's HTML error page, or empty).
96
+ // Then the request never reached `gog` at all; Fly's edge proxy is
97
+ // reporting that it could not reach the Machine — typically because
98
+ // the Machine was starting from scale-to-zero, or was mid-shutdown.
99
+ // Genuinely transient, and the only case worth retrying.
100
+ const body = (await res.json().catch(() => null)) as
101
+ | { error?: string; stderr?: string; retryable?: boolean }
102
+ | null;
103
+ const detail =
104
+ body && typeof body.error === 'string'
105
+ ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim()
106
+ ? `${body.error}\n${body.stderr}`
107
+ : body.error
108
+ : '';
109
+
110
+ // 422 is the runner's "gog ran and exited non-zero" status. It is only
111
+ // ever produced by our own handler, so reaching here proves the request
112
+ // was delivered and executed. Deterministic — say so, and say nothing
113
+ // that invites a retry.
114
+ if (res.status === RUNNER_GOG_FAILED) {
115
+ throw new Error(detail || 'gog failed on the runner (no detail supplied)');
116
+ }
117
+
118
+ // The runner's drain response: it is up, but deliberately refusing new
119
+ // work while it shuts down. The one runner-authored failure worth retrying.
120
+ if (res.status === RUNNER_DRAINING || body?.retryable === true) {
121
+ throw new Error(
122
+ `gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ''}`,
123
+ );
124
+ }
125
+
126
+ // Anything else non-2xx is infrastructure: Fly's edge could not reach the
127
+ // Machine, or the Machine answered with something that is not ours. Only
128
+ // claim the request never arrived when there is genuinely no runner body
129
+ // — a runner that did answer deserves to have its own words repeated.
130
+ //
131
+ // The status is deliberately NOT interpolated here. A runner body proves
132
+ // gog ran, so this is a deterministic failure; embedding the literal
133
+ // status would put "502" into the message, which matches
134
+ // TRANSIENT_ERROR_PATTERN (/\b5\d\d\b/) in tools/utils.ts and re-attaches
135
+ // the very "this is transient, retry the same call" hint this change
136
+ // exists to remove — reintroducing the bug during the rollout window this
137
+ // branch exists to cover. Anything genuinely transient in gog's own text
138
+ // (a Google 5xx, say) still matches on its own merits, which is correct.
139
+ if (detail) {
140
+ throw new Error(detail);
141
+ }
142
+ throw new Error(
143
+ `gog-runner HTTP ${res.status}: the response did not come from the runner, ` +
144
+ 'so the request never reached gog. The backend Machine was most likely starting ' +
145
+ 'or shutting down — this is transient, retry the same call.',
146
+ );
147
+ }
148
+ const { stdout } = (await res.json()) as { stdout: string };
149
+ return stdout;
150
+ };
151
+ }
152
+
153
+ // Wrap an McpServer in a Proxy whose `registerTool` (and `tool`, if any
154
+ // registrar uses it) intercepts the tool handler so it runs inside the
155
+ // `runExecutor` ALS scope. This is the crux of the connector: it lets the
156
+ // UNCHANGED base registrars forward every `gog` call to the per-session Fly
157
+ // executor without any change to the registrars or `runner.ts` — when a
158
+ // handler's `run()` looks up `runExecutor.getStore()` it finds `executor` and
159
+ // forwards instead of spawning. Everything else proxies through via Reflect.
160
+ export function wrapServer<T extends object>(server: T, executor: GogExecutor): T {
161
+ return new Proxy(server, {
162
+ get(target, prop, receiver) {
163
+ if (prop === 'registerTool' || prop === 'tool') {
164
+ const orig = (target as Record<string | symbol, (...a: unknown[]) => unknown>)[prop].bind(target);
165
+ return (...args: unknown[]) => {
166
+ const handler = args[args.length - 1];
167
+ if (typeof handler === 'function') {
168
+ args[args.length - 1] = (...h: unknown[]) =>
169
+ runExecutor.run({ executor }, () => (handler as (...a: unknown[]) => unknown)(...h));
170
+ }
171
+ return orig(...args);
172
+ };
173
+ }
174
+ return Reflect.get(target, prop, receiver);
175
+ },
176
+ });
177
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { createBaseServer } from './server.js';
2
+ import { runMcp } from '@chrischall/mcp-utils';
3
+ import { BASE_TOOL_REGISTRARS, VERSION } from './server.js';
4
4
 
5
- const server = createBaseServer();
6
- const transport = new StdioServerTransport();
7
- await server.connect(transport);
5
+ await runMcp({
6
+ name: 'gogcli',
7
+ version: VERSION,
8
+ tools: BASE_TOOL_REGISTRARS,
9
+ });
package/src/lib.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export {
2
- createServer,
3
- createBaseServer,
2
+ BASE_TOOL_REGISTRARS,
4
3
  VERSION,
4
+ registerApiTools,
5
5
  registerAuthTools,
6
+ authToolsFor,
6
7
  registerCalendarTools,
7
8
  registerClassroomTools,
8
9
  registerContactsTools,
@@ -13,17 +14,17 @@ export {
13
14
  registerSlidesTools,
14
15
  registerTasksTools,
15
16
  } from './server.js';
16
- export { run, MIN_GOG_VERSION } from './runner.js';
17
- export type { RunOptions, Spawner } from './runner.js';
17
+ export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
18
+ export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
18
19
  export {
20
+ PAYLOAD_INLINE_MAX,
21
+ payloadArg,
19
22
  accountParam,
20
23
  runOrDiagnose,
21
24
  diagnose,
22
- toText,
23
- toError,
25
+ errorText,
24
26
  ids,
25
27
  paginationParams,
26
28
  pushPaginationFlags,
27
29
  registerRunTool,
28
30
  } from './tools/utils.js';
29
- export type { ToolResult } from './tools/utils.js';