gogcli-mcp 2.30.0 → 3.0.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 (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +15416 -18013
  4. package/dist/lib.js +13476 -8827
  5. package/manifest.json +1 -1
  6. package/package.json +3 -3
  7. package/server.json +2 -2
  8. package/src/connector-auth.ts +22 -6
  9. package/src/connector-login.ts +87 -0
  10. package/src/connector-runtime.ts +33 -10
  11. package/src/gmail-dispatch-guard.ts +106 -0
  12. package/src/gmail-results.ts +1 -1
  13. package/src/lib.ts +10 -0
  14. package/src/pagination.ts +1 -1
  15. package/src/tools/api.ts +7 -7
  16. package/src/tools/appscript.ts +15 -15
  17. package/src/tools/auth.ts +11 -11
  18. package/src/tools/calendar.ts +13 -13
  19. package/src/tools/chat.ts +25 -25
  20. package/src/tools/classroom.ts +49 -49
  21. package/src/tools/contacts.ts +9 -9
  22. package/src/tools/docs.ts +13 -13
  23. package/src/tools/drive.ts +22 -22
  24. package/src/tools/gmail.ts +136 -23
  25. package/src/tools/sheets.ts +15 -15
  26. package/src/tools/slides.ts +13 -13
  27. package/src/tools/tasks.ts +13 -13
  28. package/src/tools/utils.ts +2 -3
  29. package/src/worker.ts +52 -64
  30. package/tests/connector-login.test.ts +151 -0
  31. package/tests/connector-runtime.test.ts +21 -1
  32. package/tests/gmail-dispatch-guard.test.ts +132 -0
  33. package/tests/sdk-single-copy.test.ts +18 -36
  34. package/tests/tools/appscript.test.ts +1 -1
  35. package/tests/tools/chat.test.ts +1 -1
  36. package/tests/tools/gmail.test.ts +242 -12
  37. package/tests/tools/sheets.test.ts +1 -1
  38. package/tests/zod-single-copy.test.ts +2 -2
  39. package/tsconfig.json +1 -3
  40. package/vitest.config.ts +3 -5
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { run } from '../runner.js';
4
4
  import { rawTextResult } from '@chrischall/mcp-utils';
@@ -25,11 +25,11 @@ export function registerSheetsTools(server: McpServer): void {
25
25
  server.registerTool('gog_sheets_get', {
26
26
  description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
27
27
  annotations: { readOnlyHint: true },
28
- inputSchema: {
28
+ inputSchema: z.object({
29
29
  spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
30
30
  range: z.string().describe('Range in A1 notation, e.g. Sheet1!A1:B10 or a named range'),
31
31
  account: accountParam,
32
- },
32
+ }),
33
33
  }, async ({ spreadsheetId, range, account }) => {
34
34
  return runOrDiagnose(['sheets', 'get', spreadsheetId, range], { account });
35
35
  });
@@ -37,7 +37,7 @@ export function registerSheetsTools(server: McpServer): void {
37
37
  server.registerTool('gog_sheets_update', {
38
38
  description: 'Write values to a Google Sheets range, overwriting existing content. Values may be strings, numbers, booleans, or null. Strings starting with "=" are interpreted as formulas (e.g. "=SUM(A1:A10)").',
39
39
  annotations: { destructiveHint: true },
40
- inputSchema: {
40
+ inputSchema: z.object({
41
41
  spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
42
42
  range: z.string().describe('Top-left cell or range in A1 notation, e.g. Sheet1!A1'),
43
43
  values: z.array(z.array(cellValueParam)).describe('2D array of values (rows of columns). Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
@@ -45,7 +45,7 @@ export function registerSheetsTools(server: McpServer): void {
45
45
  fail_if_not_empty: failIfNotEmptyParam,
46
46
  fail_on_formula_error: z.boolean().optional().describe('After writing, read the range back and fail if any cell holds a Sheets formula error (#REF!, #DIV/0!, etc.).'),
47
47
  account: accountParam,
48
- },
48
+ }),
49
49
  }, async ({ spreadsheetId, range, values, account, dry_run, fail_if_not_empty, fail_on_formula_error }) => {
50
50
  const cols = values.reduce((max, row) => Math.max(max, row.length), 0);
51
51
  if (fail_if_not_empty && values.length > 0 && cols > 0) {
@@ -79,13 +79,13 @@ export function registerSheetsTools(server: McpServer): void {
79
79
  server.registerTool('gog_sheets_append', {
80
80
  description: 'Append rows to a Google Sheet after the last row with data in the given range. Values may be strings, numbers, booleans, or null. Strings starting with "=" are interpreted as formulas.',
81
81
  annotations: { destructiveHint: true },
82
- inputSchema: {
82
+ inputSchema: z.object({
83
83
  spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
84
84
  range: z.string().describe('Range indicating which sheet/columns to append to, e.g. Sheet1!A:C'),
85
85
  values: z.array(z.array(cellValueParam)).describe('2D array of rows to append. Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
86
86
  dry_run: dryRunParam,
87
87
  account: accountParam,
88
- },
88
+ }),
89
89
  }, async ({ spreadsheetId, range, values, account, dry_run }) => {
90
90
  const args = ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
91
91
  if (dry_run) args.push('--dry-run');
@@ -95,12 +95,12 @@ export function registerSheetsTools(server: McpServer): void {
95
95
  server.registerTool('gog_sheets_clear', {
96
96
  description: 'Clear all values in a Google Sheets range (formatting is preserved).',
97
97
  annotations: { destructiveHint: true },
98
- inputSchema: {
98
+ inputSchema: z.object({
99
99
  spreadsheetId: z.string().describe('Spreadsheet ID'),
100
100
  range: z.string().describe('Range in A1 notation to clear'),
101
101
  dry_run: dryRunParam,
102
102
  account: accountParam,
103
- },
103
+ }),
104
104
  }, async ({ spreadsheetId, range, account, dry_run }) => {
105
105
  const args = ['sheets', 'clear', spreadsheetId, range];
106
106
  if (dry_run) args.push('--dry-run');
@@ -110,10 +110,10 @@ export function registerSheetsTools(server: McpServer): void {
110
110
  server.registerTool('gog_sheets_metadata', {
111
111
  description: 'Get spreadsheet metadata: title, named ranges, and per-tab properties including grid dimensions (gridProperties.rowCount / columnCount). Use this to learn a sheet\'s current size before writing — a write outside the grid fails.',
112
112
  annotations: { readOnlyHint: true },
113
- inputSchema: {
113
+ inputSchema: z.object({
114
114
  spreadsheetId: z.string().describe('Spreadsheet ID'),
115
115
  account: accountParam,
116
- },
116
+ }),
117
117
  }, async ({ spreadsheetId, account }) => {
118
118
  return runOrDiagnose(['sheets', 'metadata', spreadsheetId], { account });
119
119
  });
@@ -121,10 +121,10 @@ export function registerSheetsTools(server: McpServer): void {
121
121
  server.registerTool('gog_sheets_create', {
122
122
  description: 'Create a new Google Spreadsheet. Returns JSON with the new spreadsheetId and URL.',
123
123
  annotations: { destructiveHint: false },
124
- inputSchema: {
124
+ inputSchema: z.object({
125
125
  title: z.string().describe('Title for the new spreadsheet'),
126
126
  account: accountParam,
127
- },
127
+ }),
128
128
  }, async ({ title, account }) => {
129
129
  return runOrDiagnose(['sheets', 'create', title], { account });
130
130
  });
@@ -132,12 +132,12 @@ export function registerSheetsTools(server: McpServer): void {
132
132
  server.registerTool('gog_sheets_find_replace', {
133
133
  description: 'Find and replace text across an entire Google Spreadsheet.',
134
134
  annotations: { destructiveHint: true },
135
- inputSchema: {
135
+ inputSchema: z.object({
136
136
  spreadsheetId: z.string().describe('Spreadsheet ID'),
137
137
  find: z.string().describe('Text to find'),
138
138
  replace: z.string().describe('Replacement text'),
139
139
  account: accountParam,
140
- },
140
+ }),
141
141
  }, async ({ spreadsheetId, find, replace, account }) => {
142
142
  return runOrDiagnose(['sheets', 'find-replace', spreadsheetId, find, replace], { account });
143
143
  });
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
 
@@ -6,13 +6,13 @@ export function registerSlidesTools(server: McpServer): void {
6
6
  server.registerTool('gog_slides_export', {
7
7
  description: 'Export a Google Slides presentation to a local file (pdf or pptx).',
8
8
  annotations: { readOnlyHint: true },
9
- inputSchema: {
9
+ inputSchema: z.object({
10
10
  presentationId: z.string().describe('Presentation ID'),
11
11
  out: z.string().optional().describe('Output file path'),
12
12
  format: z.enum(['pdf', 'pptx']).optional().describe('Export format (default: pptx)'),
13
13
  overwrite: z.boolean().optional().describe('Overwrite the output file if it already exists (gog refuses otherwise)'),
14
14
  account: accountParam,
15
- },
15
+ }),
16
16
  }, async ({ presentationId, out, format, overwrite, account }) => {
17
17
  const args = ['slides', 'export', presentationId];
18
18
  if (out) args.push(`--out=${out}`);
@@ -24,22 +24,22 @@ export function registerSlidesTools(server: McpServer): void {
24
24
  server.registerTool('gog_slides_info', {
25
25
  description: 'Get metadata for a Google Slides presentation (title, ID, slide count, etc.).',
26
26
  annotations: { readOnlyHint: true },
27
- inputSchema: {
27
+ inputSchema: z.object({
28
28
  presentationId: z.string().describe('Presentation ID'),
29
29
  account: accountParam,
30
- },
30
+ }),
31
31
  }, async ({ presentationId, account }) => {
32
32
  return runOrDiagnose(['slides', 'info', presentationId], { account });
33
33
  });
34
34
 
35
35
  server.registerTool('gog_slides_create', {
36
36
  description: 'Create a new Google Slides presentation, optionally in a folder or copying from a template.',
37
- inputSchema: {
37
+ inputSchema: z.object({
38
38
  title: z.string().describe('Presentation title'),
39
39
  parent: z.string().optional().describe('Destination folder ID'),
40
40
  template: z.string().optional().describe('Template presentation ID to copy from'),
41
41
  account: accountParam,
42
- },
42
+ }),
43
43
  }, async ({ title, parent, template, account }) => {
44
44
  const args = ['slides', 'create', title];
45
45
  if (parent) args.push(`--parent=${parent}`);
@@ -49,12 +49,12 @@ export function registerSlidesTools(server: McpServer): void {
49
49
 
50
50
  server.registerTool('gog_slides_copy', {
51
51
  description: 'Copy a Google Slides presentation to a new presentation with the given title.',
52
- inputSchema: {
52
+ inputSchema: z.object({
53
53
  presentationId: z.string().describe('Presentation ID to copy'),
54
54
  title: z.string().describe('Title for the new copy'),
55
55
  parent: z.string().optional().describe('Destination folder ID'),
56
56
  account: accountParam,
57
- },
57
+ }),
58
58
  }, async ({ presentationId, title, parent, account }) => {
59
59
  const args = ['slides', 'copy', presentationId, title];
60
60
  if (parent) args.push(`--parent=${parent}`);
@@ -64,10 +64,10 @@ export function registerSlidesTools(server: McpServer): void {
64
64
  server.registerTool('gog_slides_list_slides', {
65
65
  description: 'List slides in a Google Slides presentation.',
66
66
  annotations: { readOnlyHint: true },
67
- inputSchema: {
67
+ inputSchema: z.object({
68
68
  presentationId: z.string().describe('Presentation ID'),
69
69
  account: accountParam,
70
- },
70
+ }),
71
71
  }, async ({ presentationId, account }) => {
72
72
  return runOrDiagnose(['slides', 'list-slides', presentationId], { account });
73
73
  });
@@ -75,12 +75,12 @@ export function registerSlidesTools(server: McpServer): void {
75
75
  server.registerTool('gog_slides_read_slide', {
76
76
  description: 'Read the content of a slide (text, shapes, speaker notes). Set detail=true to also include normalized element geometry, styled text runs, paragraphs, table-cell content, and image source URLs.',
77
77
  annotations: { readOnlyHint: true },
78
- inputSchema: {
78
+ inputSchema: z.object({
79
79
  presentationId: z.string().describe('Presentation ID'),
80
80
  slideId: z.string().describe('Slide ID to read'),
81
81
  detail: z.boolean().optional().describe('Include normalized element geometry, styled text runs, paragraphs, table-cell content, and image source URLs'),
82
82
  account: accountParam,
83
- },
83
+ }),
84
84
  }, async ({ presentationId, slideId, detail, account }) => {
85
85
  const args = ['slides', 'read-slide', presentationId, slideId];
86
86
  if (detail) args.push('--detail');
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
 
@@ -6,9 +6,9 @@ export function registerTasksTools(server: McpServer): void {
6
6
  server.registerTool('gog_tasks_lists', {
7
7
  description: 'List all Google Task lists.',
8
8
  annotations: { readOnlyHint: true },
9
- inputSchema: {
9
+ inputSchema: z.object({
10
10
  account: accountParam,
11
- },
11
+ }),
12
12
  }, async ({ account }) => {
13
13
  return runOrDiagnose(['tasks', 'lists', 'list'], { account });
14
14
  });
@@ -16,10 +16,10 @@ export function registerTasksTools(server: McpServer): void {
16
16
  server.registerTool('gog_tasks_list', {
17
17
  description: 'List tasks in a Google Task list.',
18
18
  annotations: { readOnlyHint: true },
19
- inputSchema: {
19
+ inputSchema: z.object({
20
20
  tasklistId: z.string().describe('Task list ID (use gog_tasks_lists to find IDs)'),
21
21
  account: accountParam,
22
- },
22
+ }),
23
23
  }, async ({ tasklistId, account }) => {
24
24
  return runOrDiagnose(['tasks', 'list', tasklistId], { account });
25
25
  });
@@ -27,11 +27,11 @@ export function registerTasksTools(server: McpServer): void {
27
27
  server.registerTool('gog_tasks_get', {
28
28
  description: 'Get a specific task by ID.',
29
29
  annotations: { readOnlyHint: true },
30
- inputSchema: {
30
+ inputSchema: z.object({
31
31
  tasklistId: z.string().describe('Task list ID'),
32
32
  taskId: z.string().describe('Task ID'),
33
33
  account: accountParam,
34
- },
34
+ }),
35
35
  }, async ({ tasklistId, taskId, account }) => {
36
36
  return runOrDiagnose(['tasks', 'get', tasklistId, taskId], { account });
37
37
  });
@@ -39,13 +39,13 @@ export function registerTasksTools(server: McpServer): void {
39
39
  server.registerTool('gog_tasks_add', {
40
40
  description: 'Add a task to a Google Task list.',
41
41
  annotations: { destructiveHint: false },
42
- inputSchema: {
42
+ inputSchema: z.object({
43
43
  tasklistId: z.string().describe('Task list ID'),
44
44
  title: z.string().describe('Task title'),
45
45
  notes: z.string().optional().describe('Task notes/description'),
46
46
  due: z.string().optional().describe('Due date (YYYY-MM-DD or RFC3339)'),
47
47
  account: accountParam,
48
- },
48
+ }),
49
49
  }, async ({ tasklistId, title, notes, due, account }) => {
50
50
  const args = ['tasks', 'add', tasklistId, `--title=${title}`];
51
51
  if (notes) args.push(`--notes=${notes}`);
@@ -56,11 +56,11 @@ export function registerTasksTools(server: McpServer): void {
56
56
  server.registerTool('gog_tasks_done', {
57
57
  description: 'Mark a task as completed.',
58
58
  annotations: { destructiveHint: true },
59
- inputSchema: {
59
+ inputSchema: z.object({
60
60
  tasklistId: z.string().describe('Task list ID'),
61
61
  taskId: z.string().describe('Task ID'),
62
62
  account: accountParam,
63
- },
63
+ }),
64
64
  }, async ({ tasklistId, taskId, account }) => {
65
65
  return runOrDiagnose(['tasks', 'done', tasklistId, taskId], { account });
66
66
  });
@@ -68,11 +68,11 @@ export function registerTasksTools(server: McpServer): void {
68
68
  server.registerTool('gog_tasks_delete', {
69
69
  description: 'Delete a task.',
70
70
  annotations: { destructiveHint: true },
71
- inputSchema: {
71
+ inputSchema: z.object({
72
72
  tasklistId: z.string().describe('Task list ID'),
73
73
  taskId: z.string().describe('Task ID'),
74
74
  account: accountParam,
75
- },
75
+ }),
76
76
  }, async ({ tasklistId, taskId, account }) => {
77
77
  // gog gates this delete behind a confirmation; the runner injects
78
78
  // --no-input, so without --force it refuses at runtime.
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
4
3
  import { errorResult, rawTextResult, minifiedResult, stripMediaUrls } from '@chrischall/mcp-utils';
5
4
  import { run, isRunnerTransportError } from '../runner.js';
6
5
  import type { GogArg, RunnerFailureKind } from '../runner.js';
@@ -149,7 +148,7 @@ export function registerRunTool(
149
148
  server.registerTool(`gog_${service}_run`, {
150
149
  description,
151
150
  annotations: { destructiveHint: true },
152
- inputSchema,
151
+ inputSchema: z.object(inputSchema),
153
152
  }, async (rawArgs) => {
154
153
  const { subcommand, args, account } = rawArgs as { subcommand: string; args: string[]; account?: string };
155
154
  return runOrDiagnose([service, subcommand, ...args], { account });
package/src/worker.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { McpAgent } from 'agents/mcp';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { createMcpHandler, getMcpAuthContext } from 'agents/mcp/server';
3
3
  import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
4
- import { handleAuthorize } from '@chrischall/mcp-connector';
5
4
  import type { ToolRegistrar } from '@chrischall/mcp-utils';
6
5
  import {
7
6
  BASE_TOOL_REGISTRARS,
@@ -15,8 +14,9 @@ import { registerExtraSheetsTools } from '../../gogcli-mcp-sheets/src/tools/shee
15
14
  import { registerExtraGmailTools } from '../../gogcli-mcp-gmail/src/tools/gmail-extra.js';
16
15
  import { registerExtraDriveTools } from '../../gogcli-mcp-drive/src/tools/drive-extra.js';
17
16
  import { registerExtraDocsTools } from '../../gogcli-mcp-docs/src/tools/docs-extra.js';
18
- import { makeFlyExecutor, wrapServer } from './connector-runtime.js';
17
+ import { createFlyExecutorResolver, wrapServer } from './connector-runtime.js';
19
18
  import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth.js';
19
+ import { handleAuthorize } from './connector-login.js';
20
20
 
21
21
  // The Cloudflare remote-connector entrypoint for gogcli-mcp.
22
22
  //
@@ -24,10 +24,9 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
24
24
  // every assembled `gog` arg-array by forwarding it to a Fly.io backend (a Worker
25
25
  // cannot spawn processes). The bridge is the `runExecutor` AsyncLocalStorage seam
26
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.
27
+ // so the handler's `run()` forwards to the authenticated caller's Fly executor.
28
28
  //
29
- // One Worker serves several MCP endpoints under one OAuth login, each a distinct
30
- // tool set backed by its own Durable Object:
29
+ // One stateless Worker serves several MCP endpoints under one OAuth login:
31
30
  // /mcp all-services base (BASE_TOOL_REGISTRARS)
32
31
  // /mcp/sheets auth + Sheets base + Sheets extras
33
32
  // /mcp/gmail auth + Gmail base + Gmail extras
@@ -38,52 +37,48 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
38
37
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
38
  // connectors in claude.ai (each authorizes with the same connector key).
40
39
 
41
- const VERSION = '2.30.0'; // x-release-please-version
40
+ const VERSION = '3.0.0'; // x-release-please-version
42
41
 
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
- // `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
- );
57
- async init() {
58
- // NO third argument, deliberately: the hosted connector supplies no
59
- // per-caller access token, so `gog` runs as the Fly volume's own identity
60
- // and refreshes from its own keyring. That is what makes the eviction +
61
- // replay machinery in connector-runtime.ts INERT here — with no token
62
- // source there is no module-level cache that can go stale, so a Google
63
- // 401 on this path stops at the `no access token was supplied` guard and
64
- // logs `replay.declined`. That record is the expected outcome for a
65
- // hosted connector, not a bug; the transport-failure classification and
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.)
81
- const executor = makeFlyExecutor((this.env as { FLY_ENDPOINT: string }).FLY_ENDPOINT, this.props.key);
82
- const wrapped = wrapServer(this.server, executor);
83
- for (const register of registrars) register(wrapped);
84
- }
85
- }
86
- return GogAgent as unknown as typeof McpAgent;
42
+ type WorkerEnv = { FLY_ENDPOINT: string };
43
+
44
+ // One resolver per Worker isolate. Reusing an executor for the same backend
45
+ // credential preserves makeFlyExecutor's refusal-probe throttle across
46
+ // stateless requests; different credentials retain independent throttles.
47
+ const resolveFlyExecutor = createFlyExecutorResolver();
48
+
49
+ // Build a fresh SDK v2 server for every HTTP request. That is the transport
50
+ // model required by MCP 2026-07-28 multi round-trip requests: input_required
51
+ // returns to the client, and the later request reconstructs the server from
52
+ // the tool arguments plus protocol-managed inputResponses.
53
+ function makeHandler(route: string, registrars: ToolRegistrar[]) {
54
+ return {
55
+ fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext) {
56
+ const handler = createMcpHandler(() => {
57
+ // Resolve the OAuth props lazily while the tool runs. OAuthProvider
58
+ // establishes this request context before calling the MCP handler.
59
+ const executor = ((args, options) => {
60
+ const props = getMcpAuthContext()?.props as GogProps | undefined;
61
+ if (!props?.key) throw new Error('Missing authenticated gogcli connector key');
62
+ return resolveFlyExecutor(env.FLY_ENDPOINT, props.key)(args, options);
63
+ }) satisfies ReturnType<typeof resolveFlyExecutor>;
64
+
65
+ // `instructions` is the connector's only channel to the model that is
66
+ // not a tool description. It explains that connector authentication
67
+ // does not prove the Google credential itself is healthy.
68
+ const server = new McpServer(
69
+ { name: 'gogcli-mcp', version: VERSION },
70
+ { instructions: CONNECTOR_INSTRUCTIONS },
71
+ );
72
+ const wrapped = wrapServer(server, executor);
73
+ for (const register of registrars) register(wrapped);
74
+ return server;
75
+ }, {
76
+ route,
77
+ allowedHostnames: ['connector.gogcli.nullnet.app'],
78
+ });
79
+ return handler(request, env, ctx);
80
+ },
81
+ };
87
82
  }
88
83
 
89
84
  // auth + <service> base + <service> extras — the exact set each sub-package's
@@ -94,12 +89,6 @@ function makeAgent(registrars: ToolRegistrar[]): typeof McpAgent {
94
89
  const svc = (service: string, base: ToolRegistrar, extra: ToolRegistrar): ToolRegistrar[] =>
95
90
  [authToolsFor(service), base, extra];
96
91
 
97
- export class GogcliMcpAgent extends makeAgent(BASE_TOOL_REGISTRARS) {}
98
- export class GogcliSheetsAgent extends makeAgent(svc('sheets', registerSheetsTools, registerExtraSheetsTools)) {}
99
- export class GogcliGmailAgent extends makeAgent(svc('gmail', registerGmailTools, registerExtraGmailTools)) {}
100
- export class GogcliDriveAgent extends makeAgent(svc('drive,driveactivity,drivelabels', registerDriveTools, registerExtraDriveTools)) {}
101
- export class GogcliDocsAgent extends makeAgent(svc('docs', registerDocsTools, registerExtraDocsTools)) {}
102
-
103
92
  const defaultHandler = {
104
93
  fetch(request: Request, env: unknown): Response | Promise<Response> {
105
94
  const url = new URL(request.url);
@@ -113,12 +102,11 @@ const defaultHandler = {
113
102
  // (otherwise `/mcp` greedily swallows `/mcp/sheets`).
114
103
  const handler = new OAuthProvider({
115
104
  apiHandlers: {
116
- '/mcp/sheets': GogcliSheetsAgent.serve('/mcp/sheets', { binding: 'SHEETS_MCP' }) as never,
117
- '/mcp/gmail': GogcliGmailAgent.serve('/mcp/gmail', { binding: 'GMAIL_MCP' }) as never,
118
- '/mcp/drive': GogcliDriveAgent.serve('/mcp/drive', { binding: 'DRIVE_MCP' }) as never,
119
- '/mcp/docs': GogcliDocsAgent.serve('/mcp/docs', { binding: 'DOCS_MCP' }) as never,
120
- '/mcp': GogcliMcpAgent.serve('/mcp') as never,
121
- '/sse': GogcliMcpAgent.serveSSE('/sse') as never,
105
+ '/mcp/sheets': makeHandler('/mcp/sheets', svc('sheets', registerSheetsTools, registerExtraSheetsTools)) as never,
106
+ '/mcp/gmail': makeHandler('/mcp/gmail', svc('gmail', registerGmailTools, registerExtraGmailTools)) as never,
107
+ '/mcp/drive': makeHandler('/mcp/drive', svc('drive,driveactivity,drivelabels', registerDriveTools, registerExtraDriveTools)) as never,
108
+ '/mcp/docs': makeHandler('/mcp/docs', svc('docs', registerDocsTools, registerExtraDocsTools)) as never,
109
+ '/mcp': makeHandler('/mcp', BASE_TOOL_REGISTRARS) as never,
122
110
  },
123
111
  defaultHandler: defaultHandler as never,
124
112
  authorizeEndpoint: '/authorize',
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { handleAuthorize } from '../src/connector-login.js';
3
+ import type { ConnectorAuth } from '../src/connector-auth.js';
4
+
5
+ function encoded(value: unknown): string {
6
+ return btoa(JSON.stringify(value));
7
+ }
8
+
9
+ function envFor(oauthReq: unknown = { clientId: 'client-1' }) {
10
+ return {
11
+ OAUTH_PROVIDER: {
12
+ parseAuthRequest: vi.fn(async () => oauthReq),
13
+ completeAuthorization: vi.fn(async () => ({ redirectTo: 'https://client.example/callback?code=ok' })),
14
+ },
15
+ };
16
+ }
17
+
18
+ const auth: ConnectorAuth<{ key: string }> = {
19
+ service: 'gogcli <Workspace>',
20
+ accent: '#4285F4',
21
+ privacyNote: 'Stored & encrypted.',
22
+ fields: [
23
+ { name: 'key', label: 'Connector "key"', type: 'password' },
24
+ { name: 'account', label: 'Account' },
25
+ ],
26
+ async login(fields) {
27
+ return { key: fields.key };
28
+ },
29
+ };
30
+
31
+ describe('connector login', () => {
32
+ it('renders the parsed OAuth request in an escaped credential form', async () => {
33
+ const env = envFor();
34
+ const response = await handleAuthorize(new Request('https://connector.example/authorize'), env, auth);
35
+
36
+ expect(response.status).toBe(200);
37
+ expect(response.headers.get('content-type')).toContain('text/html');
38
+ expect(env.OAUTH_PROVIDER.parseAuthRequest).toHaveBeenCalledOnce();
39
+ const html = await response.text();
40
+ expect(html).toContain('gogcli &lt;Workspace&gt;');
41
+ expect(html).toContain('Connector &quot;key&quot;');
42
+ expect(html).toContain('type="password"');
43
+ expect(html).toContain('Stored &amp; encrypted.');
44
+ expect(html).toContain(encoded({ clientId: 'client-1' }));
45
+ });
46
+
47
+ it('verifies submitted fields and completes authorization with an explicit user id', async () => {
48
+ const env = envFor();
49
+ const login = vi.fn(async () => ({ key: 'secret' }));
50
+ const form = new FormData();
51
+ form.set('oauthReq', encoded({ clientId: 'client-1' }));
52
+ form.set('key', 'secret');
53
+ form.set('account', 'person@example.com');
54
+
55
+ const response = await handleAuthorize(
56
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
57
+ env,
58
+ { ...auth, userId: 'fixed-user', login },
59
+ );
60
+
61
+ expect(login).toHaveBeenCalledWith(
62
+ { key: 'secret', account: 'person@example.com' },
63
+ env,
64
+ );
65
+ expect(env.OAUTH_PROVIDER.completeAuthorization).toHaveBeenCalledWith({
66
+ request: { clientId: 'client-1' },
67
+ userId: 'fixed-user',
68
+ scope: [],
69
+ metadata: {},
70
+ props: { key: 'secret' },
71
+ });
72
+ expect(response.status).toBe(302);
73
+ expect(response.headers.get('location')).toBe('https://client.example/callback?code=ok');
74
+ });
75
+
76
+ it('uses the first credential as the user id and treats non-text fields as empty', async () => {
77
+ const env = envFor();
78
+ const login = vi.fn(async () => ({ key: 'secret' }));
79
+ const form = new FormData();
80
+ form.set('oauthReq', encoded({ clientId: 'client-1' }));
81
+ form.set('key', 'secret');
82
+ form.set('account', new Blob(['ignored']));
83
+
84
+ await handleAuthorize(
85
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
86
+ env,
87
+ { ...auth, login },
88
+ );
89
+
90
+ expect(login).toHaveBeenCalledWith({ key: 'secret', account: '' }, env);
91
+ expect(env.OAUTH_PROVIDER.completeAuthorization).toHaveBeenCalledWith(
92
+ expect.objectContaining({ userId: 'secret' }),
93
+ );
94
+ });
95
+
96
+ it('uses the public identity when there are no credential fields', async () => {
97
+ const env = envFor();
98
+ const form = new FormData();
99
+ const login = vi.fn(async () => ({ key: 'public' }));
100
+
101
+ await handleAuthorize(
102
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
103
+ env,
104
+ { service: 'Public', fields: [], login },
105
+ );
106
+
107
+ expect(env.OAUTH_PROVIDER.completeAuthorization).toHaveBeenCalledWith(
108
+ expect.objectContaining({ request: undefined, userId: 'public' }),
109
+ );
110
+ });
111
+
112
+ it('re-renders a safely escaped Error without completing authorization', async () => {
113
+ const env = envFor();
114
+ const form = new FormData();
115
+ form.set('oauthReq', encoded({ clientId: 'client-1' }));
116
+ form.set('key', 'bad');
117
+ const response = await handleAuthorize(
118
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
119
+ env,
120
+ {
121
+ service: 'gogcli',
122
+ fields: [{ name: 'key', label: 'Key' }],
123
+ async login() {
124
+ throw new Error('bad <key>');
125
+ },
126
+ },
127
+ );
128
+
129
+ expect(response.status).toBe(200);
130
+ expect(await response.text()).toContain('bad &lt;key&gt;');
131
+ expect(env.OAUTH_PROVIDER.completeAuthorization).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('renders a non-Error rejection as text', async () => {
135
+ const env = envFor();
136
+ const form = new FormData();
137
+ const response = await handleAuthorize(
138
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
139
+ env,
140
+ {
141
+ service: 'gogcli',
142
+ fields: [],
143
+ async login() {
144
+ throw 'try again';
145
+ },
146
+ },
147
+ );
148
+
149
+ expect(await response.text()).toContain('try again');
150
+ });
151
+ });