gogcli-mcp 2.25.0 → 2.27.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.
@@ -0,0 +1,253 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import {
4
+ accountParam,
5
+ runOrDiagnose,
6
+ registerRunTool,
7
+ paginationParams,
8
+ pushPaginationFlags,
9
+ } from './utils.js';
10
+ import type { GogArg } from '../runner.js';
11
+ import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
12
+
13
+ // Google Chat (gog >= 0.38.0 for the mention/reaction metadata in
14
+ // `messages list`; the rest of the surface is older).
15
+ //
16
+ // TWO NAMING SYSTEMS MEET HERE, and mixing them is the mistake this module's
17
+ // descriptions exist to prevent. Chat identifies everything by RESOURCE NAME —
18
+ // `spaces/AAAA`, `spaces/AAAA/messages/BBBB`, `spaces/AAAA/threads/CCCC` — and
19
+ // those names are what every tool below wants. A bare ID (`BBBB`) is accepted
20
+ // for a message only when `space` says which space it lives in, which is why
21
+ // the reaction tools carry that extra parameter.
22
+ //
23
+ // gog_chat_spaces_find / gog_chat_dm_space are the bridges from human words
24
+ // ("the launch room", "alice@example.com") to those names; reach for one of
25
+ // them first rather than guessing a resource name.
26
+ //
27
+ // WORKSPACE-ONLY, AND THAT IS NOT A SCOPE PROBLEM: gog refuses every chat call
28
+ // on a consumer @gmail.com account with "chat requires a Google Workspace
29
+ // account (non-gmail.com)" — verified live against gog 0.38.0 — no matter which
30
+ // scopes the token carries. Re-authorizing cannot fix it, so the note below is
31
+ // appended to every description here: a model that reads "permission error" as
32
+ // "missing scope" would otherwise burn a re-auth round trip on an account that
33
+ // can never work.
34
+ export function registerChatTools(server: McpServer): void {
35
+ const workspaceOnlyNote =
36
+ ' WORKSPACE ONLY: Google Chat has no API for consumer accounts, so this fails on an @gmail.com '
37
+ + 'account with "chat requires a Google Workspace account". That is the ACCOUNT, not the token — '
38
+ + 're-authorizing or adding scopes will not help.';
39
+ const spaceParam = z.string().describe(
40
+ 'Space resource name, e.g. "spaces/AAAAsomeID" (from gog_chat_spaces_list, gog_chat_spaces_find or gog_chat_dm_space)',
41
+ );
42
+ const threadParam = z.string().optional().describe(
43
+ 'Thread resource name, e.g. "spaces/AAAA/threads/CCCC" — reply inside that thread instead of starting a new one',
44
+ );
45
+
46
+ server.registerTool('gog_chat_spaces_list', {
47
+ description:
48
+ 'List the Google Chat spaces the account belongs to — named rooms and DMs alike — with their resource names. '
49
+ + 'Start here when you do not yet have a space name; gog_chat_spaces_find is faster when you know the room\'s title.'
50
+ + workspaceOnlyNote,
51
+ annotations: { readOnlyHint: true },
52
+ inputSchema: {
53
+ ...paginationParams,
54
+ account: accountParam,
55
+ },
56
+ }, async ({ max, pageToken, page, all, account }) => {
57
+ const args = ['chat', 'spaces', 'list'];
58
+ pushPaginationFlags(args, { max, pageToken, page, all });
59
+ return runOrDiagnose(args, { account });
60
+ });
61
+
62
+ server.registerTool('gog_chat_spaces_find', {
63
+ description:
64
+ 'Find spaces whose display name matches. Substring and case-insensitive by default, which is what you want when the '
65
+ + 'user names a room approximately ("the launch room"); pass exact=true to require the whole title. DMs have no '
66
+ + 'display name — use gog_chat_dm_space to reach a person.' + workspaceOnlyNote,
67
+ annotations: { readOnlyHint: true },
68
+ inputSchema: {
69
+ displayName: z.string().describe('Space display name, or part of one'),
70
+ exact: z.boolean().optional().describe('Require an exact (still case-insensitive) match on the whole display name'),
71
+ max: z.number().int().optional().describe('Max results per page'),
72
+ account: accountParam,
73
+ },
74
+ }, async ({ displayName, exact, max, account }) => {
75
+ const args = ['chat', 'spaces', 'find', displayName];
76
+ if (exact) args.push('--exact');
77
+ if (max !== undefined) args.push(`--max=${max}`);
78
+ return runOrDiagnose(args, { account });
79
+ });
80
+
81
+ server.registerTool('gog_chat_spaces_create', {
82
+ description:
83
+ 'Create a named Chat space, optionally seeding its membership. Members are added immediately and are notified — this '
84
+ + 'is visible to other people the moment it runs, so confirm the member list before calling it.' + workspaceOnlyNote,
85
+ inputSchema: {
86
+ displayName: z.string().describe('Display name for the new space'),
87
+ members: z.array(z.string()).optional().describe('Initial members, as email addresses or "users/..." resource names'),
88
+ account: accountParam,
89
+ },
90
+ }, async ({ displayName, members, account }) => {
91
+ const args = ['chat', 'spaces', 'create', displayName];
92
+ if (members) for (const member of members) args.push(`--member=${member}`);
93
+ return runOrDiagnose(args, { account });
94
+ });
95
+
96
+ server.registerTool('gog_chat_threads_list', {
97
+ description:
98
+ 'List the threads in a space, so a reply can be targeted at an existing conversation rather than starting a new one. '
99
+ + 'Pass a thread name from here as `thread` to gog_chat_messages_send.' + workspaceOnlyNote,
100
+ annotations: { readOnlyHint: true },
101
+ inputSchema: {
102
+ space: spaceParam,
103
+ ...paginationParams,
104
+ account: accountParam,
105
+ },
106
+ }, async ({ space, max, pageToken, page, all, account }) => {
107
+ const args = ['chat', 'threads', 'list', space];
108
+ pushPaginationFlags(args, { max, pageToken, page, all });
109
+ return runOrDiagnose(args, { account });
110
+ });
111
+
112
+ server.registerTool('gog_chat_messages_list', {
113
+ description:
114
+ 'Read messages in a space. The JSON carries each message\'s @-mentions and a summary of its emoji reactions '
115
+ + '(gog >= 0.38.0) alongside the text, so "who was tagged" and "did anyone react" are answerable without extra calls. '
116
+ + 'unread=true returns only what arrived after the account last read the space — the cheap way to answer "what did I '
117
+ + 'miss". Newest-first needs an explicit order="createTime desc"; Chat\'s own default is oldest-first.'
118
+ + workspaceOnlyNote,
119
+ annotations: { readOnlyHint: true },
120
+ inputSchema: {
121
+ space: spaceParam,
122
+ thread: threadParam,
123
+ unread: z.boolean().optional().describe('Only messages posted after the account last read this space'),
124
+ order: z.enum(['createTime asc', 'createTime desc', 'lastUpdateTime asc', 'lastUpdateTime desc'])
125
+ .optional().describe('Sort order (Chat default: "createTime asc", i.e. OLDEST first — ask for "createTime desc" when you want the latest messages)'),
126
+ ...paginationParams,
127
+ account: accountParam,
128
+ },
129
+ }, async ({ space, thread, unread, order, max, pageToken, page, all, account }) => {
130
+ const args = ['chat', 'messages', 'list', space];
131
+ if (thread) args.push(`--thread=${thread}`);
132
+ if (unread) args.push('--unread');
133
+ if (order) args.push(`--order=${order}`);
134
+ pushPaginationFlags(args, { max, pageToken, page, all });
135
+ return runOrDiagnose(args, { account });
136
+ });
137
+
138
+ server.registerTool('gog_chat_messages_send', {
139
+ description:
140
+ 'Post a message to a Chat space. THIS IS IMMEDIATELY VISIBLE TO EVERYONE IN THE SPACE and cannot be unsent through '
141
+ + 'this tool, so treat it like sending mail, not like saving a draft. Pass `thread` to reply inside an existing '
142
+ + 'conversation (from gog_chat_threads_list or a message\'s thread field); omit it to start a new one. Text supports '
143
+ + 'Chat\'s markdown-ish formatting (*bold*, _italic_, `code`).' + workspaceOnlyNote,
144
+ inputSchema: {
145
+ space: spaceParam,
146
+ text: z.string().optional().describe('Message text. Optional only when an attachment is supplied.'),
147
+ thread: threadParam,
148
+ attach: z.array(z.string()).optional().describe(
149
+ 'Attachment file paths, read WHERE GOG RUNS. On a hosted or remote deployment that is not your machine — use '
150
+ + 'attachInline there instead.',
151
+ ),
152
+ attachInline: attachInlineParam,
153
+ account: accountParam,
154
+ },
155
+ }, async ({ space, text, thread, attach, attachInline, account }) => {
156
+ if (text === undefined && !attach?.length && !attachInline?.length) {
157
+ throw new Error('A Chat message needs text, an attachment, or both.');
158
+ }
159
+ const args: GogArg[] = ['chat', 'messages', 'send', space];
160
+ if (text !== undefined) args.push(`--text=${text}`);
161
+ if (thread) args.push(`--thread=${thread}`);
162
+ if (attach) for (const path of attach) args.push(`--attach=${path}`);
163
+ // Same repeatable --attach flag; the executor materializes each payload to
164
+ // a temp file beside gog. `args` is passed so the size check sees the whole
165
+ // request, not just the attachments.
166
+ args.push(...inlineAttachmentArgs('attach', attachInline, args));
167
+ return runOrDiagnose(args, { account });
168
+ });
169
+
170
+ server.registerTool('gog_chat_dm_send', {
171
+ description:
172
+ 'Send a direct message to one person by email address, creating the DM space if this is the first message. Delivered '
173
+ + 'immediately and cannot be unsent through this tool. For a room rather than a person, use gog_chat_messages_send.'
174
+ + workspaceOnlyNote,
175
+ inputSchema: {
176
+ email: z.string().describe('Recipient email address'),
177
+ text: z.string().describe('Message text'),
178
+ thread: threadParam,
179
+ account: accountParam,
180
+ },
181
+ }, async ({ email, text, thread, account }) => {
182
+ const args = ['chat', 'dm', 'send', email, `--text=${text}`];
183
+ if (thread) args.push(`--thread=${thread}`);
184
+ return runOrDiagnose(args, { account });
185
+ });
186
+
187
+ server.registerTool('gog_chat_dm_space', {
188
+ description:
189
+ 'Resolve the DM space for an email address — the bridge from a person to the "spaces/..." name the message tools '
190
+ + 'want. Creates the space if none exists yet, which is silent: it does not message the person.' + workspaceOnlyNote,
191
+ inputSchema: {
192
+ email: z.string().describe('The other person\'s email address'),
193
+ account: accountParam,
194
+ },
195
+ }, async ({ email, account }) => {
196
+ return runOrDiagnose(['chat', 'dm', 'space', email], { account });
197
+ });
198
+
199
+ server.registerTool('gog_chat_reactions_list', {
200
+ description:
201
+ 'List the emoji reactions on one message, with who reacted. gog_chat_messages_list already returns a reaction '
202
+ + 'SUMMARY per message; come here when you need the individual reactors, or the reaction resource names that '
203
+ + 'gog_chat_reactions_delete takes.' + workspaceOnlyNote,
204
+ annotations: { readOnlyHint: true },
205
+ inputSchema: {
206
+ message: z.string().describe('Message resource name ("spaces/AAAA/messages/BBBB"), or a bare message ID together with `space`'),
207
+ space: z.string().optional().describe('Space resource name — required only when `message` is a bare ID'),
208
+ ...paginationParams,
209
+ account: accountParam,
210
+ },
211
+ }, async ({ message, space, max, pageToken, page, all, account }) => {
212
+ const args = ['chat', 'messages', 'reactions', 'list', message];
213
+ if (space) args.push(`--space=${space}`);
214
+ pushPaginationFlags(args, { max, pageToken, page, all });
215
+ return runOrDiagnose(args, { account });
216
+ });
217
+
218
+ server.registerTool('gog_chat_reactions_create', {
219
+ description:
220
+ 'React to a message with an emoji. Visible to the space immediately. Pass the emoji itself ("👍"), not a :shortcode:.'
221
+ + workspaceOnlyNote,
222
+ inputSchema: {
223
+ message: z.string().describe('Message resource name ("spaces/AAAA/messages/BBBB"), or a bare message ID together with `space`'),
224
+ emoji: z.string().describe('The emoji character to react with, e.g. "👍"'),
225
+ space: z.string().optional().describe('Space resource name — required only when `message` is a bare ID'),
226
+ account: accountParam,
227
+ },
228
+ }, async ({ message, emoji, space, account }) => {
229
+ const args = ['chat', 'messages', 'reactions', 'create', message, emoji];
230
+ if (space) args.push(`--space=${space}`);
231
+ return runOrDiagnose(args, { account });
232
+ });
233
+
234
+ server.registerTool('gog_chat_reactions_delete', {
235
+ description:
236
+ 'Remove one emoji reaction. Takes the REACTION\'s own resource name ("spaces/.../messages/.../reactions/..."), not '
237
+ + 'the message\'s and not the emoji — get it from gog_chat_reactions_list. An account can only remove its own reaction.'
238
+ + workspaceOnlyNote,
239
+ annotations: { destructiveHint: true },
240
+ inputSchema: {
241
+ reaction: z.string().describe('Reaction resource name, e.g. "spaces/AAAA/messages/BBBB/reactions/CCCC"'),
242
+ account: accountParam,
243
+ },
244
+ }, async ({ reaction, account }) => {
245
+ return runOrDiagnose(['chat', 'messages', 'reactions', 'delete', reaction], { account });
246
+ });
247
+
248
+ registerRunTool(server, {
249
+ service: 'chat',
250
+ examples: '"spaces", "messages", "dm"',
251
+ note: 'Google Chat has no API for consumer accounts: every chat subcommand fails on an @gmail.com account regardless of scopes.',
252
+ });
253
+ }
@@ -1,9 +1,86 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
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
+ import type { InlineAttachmentInput } from '../attachments.js';
8
+
9
+ // gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
10
+ // only in the subcommand and default recipient set (reply → sender; reply-all
11
+ // → every participant). Recipient flags are repeatable on the CLI, so they are
12
+ // arrays here. --to/--cc/--bcc ADD or MOVE recipients onto the inherited reply
13
+ // set; --remove drops them. Body/HTML follow the same inline-or-file shape as
14
+ // the draft tools.
15
+ export const replySchema = {
16
+ messageId: z.string().describe('Gmail message ID to reply to — the short hex `id` from gog_gmail_get / _search (or gog_gmail_messages_search, gogcli-mcp-gmail only). NOT the threadId, NOT the RFC822 `<…@host>` Message-Id header.'),
17
+ body: z.string().optional().describe('Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). 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.'),
18
+ bodyHtml: z.string().optional().describe('Reply body (HTML; optional). Pass the HTML itself at any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile.'),
19
+ bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server for the reply body. gog also accepts "-" for stdin, but this server never writes to gog\'s stdin, so "-" would hang until the call times out. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
20
+ to: z.array(z.string()).optional().describe('Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message.'),
21
+ cc: z.array(z.string()).optional().describe('Add or move recipients to Cc (repeatable)'),
22
+ bcc: z.array(z.string()).optional().describe('Add or move recipients to Bcc (repeatable)'),
23
+ remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
24
+ subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
25
+ 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
+ attachInline: attachInlineParam,
28
+ from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
29
+ 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.'),
30
+ signature: z.boolean().optional().describe('Append the Gmail signature from the active send-as address'),
31
+ signatureFrom: z.string().optional().describe('Append the Gmail signature from this send-as email address'),
32
+ signatureFile: z.string().optional().describe('Append a local signature file (plain text or HTML), read on the gog server'),
33
+ account: accountParam,
34
+ };
35
+
36
+ export type ReplyFlags = {
37
+ body?: string;
38
+ bodyHtml?: string;
39
+ bodyHtmlFile?: string;
40
+ to?: string[];
41
+ cc?: string[];
42
+ bcc?: string[];
43
+ remove?: string[];
44
+ subject?: string;
45
+ noQuote?: boolean;
46
+ attach?: string[];
47
+ attachInline?: InlineAttachmentInput[];
48
+ from?: string;
49
+ autoFromAddressedAlias?: boolean;
50
+ signature?: boolean;
51
+ signatureFrom?: string;
52
+ signatureFile?: string;
53
+ };
54
+
55
+ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
56
+ assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
57
+ if (f.body) args.push(payloadArg('body', 'body-file', f.body));
58
+ if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
59
+ else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
60
+ if (f.to) for (const r of f.to) args.push(`--to=${r}`);
61
+ if (f.cc) for (const r of f.cc) args.push(`--cc=${r}`);
62
+ if (f.bcc) for (const r of f.bcc) args.push(`--bcc=${r}`);
63
+ if (f.remove) for (const r of f.remove) args.push(`--remove=${r}`);
64
+ if (f.subject) args.push(`--subject=${f.subject}`);
65
+ if (f.noQuote) args.push('--no-quote');
66
+ if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
67
+ // 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
+ // 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
+ // passed so the size check sees the body too, which shares the same budget
72
+ // once payloadArg has turned it into a file arg.
73
+ args.push(...inlineAttachmentArgs('attach', f.attachInline, args));
74
+ if (f.from) args.push(`--from=${f.from}`);
75
+ if (f.signature) args.push('--signature');
76
+ if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
77
+ if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
78
+ // PINNED, not conditional: GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS in the host env
79
+ // 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.
82
+ args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false');
83
+ }
7
84
 
