gogcli-mcp 2.29.1 → 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 +17105 -18892
- package/dist/lib.js +15157 -9562
- package/manifest.json +1 -1
- package/mint.yaml +2 -2
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/blob-upload.ts +179 -0
- package/src/blob-urls.ts +280 -0
- 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 +29 -0
- package/src/pagination.ts +1 -1
- package/src/runner.ts +19 -2
- 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/blob-upload.test.ts +204 -0
- package/tests/blob-urls.test.ts +319 -0
- 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/runner.test.ts +62 -1
- 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/docs.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,10 +6,10 @@ export function registerDocsTools(server: McpServer): void {
|
|
|
6
6
|
server.registerTool('gog_docs_info', {
|
|
7
7
|
description: 'Get Google Doc metadata: title, ID, and other properties.',
|
|
8
8
|
annotations: { readOnlyHint: true },
|
|
9
|
-
inputSchema: {
|
|
9
|
+
inputSchema: z.object({
|
|
10
10
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
11
11
|
account: accountParam,
|
|
12
|
-
},
|
|
12
|
+
}),
|
|
13
13
|
}, async ({ docId, account }) => {
|
|
14
14
|
return runOrDiagnose(['docs', 'info', docId], { account });
|
|
15
15
|
});
|
|
@@ -17,11 +17,11 @@ export function registerDocsTools(server: McpServer): void {
|
|
|
17
17
|
server.registerTool('gog_docs_cat', {
|
|
18
18
|
description: 'Read a Google Doc as plain text.',
|
|
19
19
|
annotations: { readOnlyHint: true },
|
|
20
|
-
inputSchema: {
|
|
20
|
+
inputSchema: z.object({
|
|
21
21
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
22
22
|
chips: z.boolean().optional().describe('Render Google Docs smart chips (people, dates, rich links) inline in the text output'),
|
|
23
23
|
account: accountParam,
|
|
24
|
-
},
|
|
24
|
+
}),
|
|
25
25
|
}, async ({ docId, chips, account }) => {
|
|
26
26
|
const args = ['docs', 'cat', docId];
|
|
27
27
|
if (chips) args.push('--chips');
|
|
@@ -30,10 +30,10 @@ export function registerDocsTools(server: McpServer): void {
|
|
|
30
30
|
|
|
31
31
|
server.registerTool('gog_docs_create', {
|
|
32
32
|
description: 'Create a new Google Doc. Returns JSON with the new docId and URL.',
|
|
33
|
-
inputSchema: {
|
|
33
|
+
inputSchema: z.object({
|
|
34
34
|
title: z.string().describe('Title for the new document'),
|
|
35
35
|
account: accountParam,
|
|
36
|
-
},
|
|
36
|
+
}),
|
|
37
37
|
}, async ({ title, account }) => {
|
|
38
38
|
return runOrDiagnose(['docs', 'create', title], { account });
|
|
39
39
|
});
|
|
@@ -41,7 +41,7 @@ export function registerDocsTools(server: McpServer): void {
|
|
|
41
41
|
server.registerTool('gog_docs_write', {
|
|
42
42
|
description: 'Write text content to a Google Doc, replacing existing body content by default. Set append=true to add after existing content.',
|
|
43
43
|
annotations: { destructiveHint: true },
|
|
44
|
-
inputSchema: {
|
|
44
|
+
inputSchema: z.object({
|
|
45
45
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
46
46
|
text: z.string().describe('Text content to write'),
|
|
47
47
|
append: z.boolean().optional().describe('Append to existing content instead of replacing (default: false)'),
|
|
@@ -59,7 +59,7 @@ export function registerDocsTools(server: McpServer): void {
|
|
|
59
59
|
keepWithNext: z.boolean().optional().describe('Keep the paragraph with the next paragraph (true) or clear that setting (false)'),
|
|
60
60
|
batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin in gogcli-mcp-docs) instead of applying it — nothing changes in the doc until the batch is submitted.'),
|
|
61
61
|
account: accountParam,
|
|
62
|
-
},
|
|
62
|
+
}),
|
|
63
63
|
}, async ({ docId, text, append, checkOrphans, bullets, bulletPreset, ordered, noBullets, indentStart, indentEnd, indentFirstLine, spaceAbove, spaceBelow, keepLinesTogether, keepWithNext, batch, account }) => {
|
|
64
64
|
const args = ['docs', 'write', docId, `--text=${text}`];
|
|
65
65
|
if (append) args.push('--append');
|
|
@@ -82,12 +82,12 @@ export function registerDocsTools(server: McpServer): void {
|
|
|
82
82
|
server.registerTool('gog_docs_find_replace', {
|
|
83
83
|
description: 'Find and replace text in a Google Doc.',
|
|
84
84
|
annotations: { destructiveHint: true },
|
|
85
|
-
inputSchema: {
|
|
85
|
+
inputSchema: z.object({
|
|
86
86
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
87
87
|
find: z.string().describe('Text to find'),
|
|
88
88
|
replace: z.string().describe('Replacement text'),
|
|
89
89
|
account: accountParam,
|
|
90
|
-
},
|
|
90
|
+
}),
|
|
91
91
|
}, async ({ docId, find, replace, account }) => {
|
|
92
92
|
return runOrDiagnose(['docs', 'find-replace', docId, find, replace], { account });
|
|
93
93
|
});
|
|
@@ -95,10 +95,10 @@ export function registerDocsTools(server: McpServer): void {
|
|
|
95
95
|
server.registerTool('gog_docs_structure', {
|
|
96
96
|
description: 'Show a Google Doc\'s structure with numbered paragraphs. Useful for understanding the document layout before making index-based edits.',
|
|
97
97
|
annotations: { readOnlyHint: true },
|
|
98
|
-
inputSchema: {
|
|
98
|
+
inputSchema: z.object({
|
|
99
99
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
100
100
|
account: accountParam,
|
|
101
|
-
},
|
|
101
|
+
}),
|
|
102
102
|
}, async ({ docId, account }) => {
|
|
103
103
|
return runOrDiagnose(['docs', 'structure', docId], { account });
|
|
104
104
|
});
|
package/src/tools/drive.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
2
|
-
import type { CallToolResult } from '@modelcontextprotocol/
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { rawTextResult, viewParam, resolveView } from '@chrischall/mcp-utils';
|
|
5
5
|
|
|
@@ -38,7 +38,7 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
38
38
|
server.registerTool('gog_drive_ls', {
|
|
39
39
|
description: 'List files in a Google Drive folder (default: root).',
|
|
40
40
|
annotations: { readOnlyHint: true },
|
|
41
|
-
inputSchema: {
|
|
41
|
+
inputSchema: z.object({
|
|
42
42
|
folderId: z.string().optional().describe('Folder ID to list (default: root)'),
|
|
43
43
|
max: z.number().optional().describe('Max results (default: 20)'),
|
|
44
44
|
pageToken: pageTokenParam,
|
|
@@ -50,7 +50,7 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
50
50
|
+ 'near-constant across a listing and 48% of its bytes. Ask for full to get them.',
|
|
51
51
|
}),
|
|
52
52
|
account: accountParam,
|
|
53
|
-
},
|
|
53
|
+
}),
|
|
54
54
|
}, async ({ folderId, max, pageToken, page, query, allDrives, view, account }) => {
|
|
55
55
|
const args = ['drive', 'ls'];
|
|
56
56
|
if (folderId) args.push(`--parent=${folderId}`);
|
|
@@ -69,14 +69,14 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
69
69
|
server.registerTool('gog_drive_search', {
|
|
70
70
|
description: 'Search Google Drive files by full-text query.',
|
|
71
71
|
annotations: { readOnlyHint: true },
|
|
72
|
-
inputSchema: {
|
|
72
|
+
inputSchema: z.object({
|
|
73
73
|
query: z.string().describe('Search query'),
|
|
74
74
|
// gog's `drive search` accepts no --fields mask, so unlike gog_drive_ls
|
|
75
75
|
// this tool's compact rung is a LOCAL projection. Same vocabulary either
|
|
76
76
|
// way: a caller does not need to know which lever is being pulled.
|
|
77
77
|
view: viewParam(['compact', 'full'], { note: 'compact (the default) drops thumbnailLink — a URL a model cannot see, and 30%+ of a Drive file record. Ask for full to get it back.' }),
|
|
78
78
|
account: accountParam,
|
|
79
|
-
},
|
|
79
|
+
}),
|
|
80
80
|
}, async ({ query, view, account }) => {
|
|
81
81
|
return runOrDiagnose(['drive', 'search', query], {
|
|
82
82
|
account,
|
|
@@ -87,7 +87,7 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
87
87
|
server.registerTool('gog_drive_get', {
|
|
88
88
|
description: 'Get metadata for a Google Drive file.',
|
|
89
89
|
annotations: { readOnlyHint: true },
|
|
90
|
-
inputSchema: {
|
|
90
|
+
inputSchema: z.object({
|
|
91
91
|
fileId: z.string().describe('File ID'),
|
|
92
92
|
// A --fields mask saves only 7% here: the default set is already narrow,
|
|
93
93
|
// which is why this tool takes no mask. The media strip saves 27.5% of
|
|
@@ -97,7 +97,7 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
97
97
|
// what the tool returns. The end-to-end figure is the one a caller sees.)
|
|
98
98
|
view: viewParam(['compact', 'full'], { note: 'compact (the default) drops thumbnailLink — a URL a model cannot see, and 30%+ of a Drive file record. Ask for full to get it back.' }),
|
|
99
99
|
account: accountParam,
|
|
100
|
-
},
|
|
100
|
+
}),
|
|
101
101
|
}, async ({ fileId, view, account }) => {
|
|
102
102
|
return runOrDiagnose(['drive', 'get', fileId], {
|
|
103
103
|
account,
|
|
@@ -108,10 +108,10 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
108
108
|
server.registerTool('gog_drive_mkdir', {
|
|
109
109
|
description: 'Create a new folder in Google Drive.',
|
|
110
110
|
annotations: { destructiveHint: false },
|
|
111
|
-
inputSchema: {
|
|
111
|
+
inputSchema: z.object({
|
|
112
112
|
name: z.string().describe('Folder name'),
|
|
113
113
|
account: accountParam,
|
|
114
|
-
},
|
|
114
|
+
}),
|
|
115
115
|
}, async ({ name, account }) => {
|
|
116
116
|
return runOrDiagnose(['drive', 'mkdir', name], { account });
|
|
117
117
|
});
|
|
@@ -119,11 +119,11 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
119
119
|
server.registerTool('gog_drive_rename', {
|
|
120
120
|
description: 'Rename a file or folder in Google Drive.',
|
|
121
121
|
annotations: { destructiveHint: true },
|
|
122
|
-
inputSchema: {
|
|
122
|
+
inputSchema: z.object({
|
|
123
123
|
fileId: z.string().describe('File or folder ID'),
|
|
124
124
|
newName: z.string().describe('New name'),
|
|
125
125
|
account: accountParam,
|
|
126
|
-
},
|
|
126
|
+
}),
|
|
127
127
|
}, async ({ fileId, newName, account }) => {
|
|
128
128
|
return runOrDiagnose(['drive', 'rename', fileId, newName], { account });
|
|
129
129
|
});
|
|
@@ -131,11 +131,11 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
131
131
|
server.registerTool('gog_drive_move', {
|
|
132
132
|
description: 'Move a file to a different folder in Google Drive.',
|
|
133
133
|
annotations: { destructiveHint: true },
|
|
134
|
-
inputSchema: {
|
|
134
|
+
inputSchema: z.object({
|
|
135
135
|
fileId: z.string().describe('File ID to move'),
|
|
136
136
|
parentId: z.string().describe('Destination folder ID'),
|
|
137
137
|
account: accountParam,
|
|
138
|
-
},
|
|
138
|
+
}),
|
|
139
139
|
}, async ({ fileId, parentId, account }) => {
|
|
140
140
|
return runOrDiagnose(['drive', 'move', fileId, `--parent=${parentId}`], { account });
|
|
141
141
|
});
|
|
@@ -143,11 +143,11 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
143
143
|
server.registerTool('gog_drive_delete', {
|
|
144
144
|
description: 'Move a Google Drive file to trash, or permanently delete it with permanent=true (irreversible).',
|
|
145
145
|
annotations: { destructiveHint: true },
|
|
146
|
-
inputSchema: {
|
|
146
|
+
inputSchema: z.object({
|
|
147
147
|
fileId: z.string().describe('File ID to delete'),
|
|
148
148
|
permanent: z.boolean().optional().describe('Permanently delete instead of moving to trash (irreversible)'),
|
|
149
149
|
account: accountParam,
|
|
150
|
-
},
|
|
150
|
+
}),
|
|
151
151
|
}, async ({ fileId, permanent, account }) => {
|
|
152
152
|
const args = ['drive', 'delete', fileId];
|
|
153
153
|
if (permanent) args.push('--permanent');
|
|
@@ -160,14 +160,14 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
160
160
|
server.registerTool('gog_drive_share', {
|
|
161
161
|
description: 'Share a Google Drive file or folder.',
|
|
162
162
|
annotations: { destructiveHint: true },
|
|
163
|
-
inputSchema: {
|
|
163
|
+
inputSchema: z.object({
|
|
164
164
|
fileId: z.string().describe('File or folder ID'),
|
|
165
165
|
to: z.enum(['user', 'anyone', 'domain']).describe('Share target type'),
|
|
166
166
|
email: z.string().optional().describe('User email (required when to=user)'),
|
|
167
167
|
domain: z.string().optional().describe('Domain (required when to=domain)'),
|
|
168
168
|
role: z.enum(['reader', 'writer']).optional().describe('Permission role (default: reader)'),
|
|
169
169
|
account: accountParam,
|
|
170
|
-
},
|
|
170
|
+
}),
|
|
171
171
|
}, async ({ fileId, to, email, domain, role, account }) => {
|
|
172
172
|
const args = ['drive', 'share', fileId, `--to=${to}`];
|
|
173
173
|
if (email) args.push(`--email=${email}`);
|
|
@@ -189,7 +189,7 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
189
189
|
'host filesystem, no scope widening. For a large file, page through with offset/maxChars.',
|
|
190
190
|
// Creates and deletes a temporary Doc for non-native files, so not read-only.
|
|
191
191
|
annotations: { destructiveHint: false },
|
|
192
|
-
inputSchema: {
|
|
192
|
+
inputSchema: z.object({
|
|
193
193
|
fileId: z.string().describe('Drive file ID (e.g. the id returned by gog_gmail_attachment)'),
|
|
194
194
|
ocrLanguage: z.string().optional().describe(
|
|
195
195
|
'BCP-47 language hint for OCR of scanned/image PDFs (e.g. "en", "fr"). Optional.',
|
|
@@ -197,7 +197,7 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
197
197
|
offset: z.number().int().nonnegative().optional().describe('Character offset to start from (default: 0)'),
|
|
198
198
|
maxChars: z.number().int().positive().optional().describe('Max characters to return (default: all from offset)'),
|
|
199
199
|
account: accountParam,
|
|
200
|
-
},
|
|
200
|
+
}),
|
|
201
201
|
}, async ({ fileId, ocrLanguage, offset = 0, maxChars, account }) => {
|
|
202
202
|
let tempDocId: string | undefined;
|
|
203
203
|
try {
|
|
@@ -255,10 +255,10 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
255
255
|
'stdio server; over the hosted connector the transport is text-only and this returns a clear error ' +
|
|
256
256
|
'(use gog_drive_extract_text there).',
|
|
257
257
|
annotations: { readOnlyHint: true },
|
|
258
|
-
inputSchema: {
|
|
258
|
+
inputSchema: z.object({
|
|
259
259
|
fileId: z.string().describe('Drive file ID'),
|
|
260
260
|
account: accountParam,
|
|
261
|
-
},
|
|
261
|
+
}),
|
|
262
262
|
}, async ({ fileId, account }): Promise<CallToolResult> => {
|
|
263
263
|
try {
|
|
264
264
|
const { name, mimeType } = fileMeta(await run(['drive', 'get', fileId], { account }));
|
package/src/tools/gmail.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
1
|
+
import { McpServer, type ServerContext } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken, assertNotBoth } from './utils.js';
|
|
4
4
|
import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
|
|
5
5
|
import type { GogArg } from '../runner.js';
|
|
6
6
|
import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
|
|
7
7
|
import type { InlineAttachmentInput } from '../attachments.js';
|
|
8
|
+
import { extractEmails, logGmailDispatch, requireGmailDispatchConfirmation, resultText } from '../gmail-dispatch-guard.js';
|
|
8
9
|
|
|
9
10
|
// gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
|
|
10
11
|
// only in the subcommand and default recipient set (reply → sender; reply-all
|
|
@@ -82,6 +83,94 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
|
|
|
82
83
|
args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false');
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// The send-side reply/reply-all schema, distinct from the shared replySchema
|
|
87
|
+
// above. gog_gmail_drafts_reply / _reply_all (gogcli-mcp-gmail) reuse
|
|
88
|
+
// replySchema verbatim and must never gain a send-confirmation input — draft
|
|
89
|
+
// tools stage mail, while send tools request confirmation through MCP.
|
|
90
|
+
const sendReplySchema = z.object(replySchema);
|
|
91
|
+
|
|
92
|
+
// gog's own `reply`/`reply-all` response never echoes the resolved
|
|
93
|
+
// recipients when replying to a single message (the common case: gog only
|
|
94
|
+
// includes `to` in its JSON when composing several messages at once, per
|
|
95
|
+
// internal/cmd/gmail_compose.go's gmailMessageResultJSON). So the only way to
|
|
96
|
+
// tell a caller — or the audit log below — who a reply is REALLY going to is
|
|
97
|
+
// to read the original message's own headers, which is what the reply
|
|
98
|
+
// inherits from. This makes the confirmation prompt accurate rather than a
|
|
99
|
+
// restatement of the flags the caller already passed.
|
|
100
|
+
export function parseMetadataHeaders(raw: string): Record<string, string> {
|
|
101
|
+
let parsed: unknown;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw);
|
|
104
|
+
} catch {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
const headers = (parsed as { headers?: unknown } | null)?.headers;
|
|
108
|
+
if (!headers || typeof headers !== 'object') return {};
|
|
109
|
+
const out: Record<string, string> = {};
|
|
110
|
+
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
|
111
|
+
if (typeof value === 'string') out[key] = value;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// reply → sender only; reply-all → sender plus every To/Cc participant. Both
|
|
117
|
+
// then apply the caller's own to/cc/bcc adds and remove drops, mirroring what
|
|
118
|
+
// gog itself would compose. This is intentionally an approximation (it does
|
|
119
|
+
// not, for instance, know about Reply-To or gog's self-exclusion rules) —
|
|
120
|
+
// good enough for a confirmation prompt and an audit log, neither of which is the
|
|
121
|
+
// enforcement point; the protocol confirmation gate is. Over-including a participant who
|
|
122
|
+
// would not actually receive the reply is the safe direction of error here.
|
|
123
|
+
export function computeReplyRecipients(
|
|
124
|
+
kind: 'reply' | 'reply-all',
|
|
125
|
+
headers: Record<string, string>,
|
|
126
|
+
flags: Pick<ReplyFlags, 'to' | 'cc' | 'bcc' | 'remove'>,
|
|
127
|
+
): string[] {
|
|
128
|
+
const base = kind === 'reply'
|
|
129
|
+
? [headers.from]
|
|
130
|
+
: [headers.from, headers.to, headers.cc];
|
|
131
|
+
let emails = extractEmails(...base, ...(flags.to ?? []), ...(flags.cc ?? []), ...(flags.bcc ?? []));
|
|
132
|
+
if (flags.remove?.length) {
|
|
133
|
+
const removed = new Set(extractEmails(...flags.remove));
|
|
134
|
+
emails = emails.filter((e) => !removed.has(e));
|
|
135
|
+
}
|
|
136
|
+
return emails;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Shared by gog_gmail_reply and gog_gmail_reply_all: fetch the target
|
|
140
|
+
// message's headers (always — the prompt needs them and so does the audit
|
|
141
|
+
// log on the accepted path), then confirm and send.
|
|
142
|
+
// assertNotBoth runs BEFORE the metadata fetch, in the caller, so a bad
|
|
143
|
+
// bodyHtml/bodyHtmlFile pair fails with zero gog calls, same as before this
|
|
144
|
+
// confirmation gate existed.
|
|
145
|
+
async function sendReply(
|
|
146
|
+
kind: 'reply' | 'reply-all',
|
|
147
|
+
toolName: string,
|
|
148
|
+
messageId: string,
|
|
149
|
+
account: string | undefined,
|
|
150
|
+
flags: ReplyFlags,
|
|
151
|
+
ctx: ServerContext,
|
|
152
|
+
) {
|
|
153
|
+
const metaResult = await runOrDiagnose(['gmail', 'get', messageId, '--format=metadata'], { account });
|
|
154
|
+
if (metaResult.isError) return metaResult;
|
|
155
|
+
const headers = parseMetadataHeaders(resultText(metaResult));
|
|
156
|
+
const recipients = computeReplyRecipients(kind, headers, flags);
|
|
157
|
+
const confirmation = requireGmailDispatchConfirmation(ctx, `gmail.${kind}`, {
|
|
158
|
+
messageId,
|
|
159
|
+
recipients,
|
|
160
|
+
recipientCount: recipients.length,
|
|
161
|
+
subject: flags.subject || (headers.subject ? `Re: ${headers.subject}` : undefined),
|
|
162
|
+
quoting: !flags.noQuote,
|
|
163
|
+
bodyLength: (flags.body ?? flags.bodyHtml ?? '').length,
|
|
164
|
+
attachmentCount: (flags.attach?.length ?? 0) + (flags.attachInline?.length ?? 0),
|
|
165
|
+
});
|
|
166
|
+
if (confirmation) return confirmation;
|
|
167
|
+
const args: GogArg[] = ['gmail', kind, messageId];
|
|
168
|
+
appendReplyFlags(args, flags);
|
|
169
|
+
const result = await runOrDiagnose(args, { account });
|
|
170
|
+
if (!result.isError) logGmailDispatch(toolName, recipients, account);
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
85
174
|
export function registerGmailTools(server: McpServer): void {
|
|
86
175
|
server.registerTool('gog_gmail_search', {
|
|
87
176
|
description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com). '
|
|
@@ -89,7 +178,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
89
178
|
+ 'IMPORTANT — a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist, or that there is no such mail, on the strength of one. Page through it (pass nextPageToken back as `pageToken`), set maxPages to walk several pages in one call, or narrow the query, and only then draw a conclusion. '
|
|
90
179
|
+ 'If you already know the thread, do not search for it at all — read it directly with gog_gmail_thread_get, which returns the whole thread and cannot be truncated or mis-ranked.',
|
|
91
180
|
annotations: { readOnlyHint: true },
|
|
92
|
-
inputSchema: {
|
|
181
|
+
inputSchema: z.object({
|
|
93
182
|
query: z.string().describe('Gmail search query'),
|
|
94
183
|
max: z.number().int().optional().describe('Max results to return (default: 10)'),
|
|
95
184
|
pageToken: pageTokenParam,
|
|
@@ -98,7 +187,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
98
187
|
all: z.boolean().optional().describe('Fetch every page instead of one. Removes truncation entirely, at the cost of one API round-trip per page — the reliable way to answer "does any message match?" for a query with few expected hits.'),
|
|
99
188
|
fromContact: z.string().optional().describe('Resolve a Google Contact (name or email) to its addresses and AND a from:(addr OR addr) clause onto the query — saves looking the contact up first when you only know who, not which address.'),
|
|
100
189
|
account: accountParam,
|
|
101
|
-
},
|
|
190
|
+
}),
|
|
102
191
|
}, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
|
|
103
192
|
const args = ['gmail', 'search', query];
|
|
104
193
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
@@ -126,7 +215,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
126
215
|
server.registerTool('gog_gmail_get', {
|
|
127
216
|
description: 'Get a Gmail message by ID. For a long message, sanitizeContent is the cheapest way to keep it in context: it drops the raw MIME payload and the HTML part, which are usually the bulk of the response.',
|
|
128
217
|
annotations: { readOnlyHint: true },
|
|
129
|
-
inputSchema: {
|
|
218
|
+
inputSchema: z.object({
|
|
130
219
|
messageId: z.string().describe('Message ID'),
|
|
131
220
|
format: z.enum(['full', 'metadata', 'raw']).optional().describe('Message format (default: full)'),
|
|
132
221
|
// Requires gog >= 0.37.0. Before that (openclaw/gogcli#992) the JSON
|
|
@@ -135,7 +224,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
135
224
|
// enlarged it. MIN_GOG_VERSION is the guard; there is no runtime check.
|
|
136
225
|
sanitizeContent: z.boolean().optional().describe('Return agent-oriented sanitized content: HTML stripped, HTTP(S) URLs removed, raw Gmail payloads omitted from the JSON. The largest payload-size reduction available here. Note the URL removal is lossy — omit this when you need to follow a link out of the message.'),
|
|
137
226
|
account: accountParam,
|
|
138
|
-
},
|
|
227
|
+
}),
|
|
139
228
|
}, async ({ messageId, format, sanitizeContent, account }) => {
|
|
140
229
|
const args = ['gmail', 'get', messageId];
|
|
141
230
|
if (format) args.push(`--format=${format}`);
|
|
@@ -145,7 +234,9 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
145
234
|
|
|
146
235
|
server.registerTool('gog_gmail_send', {
|
|
147
236
|
description:
|
|
148
|
-
'
|
|
237
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the recipients, subject, and body size. '
|
|
238
|
+
+ 'Mail is sent only after the user accepts that prompt. '
|
|
239
|
+
+ 'Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and '
|
|
149
240
|
+ '`attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on '
|
|
150
241
|
+ 'the same machine gog runs on — on the hosted connector and any remote deployment there is no '
|
|
151
242
|
+ 'shared filesystem, so no path you can name resolves there and `attach` will fail with '
|
|
@@ -155,7 +246,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
155
246
|
+ 'subject, recipients and body are entirely yours, and the original is not quoted unless you set '
|
|
156
247
|
+ 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
|
|
157
248
|
annotations: { destructiveHint: true },
|
|
158
|
-
inputSchema: {
|
|
249
|
+
inputSchema: z.object({
|
|
159
250
|
to: z.string().describe('Recipient(s), comma-separated'),
|
|
160
251
|
subject: z.string().describe('Subject line'),
|
|
161
252
|
body: z.string().describe('Email body (plain text). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
|
|
@@ -167,8 +258,13 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
167
258
|
attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.'),
|
|
168
259
|
attachInline: attachInlineParam,
|
|
169
260
|
account: accountParam,
|
|
170
|
-
},
|
|
171
|
-
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }) => {
|
|
261
|
+
}),
|
|
262
|
+
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }, ctx) => {
|
|
263
|
+
// Built (and validated — inlineAttachmentArgs throws on bad base64 or an
|
|
264
|
+
// oversize file) BEFORE the confirmation request, on both paths: a prompt that
|
|
265
|
+
// skipped this would tell a caller "looks fine, send it" about an
|
|
266
|
+
// attachment that was always going to fail.
|
|
267
|
+
//
|
|
172
268
|
// A long body cannot ride in argv: the hosted runner caps a single arg and
|
|
173
269
|
// Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
|
|
174
270
|
// past the shared threshold; the executor materializes the temp file.
|
|
@@ -188,7 +284,19 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
188
284
|
// file arg once it passes payloadArg's threshold and spends the same budget.
|
|
189
285
|
const inline = inlineAttachmentArgs('attach', attachInline, args);
|
|
190
286
|
args.push(...inline);
|
|
191
|
-
|
|
287
|
+
|
|
288
|
+
const recipients = extractEmails(to, cc, bcc);
|
|
289
|
+
const confirmation = requireGmailDispatchConfirmation(ctx, 'gmail.send', {
|
|
290
|
+
to, cc, bcc, recipients, recipientCount: recipients.length, subject,
|
|
291
|
+
bodyLength: body.length,
|
|
292
|
+
threaded: Boolean(replyToMessageId || threadId),
|
|
293
|
+
quoting: Boolean(quote),
|
|
294
|
+
attachmentCount: (attach?.length ?? 0) + (attachInline?.length ?? 0),
|
|
295
|
+
});
|
|
296
|
+
if (confirmation) return confirmation;
|
|
297
|
+
const result = await runOrDiagnose(args, { account });
|
|
298
|
+
if (!result.isError) logGmailDispatch('gog_gmail_send', recipients, account);
|
|
299
|
+
return result;
|
|
192
300
|
});
|
|
193
301
|
|
|
194
302
|
// ==========================================================================
|
|
@@ -209,7 +317,10 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
209
317
|
// ==========================================================================
|
|
210
318
|
server.registerTool('gog_gmail_reply', {
|
|
211
319
|
description:
|
|
212
|
-
'
|
|
320
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipient, subject, and body size. '
|
|
321
|
+
+ 'Mail is sent only after the user accepts that prompt. To STAGE a reply instead '
|
|
322
|
+
+ 'of sending it, use gog_gmail_drafts_reply (gogcli-mcp-gmail only), which never needs confirmation. '
|
|
323
|
+
+ 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are '
|
|
213
324
|
+ 'answering a message: it threads off the original AND inherits its "Re:" subject and quotes its body below '
|
|
214
325
|
+ 'yours, which gog_gmail_send does not — a send with replyToMessageId lands in the right thread but reads as a '
|
|
215
326
|
+ 'brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. '
|
|
@@ -217,24 +328,26 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
217
328
|
+ 'across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft '
|
|
218
329
|
+ 'instead of sending it.',
|
|
219
330
|
annotations: { destructiveHint: true },
|
|
220
|
-
inputSchema:
|
|
221
|
-
}, async ({ messageId, account, ...flags }) => {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
return runOrDiagnose(args, { account });
|
|
331
|
+
inputSchema: sendReplySchema,
|
|
332
|
+
}, async ({ messageId, account, ...flags }, ctx) => {
|
|
333
|
+
assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
|
|
334
|
+
return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx);
|
|
225
335
|
});
|
|
226
336
|
|
|
227
337
|
server.registerTool('gog_gmail_reply_all', {
|
|
228
338
|
description:
|
|
229
|
-
'
|
|
339
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipients, subject, and body size. '
|
|
340
|
+
+ 'Mail is sent only after the user accepts that prompt. To STAGE a '
|
|
341
|
+
+ 'reply-all instead of sending it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only), which never needs '
|
|
342
|
+
+ 'confirmation. '
|
|
343
|
+
+ 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
|
|
230
344
|
+ 'subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the '
|
|
231
|
-
+ 'reply-all.
|
|
345
|
+
+ 'reply-all.',
|
|
232
346
|
annotations: { destructiveHint: true },
|
|
233
|
-
inputSchema:
|
|
234
|
-
}, async ({ messageId, account, ...flags }) => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return runOrDiagnose(args, { account });
|
|
347
|
+
inputSchema: sendReplySchema,
|
|
348
|
+
}, async ({ messageId, account, ...flags }, ctx) => {
|
|
349
|
+
assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
|
|
350
|
+
return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx);
|
|
238
351
|
});
|
|
239
352
|
|
|
240
353
|
registerRunTool(server, { service: 'gmail', examples: '"archive", "mark-read", "labels"' });
|
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
|
});
|