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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +17105 -18892
  4. package/dist/lib.js +15157 -9562
  5. package/manifest.json +1 -1
  6. package/mint.yaml +2 -2
  7. package/package.json +5 -5
  8. package/server.json +2 -2
  9. package/src/blob-upload.ts +179 -0
  10. package/src/blob-urls.ts +280 -0
  11. package/src/connector-auth.ts +22 -6
  12. package/src/connector-login.ts +87 -0
  13. package/src/connector-runtime.ts +33 -10
  14. package/src/gmail-dispatch-guard.ts +106 -0
  15. package/src/gmail-results.ts +1 -1
  16. package/src/lib.ts +29 -0
  17. package/src/pagination.ts +1 -1
  18. package/src/runner.ts +19 -2
  19. package/src/tools/api.ts +7 -7
  20. package/src/tools/appscript.ts +15 -15
  21. package/src/tools/auth.ts +11 -11
  22. package/src/tools/calendar.ts +13 -13
  23. package/src/tools/chat.ts +25 -25
  24. package/src/tools/classroom.ts +49 -49
  25. package/src/tools/contacts.ts +9 -9
  26. package/src/tools/docs.ts +13 -13
  27. package/src/tools/drive.ts +22 -22
  28. package/src/tools/gmail.ts +136 -23
  29. package/src/tools/sheets.ts +15 -15
  30. package/src/tools/slides.ts +13 -13
  31. package/src/tools/tasks.ts +13 -13
  32. package/src/tools/utils.ts +2 -3
  33. package/src/worker.ts +52 -64
  34. package/tests/blob-upload.test.ts +204 -0
  35. package/tests/blob-urls.test.ts +319 -0
  36. package/tests/connector-login.test.ts +151 -0
  37. package/tests/connector-runtime.test.ts +21 -1
  38. package/tests/gmail-dispatch-guard.test.ts +132 -0
  39. package/tests/runner.test.ts +62 -1
  40. package/tests/sdk-single-copy.test.ts +18 -36
  41. package/tests/tools/appscript.test.ts +1 -1
  42. package/tests/tools/chat.test.ts +1 -1
  43. package/tests/tools/gmail.test.ts +242 -12
  44. package/tests/tools/sheets.test.ts +1 -1
  45. package/tests/zod-single-copy.test.ts +2 -2
  46. package/tsconfig.json +1 -3
  47. package/vitest.config.ts +3 -5
package/src/tools/docs.ts CHANGED
@@ -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,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
  });
@@ -1,5 +1,5 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
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 }));
@@ -1,10 +1,11 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
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
- 'Send an email. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and '
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
- return runOrDiagnose(args, { account });
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
- 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are '
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: replySchema,
221
- }, async ({ messageId, account, ...flags }) => {
222
- const args: GogArg[] = ['gmail', 'reply', messageId];
223
- appendReplyFlags(args, flags);
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
- 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
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. To stage it as a draft rather than send it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only).',
345
+ + 'reply-all.',
232
346
  annotations: { destructiveHint: true },
233
- inputSchema: replySchema,
234
- }, async ({ messageId, account, ...flags }) => {
235
- const args: GogArg[] = ['gmail', 'reply-all', messageId];
236
- appendReplyFlags(args, flags);
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"' });
@@ -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
  });