8
85
  export function registerGmailTools(server: McpServer): void {
9
86
  server.registerTool('gog_gmail_search', {
@@ -73,7 +150,10 @@ export function registerGmailTools(server: McpServer): void {
73
150
  + 'the same machine gog runs on — on the hosted connector and any remote deployment there is no '
74
151
  + 'shared filesystem, so no path you can name resolves there and `attach` will fail with '
75
152
  + '"no such file or directory". When either is used, the JSON result echoes the attached filenames '
76
- + 'and byte sizes — check it to confirm the files were embedded.',
153
+ + 'and byte sizes — check it to confirm the files were embedded. '
154
+ + 'NOT the tool for answering a message: replyToMessageId only files this in the right thread — the '
155
+ + 'subject, recipients and body are entirely yours, and the original is not quoted unless you set '
156
+ + 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
77
157
  annotations: { destructiveHint: true },
78
158
  inputSchema: {
79
159
  to: z.string().describe('Recipient(s), comma-separated'),
@@ -81,13 +161,14 @@ export function registerGmailTools(server: McpServer): void {
81
161
  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.'),
82
162
  cc: z.string().optional().describe('CC recipients, comma-separated'),
83
163
  bcc: z.string().optional().describe('BCC recipients, comma-separated'),
84
- replyToMessageId: z.string().optional().describe('Message ID to reply to'),
85
- threadId: z.string().optional().describe('Thread ID to reply within'),
164
+ 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
+ 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
+ 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.'),
86
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.'),
87
168
  attachInline: attachInlineParam,
88
169
  account: accountParam,
89
170
  },
90
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
171
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }) => {
91
172
  // A long body cannot ride in argv: the hosted runner caps a single arg and
92
173
  // Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
93
174
  // past the shared threshold; the executor materializes the temp file.
@@ -96,6 +177,10 @@ export function registerGmailTools(server: McpServer): void {
96
177
  if (bcc) args.push(`--bcc=${bcc}`);
97
178
  if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
98
179
  if (threadId) args.push(`--thread-id=${threadId}`);
180
+ // gog's --quote on `gmail send` is opt-in (a plain bool defaulting false),
181
+ // the mirror image of `gmail reply`, where quoting is the default and
182
+ // --no-quote opts out. Nothing here can be inferred from the reply target.
183
+ if (quote) args.push('--quote');
99
184
  if (attach) for (const path of attach) args.push(`--attach=${path}`);
100
185
  // Same repeatable --attach flag; the executor materializes each payload to a
101
186
  // temp file beside gog and substitutes its path. `args` is passed so the
@@ -106,5 +191,51 @@ export function registerGmailTools(server: McpServer): void {
106
191
  return runOrDiagnose(args, { account });
107
192
  });
108
193
 
194
+ // ==========================================================================
195
+ // REPLY / REPLY-ALL
196
+ //
197
+ // These live here, in the base package, because gog_gmail_send +
198
+ // replyToMessageId is NOT a reply. It sets In-Reply-To/References — so Gmail
199
+ // files it in the right thread — and stops there: no quoted original, no
200
+ // inherited "Re:" subject, no inherited recipients. To anyone reading the
201
+ // body it arrives as a brand-new message.
202
+ //
203
+ // The asymmetry is gog's: `gmail reply` quotes BY DEFAULT (opt out with
204
+ // --no-quote), while `gmail send` quotes only on an explicit --quote
205
+ // (internal/cmd/gmail_send.go, a plain bool defaulting false). The gmail
206
+ // sub-package reuses replySchema/appendReplyFlags for its draft-side twins
207
+ // rather than declaring a second copy — registering these tools twice in the
208
+ // one server would be a duplicate-name error.
209
+ // ==========================================================================
210
+ server.registerTool('gog_gmail_reply', {
211
+ description:
212
+ 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are '
213
+ + 'answering a message: it threads off the original AND inherits its "Re:" subject and quotes its body below '
214
+ + 'yours, which gog_gmail_send does not — a send with replyToMessageId lands in the right thread but reads as a '
215
+ + 'brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. '
216
+ + 'The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply '
217
+ + 'across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft '
218
+ + 'instead of sending it.',
219
+ 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 });
225
+ });
226
+
227
+ server.registerTool('gog_gmail_reply_all', {
228
+ description:
229
+ 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
230
+ + '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).',
232
+ 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 });
238
+ });
239
+
109
240
  registerRunTool(server, { service: 'gmail', examples: '"archive", "mark-read", "labels"' });
