gogcli-mcp 2.30.0 → 4.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 (64) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +15345 -18417
  4. package/dist/lib.js +13720 -9481
  5. package/manifest.json +2 -2
  6. package/mint.yaml +37 -33
  7. package/package.json +5 -5
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/gmail-dispatch-guard.ts +106 -0
  14. package/src/gmail-results.ts +1 -1
  15. package/src/index.ts +3 -4
  16. package/src/lib.ts +24 -9
  17. package/src/pagination.ts +1 -1
  18. package/src/runner.ts +23 -175
  19. package/src/tools/api.ts +7 -7
  20. package/src/tools/appscript.ts +16 -16
  21. package/src/tools/auth.ts +16 -16
  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 +23 -25
  28. package/src/tools/gmail.ts +145 -32
  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 +14 -61
  33. package/tests/attachments.test.ts +11 -14
  34. package/tests/blob-upload.test.ts +235 -160
  35. package/tests/bootstrap-auth.test.ts +245 -0
  36. package/tests/gmail-dispatch-guard.test.ts +132 -0
  37. package/tests/runner-file-args.test.ts +1 -13
  38. package/tests/runner.test.ts +8 -95
  39. package/tests/sdk-single-copy.test.ts +11 -37
  40. package/tests/tools/appscript.test.ts +1 -1
  41. package/tests/tools/auth-401-shapes.test.ts +2 -3
  42. package/tests/tools/auth.test.ts +5 -4
  43. package/tests/tools/chat.test.ts +1 -1
  44. package/tests/tools/drive.test.ts +11 -3
  45. package/tests/tools/gmail.test.ts +244 -14
  46. package/tests/tools/sheets.test.ts +1 -1
  47. package/tests/tools/utils.test.ts +1 -50
  48. package/tests/zod-single-copy.test.ts +8 -16
  49. package/tsconfig.json +1 -4
  50. package/vitest.config.ts +2 -14
  51. package/src/auth-log.ts +0 -205
  52. package/src/connector-auth.ts +0 -303
  53. package/src/connector-runtime.ts +0 -887
  54. package/src/google-probe.ts +0 -113
  55. package/src/google-token.ts +0 -391
  56. package/src/remote-runner.ts +0 -77
  57. package/src/worker.ts +0 -129
  58. package/tests/auth-log.test.ts +0 -530
  59. package/tests/connector-auth.test.ts +0 -559
  60. package/tests/connector-runtime.test.ts +0 -1644
  61. package/tests/google-probe.test.ts +0 -116
  62. package/tests/google-token.test.ts +0 -425
  63. package/tests/remote-runner.test.ts +0 -202
  64. package/tests/worker.test.ts +0 -167
@@ -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 {
@@ -251,14 +251,12 @@ export function registerDriveTools(server: McpServer): void {
251
251
  description:
252
252
  'Fetch a Drive file\'s raw bytes and return them base64-encoded as an embedded resource — the ' +
253
253
  'generic fallback for callers that want the file itself (to parse locally) rather than extracted ' +
254
- 'text. For readable text from a PDF, prefer gog_drive_extract_text. NOTE: only works on the local ' +
255
- 'stdio server; over the hosted connector the transport is text-only and this returns a clear error ' +
256
- '(use gog_drive_extract_text there).',
254
+ 'text. For readable text from a PDF, prefer gog_drive_extract_text.',
257
255
  annotations: { readOnlyHint: true },
258
- inputSchema: {
256
+ inputSchema: z.object({
259
257
  fileId: z.string().describe('Drive file ID'),
260
258
  account: accountParam,
261
- },
259
+ }),
262
260
  }, async ({ fileId, account }): Promise<CallToolResult> => {
263
261
  try {
264
262
  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
@@ -23,7 +24,7 @@ export const replySchema = {
23
24
  remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
24
25
  subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
25
26
  noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
26
- 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. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
27
+ 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 a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
27
28
  attachInline: attachInlineParam,
28
29
  from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
29
30
  autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
@@ -65,9 +66,9 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
65
66
  if (f.noQuote) args.push('--no-quote');
66
67
  if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
67
68
  // Same repeatable --attach flag, but the bytes travel with the call: the
68
- // executor writes each one to a temp file beside gog and passes that path.
69
+ // runner writes each one to a temp file beside gog and passes that path.
69
70
  // This is the only attachment route that works when the caller and gog do not
70
- // share a filesystem (hosted connector, GOG_RUNNER_URL backend). `args` is
71
+ // share a filesystem (a hosted deployment such as mcp-host). `args` is
71
72
  // passed so the size check sees the body too, which shares the same budget
72
73
  // once payloadArg has turned it into a file arg.
73
74
  args.push(...inlineAttachmentArgs('attach', f.attachInline, args));
@@ -77,11 +78,99 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
77
78
  if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
78
79
  // PINNED, not conditional: GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS in the host env
79
80
  // silently changes which address the mail goes out FROM, with nothing in the arg
80
- // array to show for it — and the remote runner's backend env is not ours to set.
81
- // An explicit flag is the only value authoritative on both transports.
81
+ // array to show for it — and a hosted deployment's env is not the caller's to set.
82
+ // An explicit flag is the only value authoritative everywhere.
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.'),
@@ -164,14 +255,19 @@ export function registerGmailTools(server: McpServer): void {
164
255
  replyToMessageId: z.string().optional().describe('Message ID to thread this message against — sets In-Reply-To/References only. It does NOT quote the original (pass quote for that), inherit its recipients, or prefix the subject with "Re:". For an actual reply use gog_gmail_reply.'),
165
256
  threadId: z.string().optional().describe('Thread ID to thread this message within. Same caveat as replyToMessageId: threading only, no quote and no inherited subject or recipients.'),
166
257
  quote: z.boolean().optional().describe('Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it.'),
167
- 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.'),
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 a hosted deployment (e.g. mcp-host) 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 }) => {
172
- // A long body cannot ride in argv: the hosted runner caps a single arg and
173
- // Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
174
- // past the shared threshold; the executor materializes the temp file.
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
+ //
268
+ // A long body cannot ride in argv: Linux caps MAX_ARG_STRLEN at 128 KiB.
269
+ // payloadArg swaps it for --body-file past the shared threshold; the runner
270
+ // materializes the temp file.
175
271
  const args: GogArg[] = ['gmail', 'send', `--to=${to}`, `--subject=${subject}`, payloadArg('body', 'body-file', body)];
176
272
  if (cc) args.push(`--cc=${cc}`);
177
273
  if (bcc) args.push(`--bcc=${bcc}`);
@@ -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
  });