gogcli-mcp 3.0.0 → 4.0.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 (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +474 -947
  4. package/dist/lib.js +575 -983
  5. package/manifest.json +2 -2
  6. package/mint.yaml +41 -37
  7. package/package.json +3 -3
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/index.ts +3 -4
  14. package/src/lib.ts +14 -9
  15. package/src/runner.ts +27 -176
  16. package/src/tools/appscript.ts +1 -1
  17. package/src/tools/auth.ts +5 -5
  18. package/src/tools/drive.ts +1 -3
  19. package/src/tools/gmail.ts +9 -9
  20. package/src/tools/utils.ts +12 -58
  21. package/tests/attachments.test.ts +11 -14
  22. package/tests/blob-upload.test.ts +235 -160
  23. package/tests/bootstrap-auth.test.ts +245 -0
  24. package/tests/runner-file-args.test.ts +1 -13
  25. package/tests/runner.test.ts +53 -96
  26. package/tests/sdk-single-copy.test.ts +4 -12
  27. package/tests/tools/auth-401-shapes.test.ts +2 -3
  28. package/tests/tools/auth.test.ts +5 -4
  29. package/tests/tools/drive.test.ts +11 -3
  30. package/tests/tools/gmail.test.ts +2 -2
  31. package/tests/tools/utils.test.ts +1 -50
  32. package/tests/zod-single-copy.test.ts +6 -14
  33. package/tsconfig.json +1 -2
  34. package/vitest.config.ts +2 -12
  35. package/src/auth-log.ts +0 -205
  36. package/src/connector-auth.ts +0 -319
  37. package/src/connector-login.ts +0 -87
  38. package/src/connector-runtime.ts +0 -910
  39. package/src/google-probe.ts +0 -113
  40. package/src/google-token.ts +0 -391
  41. package/src/remote-runner.ts +0 -77
  42. package/src/worker.ts +0 -117
  43. package/tests/auth-log.test.ts +0 -530
  44. package/tests/connector-auth.test.ts +0 -559
  45. package/tests/connector-login.test.ts +0 -151
  46. package/tests/connector-runtime.test.ts +0 -1664
  47. package/tests/google-probe.test.ts +0 -116
  48. package/tests/google-token.test.ts +0 -425
  49. package/tests/remote-runner.test.ts +0 -202
  50. package/tests/worker.test.ts +0 -167
@@ -1,202 +0,0 @@
1
- import { readdirSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { describe, it, expect, vi, afterEach } from 'vitest';
5
- import { useRemoteGogRunner } from '../src/remote-runner.js';
6
- import { run, runBinary, runExecutor, setDefaultGogExecutor } from '../src/runner.js';
7
-
8
- /**
9
- * The whole point of this seam is that a host without the `gog` binary can
10
- * still serve. Every way it has silently failed so far: a half-configured env
11
- * that falls back to spawning; a bin that never calls `useRemoteGogRunner()`;
12
- * and — the one that shipped in 2.19.x — parking the executor in an
13
- * AsyncLocalStorage that the tool calls never inherit, so the seam reported
14
- * success and then spawned anyway.
15
- *
16
- * They share a shape worth naming: every one of them fails by falling back to
17
- * the local binary, which on a laptop is invisible and on a host is total. So
18
- * these tests assert on where a call ACTUALLY went, never on whether the wiring
19
- * step was performed.
20
- */
21
-
22
- afterEach(() => {
23
- vi.unstubAllGlobals();
24
- vi.unstubAllEnvs();
25
- // The executor is process-wide BY DESIGN now, so unlike an ALS store it does
26
- // not unwind when a test ends — without this, the first test to install one
27
- // would silently hand it to every test that ran after it.
28
- setDefaultGogExecutor(undefined);
29
- });
30
-
31
- describe('useRemoteGogRunner', () => {
32
- it('does nothing unless BOTH variables are set, so local installs are untouched', () => {
33
- expect(useRemoteGogRunner({})).toBe(false);
34
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test' })).toBe(false);
35
- expect(useRemoteGogRunner({ GOG_RUNNER_KEY: 'k' })).toBe(false);
36
- });
37
-
38
- it('treats blanks, placeholders and stringified nothings as unset', () => {
39
- // MCP hosts pass env blocks through verbatim, so all three of these arrive
40
- // in practice: a blank, a literal `${...}` that never expanded, and the
41
- // string "undefined" from a host that stringified a missing value. The
42
- // shared readEnvVar knows all three; a hand-rolled trim knew only the first
43
- // two, which is why this uses the shared one.
44
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: ' ', GOG_RUNNER_KEY: 'k' })).toBe(false);
45
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: '${GOG_RUNNER_KEY}' })).toBe(false);
46
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'undefined' })).toBe(false);
47
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'null', GOG_RUNNER_KEY: 'k' })).toBe(false);
48
- });
49
-
50
- it('addresses and authenticates the backend correctly', async () => {
51
- // Asserted THROUGH run(), not by reading whichever slot the executor is
52
- // parked in. The previous version of this test fetched
53
- // `runExecutor.getStore()` and called what it found, so it was really a
54
- // test that `enterWith` had been called — which stayed green while the
55
- // shipped bins routed every call to a local binary (see the next test).
56
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
57
- vi.stubGlobal('fetch', fetchMock);
58
- vi.stubEnv('GOG_PATH', '/nonexistent/gog');
59
-
60
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test/', GOG_RUNNER_KEY: 'secret' })).toBe(true);
61
-
62
- // Cross a macrotask boundary, the way a stdio tool call does.
63
- await new Promise((r) => setTimeout(r, 0));
64
- expect(await run(['--version'])).toBe('ok');
65
-
66
- const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
67
- // Trailing slash trimmed, so the endpoint is never `…//run`.
68
- expect(url).toBe('https://r.test/run');
69
- expect((init.headers as Record<string, string>).Authorization).toBe('Bearer secret');
70
- });
71
-
72
- it('still routes remotely from a context the store never reached', async () => {
73
- // THE ONE THE TEST ABOVE CANNOT CATCH, and it shipped broken.
74
- //
75
- // `enterWith` mutates the store of the async resource that is current when
76
- // it runs. The test above calls useRemoteGogRunner() itself, so everything
77
- // it awaits afterwards is a descendant of that resource and inherits the
78
- // store — it passes whether or not the seam works where it matters.
79
- //
80
- // A bin does something different: it calls useRemoteGogRunner() while the
81
- // ES module is evaluating, and then `await runMcp(...)` starts serving. The
82
- // tool calls arrive later as I/O events on the transport's own async
83
- // resources, which are NOT descendants of module evaluation, so
84
- // `getStore()` is undefined by the time `run()` asks. Verified against the
85
- // published 2.19.1 bin: `useRemoteGogRunner()` returned true, and at call
86
- // time the store was undefined — every hosted gog MCP answered
87
- // "gog executable not found" while pointed at a healthy backend.
88
- //
89
- // `runExecutor.exit()` is that condition exactly, and deterministically:
90
- // run the call where the ALS store does not apply.
91
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'remote' }), { status: 200 }));
92
- vi.stubGlobal('fetch', fetchMock);
93
- // So the spawn fallback cannot accidentally succeed on a machine that has
94
- // gog installed and make this pass for the wrong reason.
95
- vi.stubEnv('GOG_PATH', '/nonexistent/gog');
96
-
97
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
98
-
99
- const output = await runExecutor.exit(() => run(['auth', 'status']));
100
- expect(output).toBe('remote');
101
- expect(fetchMock).toHaveBeenCalledTimes(1);
102
- });
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
-
145
- it('lets a per-request store beat the process-wide default', async () => {
146
- // The Worker path scopes every request in `runExecutor.run({ executor })`
147
- // because one isolate serves many callers (connector-runtime.ts). A
148
- // process-wide default must never shadow that: it is the fallback for a
149
- // stdio bin, not a second answer to "whose backend is this".
150
- const perRequest = vi.fn(async () => 'per-request');
151
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ stdout: 'default' }), { status: 200 })));
152
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
153
-
154
- const output = await runExecutor.run({ executor: perRequest }, () => run(['auth', 'status']));
155
- expect(output).toBe('per-request');
156
- expect(perRequest).toHaveBeenCalledTimes(1);
157
- });
158
-
159
- it('still refuses binary retrieval when the default is a remote executor', async () => {
160
- // runBinary's guard asks the same question ("is a text-only forward
161
- // installed?") and must read the same answer, or a hosted bin would fall
162
- // through to spawning for bytes while every text call went remote.
163
- vi.stubEnv('GOG_PATH', '/nonexistent/gog');
164
- expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
165
-
166
- await expect(runExecutor.exit(() => runBinary(['drive', 'read']))).rejects.toThrow(/text-only/);
167
- });
168
- });
169
-
170
- /**
171
- * The seam is opt-in PER BIN — `useRemoteGogRunner()` is a call in each
172
- * package's `index.ts`, and a package that omits it spawns the binary no
173
- * matter what the host sets. That shipped: 2.19.0 wired base, sheets, docs,
174
- * drive and gmail, and left calendar, classroom, contacts and slides behind,
175
- * so those four answered every tool call on mcp-host with "gog executable not
176
- * found" — a host with no binary and no way to ask for the backend.
177
- *
178
- * Nothing caught it because each package's suite covers its TOOLS, and
179
- * `src/index.ts` is excluded from the coverage gate in every package (it is
180
- * the bin: it boots a server and cannot be imported under test). So this
181
- * asserts on the source text instead, across the whole workspace — the one
182
- * check that scales to the next sub-package, which will otherwise be added by
183
- * copying an index.ts that predates the seam.
184
- */
185
- describe('every package bin', () => {
186
- const packagesDir = fileURLToPath(new URL('../../', import.meta.url));
187
- const bins = readdirSync(packagesDir)
188
- .filter((name) => name.startsWith('gogcli-mcp'))
189
- .map((name) => [name, join(packagesDir, name, 'src', 'index.ts')] as const);
190
-
191
- it('finds every package (guards the glob itself against silently matching nothing)', () => {
192
- expect(bins.length).toBeGreaterThanOrEqual(9);
193
- });
194
-
195
- it.each(bins)('%s installs the remote executor before starting the server', (_name, path) => {
196
- const source = readFileSync(path, 'utf8');
197
- expect(source).toContain('useRemoteGogRunner()');
198
- // Order matters as much as presence: `runMcp` starts serving, so a call
199
- // placed after it could be beaten by a tool call and fall back to spawning.
200
- expect(source.indexOf('useRemoteGogRunner()')).toBeLessThan(source.indexOf('runMcp({'));
201
- });
202
- });
@@ -1,167 +0,0 @@
1
- import { SELF } from 'cloudflare:test';
2
- import { describe, it, expect } from 'vitest';
3
- import { createTestHarness } from '@chrischall/mcp-utils/test';
4
- import type { ToolRegistrar } from '@chrischall/mcp-utils';
5
- import {
6
- BASE_TOOL_REGISTRARS,
7
- registerAuthTools,
8
- registerSheetsTools,
9
- registerGmailTools,
10
- registerDriveTools,
11
- registerDocsTools,
12
- } from '../src/lib.js';
13
- import { registerExtraSheetsTools } from '../../gogcli-mcp-sheets/src/tools/sheets-extra.js';
14
- import { registerExtraGmailTools } from '../../gogcli-mcp-gmail/src/tools/gmail-extra.js';
15
- import { registerExtraDriveTools } from '../../gogcli-mcp-drive/src/tools/drive-extra.js';
16
- import { registerExtraDocsTools } from '../../gogcli-mcp-docs/src/tools/docs-extra.js';
17
-
18
- // Each per-service MCP path exposes auth + <service> base + <service> extras —
19
- // the same tool set that sub-package's stdio server exposes. Mirror worker.ts's
20
- // wiring. Min counts are conservative floors (base+auth alone is ~13-15).
21
- const SERVICE_PATHS: Array<{
22
- path: string;
23
- regs: ToolRegistrar[];
24
- baseTool: string;
25
- minTools: number;
26
- }> = [
27
- { path: '/mcp/sheets', regs: [registerAuthTools, registerSheetsTools, registerExtraSheetsTools], baseTool: 'gog_sheets_get', minTools: 40 },
28
- { path: '/mcp/gmail', regs: [registerAuthTools, registerGmailTools, registerExtraGmailTools], baseTool: 'gog_gmail_search', minTools: 40 },
29
- { path: '/mcp/drive', regs: [registerAuthTools, registerDriveTools, registerExtraDriveTools], baseTool: 'gog_drive_ls', minTools: 35 },
30
- { path: '/mcp/docs', regs: [registerAuthTools, registerDocsTools, registerExtraDocsTools], baseTool: 'gog_docs_cat', minTools: 60 },
31
- ];
32
-
33
- // Handshake + tool-surface test for the gogcli Cloudflare remote connector, run
34
- // inside the real Workers runtime (Miniflare) via `@cloudflare/vitest-pool-workers`
35
- // against `wrangler.jsonc`. It proves things that don't require a live Fly
36
- // backend or an authenticated session:
37
- // 1. the OAuth default handler serves discovery + the login page;
38
- // 2. an unauthenticated `/mcp` request is rejected before any tool code runs;
39
- // 3. the base registrars register the full gog tool surface.
40
- //
41
- // The full authenticated `initialize` + `tools/list` over `/mcp` requires a real
42
- // OAuth access token minted via `workers-oauth-provider`'s KV-backed grant flow
43
- // (POST /authorize with a real connector key → auth code → POST /token), which
44
- // would mean a live Fly login or extensive KV mocking — out of scope for a
45
- // hermetic in-process test. So #3 asserts tool registration through the same
46
- // in-memory MCP harness the stdio suite uses, wired exactly as `worker.ts` wires
47
- // it (BASE_TOOL_REGISTRARS), rather than through the token-gated `/mcp` route.
48
-
49
- describe('gogcli Cloudflare connector — OAuth surface', () => {
50
- it('serves the OAuth authorization-server discovery document', async () => {
51
- const res = await SELF.fetch('https://example.com/.well-known/oauth-authorization-server');
52
- expect(res.status).toBe(200);
53
- const meta = (await res.json()) as { authorization_endpoint?: string; token_endpoint?: string };
54
- expect(meta.authorization_endpoint).toContain('/authorize');
55
- expect(meta.token_endpoint).toContain('/token');
56
- });
57
-
58
- it('rejects an unauthenticated /mcp request', async () => {
59
- const res = await SELF.fetch('https://example.com/mcp', {
60
- method: 'POST',
61
- headers: { 'content-type': 'application/json' },
62
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
63
- });
64
- expect(res.status).toBe(401);
65
- });
66
-
67
- // Each per-service path is wired as an auth-gated API route (→ 401), not a
68
- // 404 from the default handler. (A 401 confirms the path is registered and
69
- // token-gated; correct routing to the SERVICE agent vs the base agent is
70
- // verified live post-deploy via an authenticated tools/list.)
71
- for (const { path } of SERVICE_PATHS) {
72
- it(`rejects an unauthenticated ${path} request`, async () => {
73
- const res = await SELF.fetch(`https://example.com${path}`, {
74
- method: 'POST',
75
- headers: { 'content-type': 'application/json' },
76
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
77
- });
78
- expect(res.status).toBe(401);
79
- });
80
- }
81
-
82
- it('GET /authorize renders the gogcli login page with the connector-key field', async () => {
83
- // `/authorize` is only reachable for a REGISTERED client, so the request has
84
- // to be preceded by a real dynamic client registration.
85
- //
86
- // It did not always: workers-oauth-provider 0.8.x parsed the request without
87
- // a `client_id` (it only called validateRedirectUriScheme unconditionally,
88
- // which is why `redirect_uri` was already here). 0.10.x moved the
89
- // `client_id is required` check and the client lookup ahead of everything
90
- // else in parseAuthRequest, so the old URL now throws before the login page
91
- // is ever rendered — an AuthorizationError from inside the library, not from
92
- // any code in this repo. Registering first is the fix, and it exercises the
93
- // path a real client actually takes.
94
- const redirectUri = 'https://example.com/callback';
95
- const registration = await SELF.fetch('https://example.com/register', {
96
- method: 'POST',
97
- headers: { 'content-type': 'application/json' },
98
- body: JSON.stringify({
99
- client_name: 'worker test client',
100
- redirect_uris: [redirectUri],
101
- token_endpoint_auth_method: 'none',
102
- }),
103
- });
104
- expect(registration.status).toBe(201);
105
- const { client_id: clientId } = (await registration.json()) as { client_id: string };
106
- expect(clientId).toBeTruthy();
107
-
108
- // PKCE is mandatory for a public client (`token_endpoint_auth_method: none`)
109
- // on the authorization-code flow, and the provider enforces it during the
110
- // same parse. The challenge is never redeemed here — this test stops at the
111
- // rendered page — so any well-formed S256 value will do.
112
- const codeChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
113
- const res = await SELF.fetch(
114
- 'https://example.com/authorize?response_type=code&state=abc' +
115
- `&client_id=${encodeURIComponent(clientId)}` +
116
- `&redirect_uri=${encodeURIComponent(redirectUri)}` +
117
- `&code_challenge=${codeChallenge}&code_challenge_method=S256`,
118
- );
119
- expect(res.status).toBe(200);
120
- expect(res.headers.get('content-type')).toContain('text/html');
121
- const html = await res.text();
122
- expect(html).toContain('gogcli');
123
- expect(html).toContain('gogcli connector key');
124
- expect(html).toContain('type="password"');
125
- });
126
- });
127
-
128
- describe('gogcli Cloudflare connector — tool surface', () => {
129
- it('registers the base gog tool set via the same registrars as worker.ts', async () => {
130
- // Mirror src/worker.ts's `tools` wiring: register every BASE_TOOL_REGISTRARS
131
- // onto the in-memory harness server (no executor needed just to list tools).
132
- const harness = await createTestHarness((server) => {
133
- for (const register of BASE_TOOL_REGISTRARS) register(server);
134
- });
135
-
136
- try {
137
- const names = (await harness.listTools()).map((t) => t.name);
138
- // Spot-check representative tools from multiple services + a run escape hatch.
139
- expect(names).toContain('gog_sheets_get');
140
- expect(names).toContain('gog_gmail_search');
141
- expect(names).toContain('gog_drive_ls');
142
- expect(names).toContain('gog_sheets_run');
143
- // The base package exposes a broad multi-service surface.
144
- expect(names.length).toBeGreaterThan(50);
145
- } finally {
146
- await harness.close();
147
- }
148
- });
149
-
150
- // Each per-service path registers auth + that service's base + its EXTRAS —
151
- // proving the extended tool set is wired (well past the ~13 base+auth tools).
152
- for (const { path, regs, baseTool, minTools } of SERVICE_PATHS) {
153
- it(`${path} registers the extended ${baseTool.split('_')[1]} tool set (base + extras)`, async () => {
154
- const harness = await createTestHarness((server) => {
155
- for (const register of regs) register(server);
156
- });
157
- try {
158
- const names = (await harness.listTools()).map((t) => t.name);
159
- expect(names).toContain(baseTool); // the service's base op
160
- expect(names).toContain('gog_auth_list'); // auth tools included
161
- expect(names.length).toBeGreaterThan(minTools); // extras present, not just base
162
- } finally {
163
- await harness.close();
164
- }
165
- });
166
- }
167
- });