110
241
  }
@@ -441,3 +441,23 @@ export function formatAuthHealth(raw: string, now: number): string {
441
441
  }
442
442
  return accounts.map((a) => formatOneAccountHealth(a, now)).join('\n\n');
443
443
  }
444
+
445
+ // gog rejects an inline flag together with its --*-file twin — `gmail drafts
446
+ // create` errors with "use only one of --body-html or --body-html-file", and
447
+ // `gmail forward` does the same for --note (misreporting it as --body). Catch
448
+ // the conflict here so the caller gets a message naming the TOOL params it
449
+ // actually passed, instead of a gog error naming flags it never saw.
450
+ export function assertNotBoth(
451
+ inlineParam: string,
452
+ fileParam: string,
453
+ inlineValue: string | undefined,
454
+ fileValue: string | undefined,
455
+ ): void {
456
+ if (inlineValue !== undefined && fileValue !== undefined) {
457
+ throw new Error(
458
+ `${inlineParam} and ${fileParam} are mutually exclusive — gog accepts only one of them. ` +
459
+ `Pass ${inlineParam} with the content itself (it is written to a temp file automatically when large), ` +
460
+ `or ${fileParam} with a path that already exists on the gog server.`,
461
+ );
462
+ }
463
+ }
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.25.0'; // x-release-please-version
41
+ const VERSION = '2.27.0'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.
@@ -255,6 +255,53 @@ describe('fetchGmailPages', () => {
255
255
  expect(runPage).toHaveBeenCalledTimes(2);
256
256
  });
