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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +15416 -18013
- package/dist/lib.js +13476 -8827
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/connector-auth.ts +22 -6
- package/src/connector-login.ts +87 -0
- package/src/connector-runtime.ts +33 -10
- package/src/gmail-dispatch-guard.ts +106 -0
- package/src/gmail-results.ts +1 -1
- package/src/lib.ts +10 -0
- package/src/pagination.ts +1 -1
- package/src/tools/api.ts +7 -7
- package/src/tools/appscript.ts +15 -15
- package/src/tools/auth.ts +11 -11
- package/src/tools/calendar.ts +13 -13
- package/src/tools/chat.ts +25 -25
- package/src/tools/classroom.ts +49 -49
- package/src/tools/contacts.ts +9 -9
- package/src/tools/docs.ts +13 -13
- package/src/tools/drive.ts +22 -22
- package/src/tools/gmail.ts +136 -23
- package/src/tools/sheets.ts +15 -15
- package/src/tools/slides.ts +13 -13
- package/src/tools/tasks.ts +13 -13
- package/src/tools/utils.ts +2 -3
- package/src/worker.ts +52 -64
- package/tests/connector-login.test.ts +151 -0
- package/tests/connector-runtime.test.ts +21 -1
- package/tests/gmail-dispatch-guard.test.ts +132 -0
- package/tests/sdk-single-copy.test.ts +18 -36
- package/tests/tools/appscript.test.ts +1 -1
- package/tests/tools/chat.test.ts +1 -1
- package/tests/tools/gmail.test.ts +242 -12
- package/tests/tools/sheets.test.ts +1 -1
- package/tests/zod-single-copy.test.ts +2 -2
- package/tsconfig.json +1 -3
- package/vitest.config.ts +3 -5
package/src/tools/sheets.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
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
|
});
|
package/src/tools/slides.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
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');
|
package/src/tools/tasks.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
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.
|
package/src/tools/utils.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { McpServer } from '@modelcontextprotocol/
|
|
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/
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
|
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 = '
|
|
40
|
+
const VERSION = '3.0.0'; // x-release-please-version
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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':
|
|
117
|
-
'/mcp/gmail':
|
|
118
|
-
'/mcp/drive':
|
|
119
|
-
'/mcp/docs':
|
|
120
|
-
'/mcp':
|
|
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 <Workspace>');
|
|
41
|
+
expect(html).toContain('Connector "key"');
|
|
42
|
+
expect(html).toContain('type="password"');
|
|
43
|
+
expect(html).toContain('Stored & 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 <key>');
|
|
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
|
+
});
|