mcp-google-multi 5.2.0-alpha.5 → 5.2.0-alpha.6

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.
@@ -14,6 +14,20 @@ export declare function encodeAddressHeader(value: string): string;
14
14
  * Normalize bare `\n` or `\r` to CRLF.
15
15
  */
16
16
  export declare function normalizeBodyLineEndings(body: string): string;
17
+ /**
18
+ * Best-effort HTML→plain-text for reading HTML-only emails. Regex-based on
19
+ * purpose — no HTML parser dependency, and mail HTML is flat enough for it.
20
+ */
21
+ export declare function htmlToText(html: string): string;
22
+ /**
23
+ * RFC 5322 threading: In-Reply-To/References must carry the parent's real
24
+ * Message-ID header, not the Gmail API id. Falls back to the API id when the
25
+ * header is missing so replies still thread inside Gmail.
26
+ */
27
+ export declare function buildReplyHeaders(fallbackId: string, parentMessageIdHeader: string, parentReferences: string): {
28
+ inReplyTo: string;
29
+ references: string;
30
+ };
17
31
  /**
18
32
  * Build a multipart/alternative body so HTML-capable clients render the rich
19
33
  * version and plain clients fall back. Returns the header value AND the body.
@@ -63,6 +63,95 @@ export function encodeAddressHeader(value) {
63
63
  export function normalizeBodyLineEndings(body) {
64
64
  return body.replace(/\r\n|\r|\n/g, '\r\n');
65
65
  }
66
+ const NAMED_ENTITIES = {
67
+ amp: '&',
68
+ lt: '<',
69
+ gt: '>',
70
+ quot: '"',
71
+ apos: "'",
72
+ nbsp: ' ',
73
+ };
74
+ function codePointToChar(code, fallback) {
75
+ if (Number.isNaN(code) || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
76
+ return fallback;
77
+ return String.fromCodePoint(code);
78
+ }
79
+ function decodeEntities(text) {
80
+ return text.replace(/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z][a-zA-Z0-9]*));/g, (match, dec, hex, named) => {
81
+ if (dec)
82
+ return codePointToChar(parseInt(dec, 10), match);
83
+ if (hex)
84
+ return codePointToChar(parseInt(hex, 16), match);
85
+ return NAMED_ENTITIES[named.toLowerCase()] ?? match;
86
+ });
87
+ }
88
+ // Fixpoint tag strip: one pass can reassemble a tag from a removed span's
89
+ // edges (<scr<b>ipt> -> <script>).
90
+ function stripTags(input, replacement = '') {
91
+ let out = input;
92
+ for (let previous = ''; previous !== out;) {
93
+ previous = out;
94
+ out = out.replace(/<[^>]*>/g, replacement);
95
+ }
96
+ return out;
97
+ }
98
+ /**
99
+ * Best-effort HTML→plain-text for reading HTML-only emails. Regex-based on
100
+ * purpose — no HTML parser dependency, and mail HTML is flat enough for it.
101
+ */
102
+ export function htmlToText(html) {
103
+ // Repeat until stable: single-pass removal can leave behind sequences
104
+ // reassembled from the removed span's edges (<scr<script>ipt>).
105
+ let text = html;
106
+ for (let previous = ''; previous !== text;) {
107
+ previous = text;
108
+ text = text
109
+ // --!> is a valid comment terminator per WHATWG
110
+ .replace(/<!--[\s\S]*?--!?>/g, '')
111
+ // an unterminated comment consumes the rest of the document per spec
112
+ .replace(/<!--[\s\S]*$/, '')
113
+ .replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
114
+ }
115
+ // All comment content is gone; drop any stray bare delimiters too.
116
+ text = text.replace(/<!--|--!?>/g, '');
117
+ // Source whitespace (incl. newlines) is insignificant in HTML; real line
118
+ // structure is reintroduced from block tags below.
119
+ text = text.replace(/\s+/g, ' ');
120
+ text = text.replace(/<a\b[^>]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a\s*>/gi, (_match, dq, sq, inner) => {
121
+ const href = (dq ?? sq ?? '').trim();
122
+ const innerText = stripTags(inner, ' ').replace(/\s+/g, ' ').trim();
123
+ const redundant = href === '' ||
124
+ href.startsWith('#') ||
125
+ href === innerText ||
126
+ href === `mailto:${innerText}`;
127
+ return redundant ? inner : `${inner} (${href})`;
128
+ });
129
+ text = text
130
+ .replace(/<(?:br|hr)\b[^>]*>/gi, '\n')
131
+ .replace(/<\/t[dh]\s*>/gi, ' ')
132
+ .replace(/<(?:p|h[1-6])\b[^>]*>/gi, '\n')
133
+ .replace(/<\/(?:p|div|h[1-6]|li|tr|table|ul|ol|blockquote|pre|section|article|header|footer|address|figure|dl|dt|dd)\s*>/gi, '\n');
134
+ text = stripTags(text);
135
+ return decodeEntities(text)
136
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
137
+ .replace(/\n{3,}/g, '\n\n')
138
+ .trim();
139
+ }
140
+ /**
141
+ * RFC 5322 threading: In-Reply-To/References must carry the parent's real
142
+ * Message-ID header, not the Gmail API id. Falls back to the API id when the
143
+ * header is missing so replies still thread inside Gmail.
144
+ */
145
+ export function buildReplyHeaders(fallbackId, parentMessageIdHeader, parentReferences) {
146
+ const parentId = parentMessageIdHeader.trim();
147
+ if (parentId === '')
148
+ return { inReplyTo: fallbackId, references: fallbackId };
149
+ const refs = parentReferences.trim().replace(/\s+/g, ' ');
150
+ return {
151
+ inReplyTo: parentId,
152
+ references: refs === '' ? parentId : `${refs} ${parentId}`,
153
+ };
154
+ }
66
155
  /**
67
156
  * RFC 2046 §5.1.1 boundary token: 1-70 chars from a restricted set, no trailing space.
68
157
  * randomBytes hex output is only [0-9a-f], all of which are bcharsnospace.
@@ -1,4 +1,6 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
2
  import type { GmailMessageFull } from '../types.js';
3
- export declare function parseMessage(msg: any, bodyCap?: number): GmailMessageFull;
3
+ export declare function parseMessage(msg: any, bodyCap?: number, opts?: {
4
+ rawHtml?: boolean;
5
+ }): GmailMessageFull;
4
6
  export declare function registerGmailTools(server: ToolRegistry): void;
@@ -4,7 +4,7 @@ import { google } from 'googleapis';
4
4
  import { ACCOUNTS } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError } from './_errors.js';
7
- import { buildMultipartAlternative, encodeAddressHeader, encodeHeaderValue, normalizeBodyLineEndings } from './gmail-mime.js';
7
+ import { buildMultipartAlternative, buildReplyHeaders, encodeAddressHeader, encodeHeaderValue, htmlToText, normalizeBodyLineEndings } from './gmail-mime.js';
8
8
  import { sliceClean } from '../trim.js';
9
9
  import * as path from 'path';
10
10
  import * as fs from 'fs';
@@ -12,38 +12,50 @@ const accountEnum = z.enum(ACCOUNTS);
12
12
  function getHeader(headers, name) {
13
13
  return headers?.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
14
14
  }
15
- function decodeBody(payload) {
16
- if (payload.body?.data) {
17
- return Buffer.from(payload.body.data, 'base64url').toString('utf-8');
15
+ function collectTextParts(part, plain, html, topLevel = false) {
16
+ if (!part)
17
+ return;
18
+ const isAttachment = Boolean(part.filename) || Boolean(part.body?.attachmentId);
19
+ if (!isAttachment && part.body?.data) {
20
+ const decoded = Buffer.from(part.body.data, 'base64url').toString('utf-8');
21
+ if (part.mimeType === 'text/html')
22
+ html.push(decoded);
23
+ // Simple (non-multipart) messages can carry any mimeType at the top level;
24
+ // returning their raw body preserves pre-collect behavior.
25
+ else if (part.mimeType === 'text/plain' || !part.mimeType || topLevel)
26
+ plain.push(decoded);
18
27
  }
19
- if (payload.parts) {
20
- for (const part of payload.parts) {
21
- if (part.mimeType === 'text/plain' && part.body?.data) {
22
- return Buffer.from(part.body.data, 'base64url').toString('utf-8');
23
- }
24
- }
25
- for (const part of payload.parts) {
26
- if (part.mimeType === 'text/html' && part.body?.data) {
27
- return Buffer.from(part.body.data, 'base64url').toString('utf-8');
28
- }
29
- }
30
- for (const part of payload.parts) {
31
- if (part.parts) {
32
- const result = decodeBody(part);
33
- if (result)
34
- return result;
35
- }
36
- }
28
+ for (const child of part.parts ?? []) {
29
+ collectTextParts(child, plain, html);
37
30
  }
38
- return '';
31
+ }
32
+ function decodeBody(payload, rawHtml = false) {
33
+ const plain = [];
34
+ const html = [];
35
+ collectTextParts(payload, plain, html, true);
36
+ // Concatenating every plain leaf keeps forwarded/mixed messages whole;
37
+ // any plain content beats HTML because alternatives duplicate the same body.
38
+ if (plain.length > 0)
39
+ return { body: plain.join('\n\n'), bodyOrigin: 'text/plain' };
40
+ if (html.length > 0) {
41
+ const joined = html.join('\n\n');
42
+ return { body: rawHtml ? joined : htmlToText(joined), bodyOrigin: 'text/html' };
43
+ }
44
+ return { body: '' };
39
45
  }
40
46
  function getAttachments(payload) {
41
47
  const attachments = [];
42
48
  if (payload.filename && payload.body?.attachmentId) {
49
+ const disposition = getHeader(payload.headers, 'Content-Disposition');
50
+ const inline = disposition.toLowerCase().startsWith('inline')
51
+ || getHeader(payload.headers, 'Content-ID') !== '';
43
52
  attachments.push({
44
53
  filename: payload.filename,
45
54
  attachmentId: payload.body.attachmentId,
46
55
  mimeType: payload.mimeType ?? 'application/octet-stream',
56
+ ...(typeof payload.body.size === 'number' ? { sizeBytes: payload.body.size } : {}),
57
+ ...(payload.partId ? { partId: payload.partId } : {}),
58
+ ...(inline ? { inline: true } : {}),
47
59
  });
48
60
  }
49
61
  if (payload.parts) {
@@ -53,11 +65,30 @@ function getAttachments(payload) {
53
65
  }
54
66
  return attachments;
55
67
  }
68
+ async function resolveReplyHeaders(gmail, replyToMessageId) {
69
+ try {
70
+ const meta = await gmail.users.messages.get({
71
+ userId: 'me',
72
+ id: replyToMessageId,
73
+ format: 'metadata',
74
+ metadataHeaders: ['Message-ID', 'References'],
75
+ });
76
+ const headers = meta.data.payload?.headers;
77
+ return buildReplyHeaders(replyToMessageId, getHeader(headers, 'Message-ID'), getHeader(headers, 'References'));
78
+ }
79
+ catch {
80
+ // Degrade to the API id (pre-lookup behavior) rather than blocking the send.
81
+ return { inReplyTo: replyToMessageId, references: replyToMessageId };
82
+ }
83
+ }
56
84
  const BODY_CAP_CHARS = 50_000;
57
- export function parseMessage(msg, bodyCap) {
85
+ export function parseMessage(msg, bodyCap, opts) {
58
86
  const headers = msg.payload?.headers ?? [];
59
- const body = decodeBody(msg.payload);
87
+ const { body, bodyOrigin } = decodeBody(msg.payload, opts?.rawHtml === true);
60
88
  const capped = bodyCap !== undefined && body.length > bodyCap;
89
+ const messageIdHeader = getHeader(headers, 'Message-ID');
90
+ const inReplyTo = getHeader(headers, 'In-Reply-To');
91
+ const references = getHeader(headers, 'References');
61
92
  return {
62
93
  id: msg.id ?? '',
63
94
  threadId: msg.threadId ?? '',
@@ -67,7 +98,13 @@ export function parseMessage(msg, bodyCap) {
67
98
  cc: getHeader(headers, 'Cc'),
68
99
  date: getHeader(headers, 'Date'),
69
100
  body: capped ? sliceClean(body, bodyCap) : body,
101
+ ...(bodyOrigin ? { bodyOrigin } : {}),
70
102
  ...(capped ? { bodyTruncated: true, bodyTotalChars: body.length } : {}),
103
+ ...(messageIdHeader ? { messageIdHeader } : {}),
104
+ ...(inReplyTo ? { inReplyTo } : {}),
105
+ ...(references ? { references } : {}),
106
+ ...(msg.labelIds ? { labelIds: msg.labelIds } : {}),
107
+ ...(msg.internalDate ? { internalDate: msg.internalDate } : {}),
71
108
  attachments: getAttachments(msg.payload),
72
109
  };
73
110
  }
@@ -91,21 +128,28 @@ export function registerGmailTools(server) {
91
128
  });
92
129
  const messages = listRes.data.messages ?? [];
93
130
  const results = [];
94
- for (const m of messages) {
95
- const detail = await gmail.users.messages.get({
131
+ // Bounded parallelism: chunked Promise.all keeps order and avoids
132
+ // hammering the per-user quota with an unbounded fan-out.
133
+ const CHUNK_SIZE = 10;
134
+ for (let i = 0; i < messages.length; i += CHUNK_SIZE) {
135
+ const details = await Promise.all(messages.slice(i, i + CHUNK_SIZE).map((m) => gmail.users.messages.get({
96
136
  userId: 'me',
97
137
  id: m.id,
98
138
  format: 'metadata',
99
- metadataHeaders: ['From', 'Subject', 'Date'],
100
- });
101
- results.push({
102
- id: detail.data.id ?? '',
103
- threadId: detail.data.threadId ?? '',
104
- subject: getHeader(detail.data.payload?.headers, 'Subject'),
105
- from: getHeader(detail.data.payload?.headers, 'From'),
106
- date: getHeader(detail.data.payload?.headers, 'Date'),
107
- snippet: detail.data.snippet ?? '',
108
- });
139
+ metadataHeaders: ['From', 'To', 'Subject', 'Date'],
140
+ })));
141
+ for (const detail of details) {
142
+ results.push({
143
+ id: detail.data.id ?? '',
144
+ threadId: detail.data.threadId ?? '',
145
+ subject: getHeader(detail.data.payload?.headers, 'Subject'),
146
+ from: getHeader(detail.data.payload?.headers, 'From'),
147
+ to: getHeader(detail.data.payload?.headers, 'To'),
148
+ date: getHeader(detail.data.payload?.headers, 'Date'),
149
+ snippet: detail.data.snippet ?? '',
150
+ labelIds: detail.data.labelIds ?? [],
151
+ });
152
+ }
109
153
  }
110
154
  return {
111
155
  content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
@@ -121,8 +165,10 @@ export function registerGmailTools(server) {
121
165
  account: accountEnum.describe('Google account alias'),
122
166
  messageId: z.string().describe('Gmail message ID'),
123
167
  full: coerceBoolean.optional().describe('Return the entire body without the character cap'),
168
+ rawHtml: coerceBoolean.optional()
169
+ .describe('Return the HTML body unconverted instead of the plain-text rendering (HTML-only messages)'),
124
170
  },
125
- }, async ({ account, messageId, full }) => {
171
+ }, async ({ account, messageId, full, rawHtml }) => {
126
172
  try {
127
173
  const auth = await getClient(account);
128
174
  const gmail = google.gmail({ version: 'v1', auth });
@@ -131,7 +177,7 @@ export function registerGmailTools(server) {
131
177
  id: messageId,
132
178
  format: 'full',
133
179
  });
134
- const result = parseMessage(res.data, full ? undefined : BODY_CAP_CHARS);
180
+ const result = parseMessage(res.data, full ? undefined : BODY_CAP_CHARS, { rawHtml });
135
181
  return {
136
182
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
137
183
  };
@@ -141,22 +187,46 @@ export function registerGmailTools(server) {
141
187
  }
142
188
  });
143
189
  server.registerTool('gmail_read_thread', {
144
- description: 'Read all messages in a Gmail thread (bodies capped at 50k chars each unless full=true)',
190
+ description: 'Read all messages in a Gmail thread (bodies capped at 50k chars each unless full=true). mode=summary returns headers + snippets only.',
145
191
  inputSchema: {
146
192
  account: accountEnum.describe('Google account alias'),
147
193
  threadId: z.string().describe('Gmail thread ID'),
148
194
  full: coerceBoolean.optional().describe('Return entire bodies without the character cap'),
195
+ rawHtml: coerceBoolean.optional()
196
+ .describe('Return HTML bodies unconverted instead of the plain-text rendering (HTML-only messages)'),
197
+ mode: z.enum(['full', 'summary']).default('full')
198
+ .describe('full = complete bodies; summary = per-message headers and snippet only'),
149
199
  },
150
- }, async ({ account, threadId, full }) => {
200
+ }, async ({ account, threadId, full, rawHtml, mode }) => {
151
201
  try {
152
202
  const auth = await getClient(account);
153
203
  const gmail = google.gmail({ version: 'v1', auth });
204
+ if (mode === 'summary') {
205
+ const res = await gmail.users.threads.get({
206
+ userId: 'me',
207
+ id: threadId,
208
+ format: 'metadata',
209
+ metadataHeaders: ['From', 'To', 'Subject', 'Date', 'Message-ID'],
210
+ });
211
+ const summaries = (res.data.messages ?? []).map((m) => ({
212
+ id: m.id ?? '',
213
+ from: getHeader(m.payload?.headers, 'From'),
214
+ to: getHeader(m.payload?.headers, 'To'),
215
+ subject: getHeader(m.payload?.headers, 'Subject'),
216
+ date: getHeader(m.payload?.headers, 'Date'),
217
+ snippet: m.snippet ?? '',
218
+ labelIds: m.labelIds ?? [],
219
+ }));
220
+ return {
221
+ content: [{ type: 'text', text: JSON.stringify(summaries, null, 2) }],
222
+ };
223
+ }
154
224
  const res = await gmail.users.threads.get({
155
225
  userId: 'me',
156
226
  id: threadId,
157
227
  format: 'full',
158
228
  });
159
- const messages = (res.data.messages ?? []).map((m) => parseMessage(m, full ? undefined : BODY_CAP_CHARS));
229
+ const messages = (res.data.messages ?? []).map((m) => parseMessage(m, full ? undefined : BODY_CAP_CHARS, { rawHtml }));
160
230
  return {
161
231
  content: [{ type: 'text', text: JSON.stringify(messages, null, 2) }],
162
232
  };
@@ -205,8 +275,9 @@ export function registerGmailTools(server) {
205
275
  if (cc)
206
276
  headers.push(`Cc: ${encodeAddressHeader(cc)}`);
207
277
  if (replyToMessageId) {
208
- headers.push(`In-Reply-To: ${replyToMessageId}`);
209
- headers.push(`References: ${replyToMessageId}`);
278
+ const { inReplyTo, references } = await resolveReplyHeaders(gmail, replyToMessageId);
279
+ headers.push(`In-Reply-To: ${inReplyTo}`);
280
+ headers.push(`References: ${references}`);
210
281
  }
211
282
  const rawMessage = [...headers, '', bodyText].join('\r\n');
212
283
  const encoded = Buffer.from(rawMessage, 'utf-8').toString('base64url');
@@ -272,10 +343,12 @@ export function registerGmailTools(server) {
272
343
  htmlBody: z.string().optional()
273
344
  .describe('Optional HTML body. When set, drafts as multipart/alternative so HTML-capable clients render the rich version. Use bare tags only: <p>, <a>, <br>, <strong>, <em>, <ul><li>.'),
274
345
  cc: z.string().optional().describe('CC recipients, comma-separated'),
346
+ replyToMessageId: z.string().optional()
347
+ .describe('Message ID to reply to (sets In-Reply-To and References headers)'),
275
348
  replyToThreadId: z.string().optional()
276
349
  .describe('Thread ID to associate the draft with'),
277
350
  },
278
- }, async ({ account, to, subject, body, htmlBody, cc, replyToThreadId }) => {
351
+ }, async ({ account, to, subject, body, htmlBody, cc, replyToMessageId, replyToThreadId }) => {
279
352
  try {
280
353
  const auth = await getClient(account);
281
354
  const gmail = google.gmail({ version: 'v1', auth });
@@ -299,6 +372,11 @@ export function registerGmailTools(server) {
299
372
  }
300
373
  if (cc)
301
374
  headers.push(`Cc: ${encodeAddressHeader(cc)}`);
375
+ if (replyToMessageId) {
376
+ const { inReplyTo, references } = await resolveReplyHeaders(gmail, replyToMessageId);
377
+ headers.push(`In-Reply-To: ${inReplyTo}`);
378
+ headers.push(`References: ${references}`);
379
+ }
302
380
  const rawMessage = [...headers, '', bodyText].join('\r\n');
303
381
  const encoded = Buffer.from(rawMessage, 'utf-8').toString('base64url');
304
382
  const draftParams = {
package/dist/types.d.ts CHANGED
@@ -10,13 +10,18 @@ export interface GmailMessageHeader {
10
10
  threadId: string;
11
11
  subject: string;
12
12
  from: string;
13
+ to: string;
13
14
  date: string;
14
15
  snippet: string;
16
+ labelIds: string[];
15
17
  }
16
18
  export interface GmailAttachment {
17
19
  filename: string;
18
20
  attachmentId: string;
19
21
  mimeType: string;
22
+ sizeBytes?: number;
23
+ partId?: string;
24
+ inline?: boolean;
20
25
  }
21
26
  export interface GmailMessageFull {
22
27
  id: string;
@@ -27,8 +32,14 @@ export interface GmailMessageFull {
27
32
  cc: string;
28
33
  date: string;
29
34
  body: string;
35
+ bodyOrigin?: 'text/plain' | 'text/html';
30
36
  bodyTruncated?: boolean;
31
37
  bodyTotalChars?: number;
38
+ messageIdHeader?: string;
39
+ inReplyTo?: string;
40
+ references?: string;
41
+ labelIds?: string[];
42
+ internalDate?: string;
32
43
  attachments: GmailAttachment[];
33
44
  }
34
45
  export interface DriveFile {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "5.2.0-alpha.5",
3
+ "version": "5.2.0-alpha.6",
4
4
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
5
5
  "type": "module",
6
6
  "license": "MIT",