257
257
 
258
+ // A REPEATED CURSOR IS A STALL, and gog 0.38.0 fixed the same class on its own
259
+ // side (openclaw/gogcli#1004) — but that fix covers gog's `--all`, and this
260
+ // walk is our own: it makes N separate single-page gog calls, so a cursor
261
+ // Google repeats comes straight back to this loop. Without a guard the walk
262
+ // re-fetches the same page and merges its items again on every remaining
263
+ // iteration, so the caller gets DUPLICATES presented as more results, which is
264
+ // worse than a short answer because nothing about the payload looks wrong.
265
+ it('stops when Google repeats a cursor, instead of re-fetching the same page', async () => {
266
+ const runPage = vi.fn()
267
+ .mockResolvedValueOnce(page(['a'], 'T1'))
268
+ .mockResolvedValueOnce(page(['b'], 'T1'));
269
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
270
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b']);
271
+ // Stopped after the repeat was seen — NOT after burning all 10 pages.
272
+ expect(runPage).toHaveBeenCalledTimes(2);
273
+ });
274
+
275
+ it('still reads as truncated after a repeated cursor', async () => {
276
+ const runPage = vi.fn()
277
+ .mockResolvedValueOnce(page(['a'], 'T1'))
278
+ .mockResolvedValueOnce(page(['b'], 'T1'));
279
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
280
+ // The walk could not reach the end, so the cursor STAYS. Dropping it would
281
+ // claim completeness we never established, and a set that reads complete is
282
+ // exactly how "that email doesn't exist" gets reported about mail that does.
283
+ expect(out.nextPageToken).toBe('T1');
284
+ });
285
+
286
+ it('detects a cursor that repeats the caller-supplied one', async () => {
287
+ const runPage = vi.fn().mockResolvedValueOnce(page(['a'], 'START'));
288
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, 'START')).content[0].text as string);
289
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a']);
290
+ expect(out.nextPageToken).toBe('START');
291
+ expect(runPage).toHaveBeenCalledTimes(1);
292
+ });
293
+
294
+ it('detects a cursor that repeats a token from earlier in the walk, not just the previous one', async () => {
295
+ const runPage = vi.fn()
296
+ .mockResolvedValueOnce(page(['a'], 'T1'))
297
+ .mockResolvedValueOnce(page(['b'], 'T2'))
298
+ .mockResolvedValueOnce(page(['c'], 'T1'));
299
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
300
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'c']);
301
+ expect(out.nextPageToken).toBe('T1');
302
+ expect(runPage).toHaveBeenCalledTimes(3);
303
+ });
304
+
258
305
  it('starts from a caller-supplied cursor', async () => {
259
306
  const runPage = vi.fn().mockResolvedValue(page(['a']));
260
307
  await fetchGmailPages(runPage, 'threads', 3, 'START');
@@ -14,8 +14,10 @@ describe('BASE_TOOL_REGISTRARS', () => {
14
14
  // One representative tool per service registrar, in registrar order.
15
15
  for (const expected of [
16
16
  'gog_api_list',
17
+ 'gog_appscript_get',
17
18
  'gog_auth_list',
18
19
  'gog_calendar_events',
20
+ 'gog_chat_spaces_list',
19
21
  'gog_classroom_courses_list',
20
22
  'gog_contacts_list',
21
23
  'gog_docs_cat',
@@ -41,3 +43,62 @@ describe('VERSION', () => {
41
43
  expect(VERSION).toBe('0.0.0');
42
44
  });
43
45
  });
46
+
47
+ // The tool counts in README.md, SKILL.md and manifest.json are hand-maintained
48
+ // and have now drifted twice (0ec3470 "correct the stale tool counts", then
49
+ // again when reply/reply-all landed). They are the first thing a reader sees,
50
+ // so derive the truth from the registrars and fail the build on a mismatch
51
+ // rather than catching it in review a release later.
52
+ describe('published tool counts match the registrars', () => {
53
+ const readPkgFile = async (name: string) => {
54
+ const { readFile } = await import('node:fs/promises');
55
+ const { fileURLToPath } = await import('node:url');
56
+ return readFile(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
57
+ };
58
+
59
+ const liveToolNames = async (): Promise<string[]> => {
60
+ const harness = await createTestHarness((server) => {
61
+ for (const register of BASE_TOOL_REGISTRARS) register(server, undefined);
62
+ });
63
+ const names = (await harness.listTools()).map((t) => t.name);
64
+ await harness.close();
65
+ return names;
66
+ };
67
+
68
+ it('manifest.json lists exactly the registered tools', async () => {
69
+ const names = await liveToolNames();
70
+ const manifest = JSON.parse(await readPkgFile('manifest.json')) as { tools: Array<{ name: string }> };
71
+ expect([...manifest.tools.map((t) => t.name)].sort()).toEqual([...names].sort());
72
+ });
73
+
74
+ it.each(['README.md', 'SKILL.md'])('%s states the real total', async (file) => {
75
+ const total = (await liveToolNames()).length;
76
+ const text = await readPkgFile(file);
77
+ const heading = /^## Tools \((\d+)\)$/m.exec(text);
78
+ expect(heading, `${file} has no "## Tools (N)" heading`).not.toBeNull();
79
+ expect(Number(heading![1])).toBe(total);
80
+ // README also states the count in its opening paragraph.
81
+ const prose = /Includes (\d+) tools across/.exec(text);
82
+ if (prose) expect(Number(prose[1])).toBe(total);
83
+ });
84
+
85
+ it.each(['README.md', 'SKILL.md'])('%s states the real per-service counts', async (file) => {
86
+ const names = await liveToolNames();
87
+ const text = await readPkgFile(file);
88
+ // README: `| **Gmail** | 6 | …` SKILL: `| **Gmail** (6) | …`
89
+ const rows = [...text.matchAll(/^\| \*\*(.+?)\*\*(?: \((\d+)\)| \| (\d+))? \|/gm)];
90
+ expect(rows.length).toBeGreaterThan(0);
91
+ const service = (row: string) => row.toLowerCase().replace(/[^a-z]/g, '');
92
+ // Map a table label onto the gog_<service>_ prefix its tools carry.
93
+ const prefixFor: Record<string, string> = {
94
+ appsscript: 'appscript', discoveryapi: 'api',
95
+ };
96
+ for (const row of rows) {
97
+ const stated = Number(row[2] ?? row[3]);
98
+ if (!Number.isFinite(stated)) continue;
99
+ const key = service(row[1]);
100
+ const prefix = `gog_${prefixFor[key] ?? key}_`;
101
+ expect(names.filter((n) => n.startsWith(prefix)).length, `${file} row ${row[1]}`).toBe(stated);
102
+ }
103
+ });
104
+ });