mcp-google-multi 5.2.0-alpha.5 → 5.2.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.js +9 -1
- package/dist/executor.js +3 -0
- package/dist/index.js +14 -1
- package/dist/tools/drive.js +2 -2
- package/dist/tools/gmail-mime.d.ts +14 -0
- package/dist/tools/gmail-mime.js +89 -0
- package/dist/tools/gmail.d.ts +3 -1
- package/dist/tools/gmail.js +124 -46
- package/dist/tools/google-api.js +3 -2
- package/dist/types.d.ts +11 -0
- package/dist/write-control.js +5 -0
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -111,10 +111,15 @@ export function resolveScopesForAccount(alias) {
|
|
|
111
111
|
export async function runAuthFlow(args) {
|
|
112
112
|
const accountIdx = args.indexOf('--account');
|
|
113
113
|
if (accountIdx === -1 || !args[accountIdx + 1]) {
|
|
114
|
-
console.error('Usage:
|
|
114
|
+
console.error('Usage: mcp-google-multi auth --account <alias>');
|
|
115
115
|
console.error(`Valid aliases: ${ACCOUNTS.join(', ')}`);
|
|
116
116
|
process.exit(1);
|
|
117
117
|
}
|
|
118
|
+
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
|
119
|
+
console.error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are not set. Create an OAuth Desktop client ' +
|
|
120
|
+
'(see docs/google-cloud-setup.md) and add both to your environment before authenticating.');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
118
123
|
const alias = args[accountIdx + 1];
|
|
119
124
|
if (!ACCOUNTS.includes(alias)) {
|
|
120
125
|
console.error(`Unknown account "${alias}". Valid aliases: ${ACCOUNTS.join(', ')}`);
|
|
@@ -178,6 +183,7 @@ export async function runAuthFlow(args) {
|
|
|
178
183
|
res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
179
184
|
server.destroy();
|
|
180
185
|
console.log(`Token saved (encrypted) for ${alias}.`);
|
|
186
|
+
console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
|
|
181
187
|
resolve();
|
|
182
188
|
}
|
|
183
189
|
}
|
|
@@ -190,6 +196,8 @@ export async function runAuthFlow(args) {
|
|
|
190
196
|
})
|
|
191
197
|
// Bind to loopback only — never expose the OAuth callback to the local network.
|
|
192
198
|
.listen(4242, '127.0.0.1', () => {
|
|
199
|
+
// Always print the URL: `open` silently no-ops on headless/SSH sessions.
|
|
200
|
+
console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
|
|
193
201
|
open(authorizeUrl, { wait: false }).then((cp) => cp.unref());
|
|
194
202
|
});
|
|
195
203
|
destroyer(server);
|
package/dist/executor.js
CHANGED
|
@@ -27,6 +27,7 @@ export async function executeApiMethod(method, args, deps = {}) {
|
|
|
27
27
|
message: 'Binary media download (alt=media) returns no usable JSON through this tool.',
|
|
28
28
|
hint: 'Use drive_download / drive_export for file content.',
|
|
29
29
|
retriable: false,
|
|
30
|
+
account: args.account,
|
|
30
31
|
}, true);
|
|
31
32
|
}
|
|
32
33
|
let url;
|
|
@@ -39,6 +40,7 @@ export async function executeApiMethod(method, args, deps = {}) {
|
|
|
39
40
|
message: err.message,
|
|
40
41
|
hint: `Required params for ${method.id}: ${method.requiredParams.join(', ') || '(none)'}`,
|
|
41
42
|
retriable: false,
|
|
43
|
+
account: args.account,
|
|
42
44
|
}, true);
|
|
43
45
|
}
|
|
44
46
|
if (!isGoogleApiUrl(url)) {
|
|
@@ -47,6 +49,7 @@ export async function executeApiMethod(method, args, deps = {}) {
|
|
|
47
49
|
message: `Refusing to send credentials to a non-googleapis.com host for "${method.id}".`,
|
|
48
50
|
hint: 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
|
|
49
51
|
retriable: false,
|
|
52
|
+
account: args.account,
|
|
50
53
|
}, true);
|
|
51
54
|
}
|
|
52
55
|
try {
|
package/dist/index.js
CHANGED
|
@@ -81,7 +81,20 @@ async function main() {
|
|
|
81
81
|
const counts = registry.visibleCount();
|
|
82
82
|
console.log(`Write-control: ${describePolicy(policy)}`);
|
|
83
83
|
console.log(`CUD tools enabled: ${cud.length - disabled.length}/${cud.length}`);
|
|
84
|
-
|
|
84
|
+
// At full-coverage scale, a flat name dump is unreadable — summarize per
|
|
85
|
+
// service unless the list is short.
|
|
86
|
+
let disabledLine = '(none)';
|
|
87
|
+
if (disabled.length > 0 && disabled.length <= 20) {
|
|
88
|
+
disabledLine = disabled.map((t) => t.name).join(', ');
|
|
89
|
+
}
|
|
90
|
+
else if (disabled.length > 20) {
|
|
91
|
+
const perService = new Map();
|
|
92
|
+
for (const t of disabled)
|
|
93
|
+
perService.set(t.service, (perService.get(t.service) ?? 0) + 1);
|
|
94
|
+
const summary = [...perService.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([s, n]) => `${s} ${n}`).join(', ');
|
|
95
|
+
disabledLine = `${disabled.length} write tools (${summary}) — enable via GOOGLE_PROFILE / GOOGLE_WRITE_ALLOW`;
|
|
96
|
+
}
|
|
97
|
+
console.log(`Disabled: ${disabledLine}`);
|
|
85
98
|
console.log(`Services: ${registry.services().join(', ')}`);
|
|
86
99
|
console.log(`Tool surface: ${counts.eager} eager (discover + escape hatch), ${counts.hidden} deferred until discovery`);
|
|
87
100
|
console.log(`Escape hatch: google_api_call CUD verdicts follow profile=${policy.profile} and your allow/deny globs`);
|
package/dist/tools/drive.js
CHANGED
|
@@ -246,7 +246,7 @@ export function registerDriveTools(server) {
|
|
|
246
246
|
const dest = path.join(savePath, path.basename(filename));
|
|
247
247
|
const res = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'stream' });
|
|
248
248
|
// pipeline destroys both streams on source/sink error; raw .pipe leaks the partial file.
|
|
249
|
-
await pipeline(res.data, fs.createWriteStream(dest));
|
|
249
|
+
await pipeline(res.data, fs.createWriteStream(dest, { mode: 0o600 }));
|
|
250
250
|
const { size } = fs.statSync(dest);
|
|
251
251
|
return {
|
|
252
252
|
content: [{ type: 'text', text: JSON.stringify({ savedPath: dest, bytes: size }, null, 2) }],
|
|
@@ -272,7 +272,7 @@ export function registerDriveTools(server) {
|
|
|
272
272
|
const dest = path.join(savePath, path.basename(filename));
|
|
273
273
|
const res = await drive.files.export({ fileId, mimeType: exportMime }, { responseType: 'stream' });
|
|
274
274
|
// pipeline destroys both streams on source/sink error; raw .pipe leaks the partial file.
|
|
275
|
-
await pipeline(res.data, fs.createWriteStream(dest));
|
|
275
|
+
await pipeline(res.data, fs.createWriteStream(dest, { mode: 0o600 }));
|
|
276
276
|
const { size } = fs.statSync(dest);
|
|
277
277
|
return {
|
|
278
278
|
content: [{ type: 'text', text: JSON.stringify({ savedPath: dest, bytes: size }, null, 2) }],
|
|
@@ -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.
|
package/dist/tools/gmail-mime.js
CHANGED
|
@@ -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.
|
package/dist/tools/gmail.d.ts
CHANGED
|
@@ -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
|
|
3
|
+
export declare function parseMessage(msg: any, bodyCap?: number, opts?: {
|
|
4
|
+
rawHtml?: boolean;
|
|
5
|
+
}): GmailMessageFull;
|
|
4
6
|
export declare function registerGmailTools(server: ToolRegistry): void;
|
package/dist/tools/gmail.js
CHANGED
|
@@ -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
|
|
16
|
-
if (
|
|
17
|
-
return
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
209
|
-
headers.push(`
|
|
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');
|
|
@@ -253,7 +324,7 @@ export function registerGmailTools(server) {
|
|
|
253
324
|
const buffer = Buffer.from(data, 'base64url');
|
|
254
325
|
// Strip path components so callers can't escape savePath via "../".
|
|
255
326
|
const fullPath = path.join(savePath, path.basename(filename));
|
|
256
|
-
await fs.promises.writeFile(fullPath, buffer);
|
|
327
|
+
await fs.promises.writeFile(fullPath, buffer, { mode: 0o600 });
|
|
257
328
|
return {
|
|
258
329
|
content: [{ type: 'text', text: `Saved to ${fullPath} (${buffer.length} bytes)` }],
|
|
259
330
|
};
|
|
@@ -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/tools/google-api.js
CHANGED
|
@@ -112,7 +112,7 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
112
112
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
113
113
|
}, async ({ account, api, methodId, pathParams, queryParams, body }) => {
|
|
114
114
|
if (!WORKSPACE_APIS[api]) {
|
|
115
|
-
return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
|
|
115
|
+
return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false, account }, true);
|
|
116
116
|
}
|
|
117
117
|
if (!apiEnabled(api))
|
|
118
118
|
return toolsetDisabled(api);
|
|
@@ -121,7 +121,7 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
121
121
|
index = await loadMethodIndex(api, deps);
|
|
122
122
|
}
|
|
123
123
|
catch (err) {
|
|
124
|
-
return jsonResult({ error: 'discovery_unavailable', message: err.message, retriable: true }, true);
|
|
124
|
+
return jsonResult({ error: 'discovery_unavailable', message: err.message, retriable: true, account }, true);
|
|
125
125
|
}
|
|
126
126
|
const method = index.find((m) => m.id === methodId);
|
|
127
127
|
if (!method) {
|
|
@@ -130,6 +130,7 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
130
130
|
message: `No method "${methodId}" in ${api}.`,
|
|
131
131
|
hint: `Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
|
|
132
132
|
retriable: false,
|
|
133
|
+
account,
|
|
133
134
|
}, true);
|
|
134
135
|
}
|
|
135
136
|
const cud = cudFromMethod(method);
|
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/dist/write-control.js
CHANGED
|
@@ -4,6 +4,11 @@ function parseGlobs(value) {
|
|
|
4
4
|
}
|
|
5
5
|
export function resolvePolicy(env = process.env) {
|
|
6
6
|
const raw = (env.GOOGLE_PROFILE ?? 'read-only').trim();
|
|
7
|
+
if (raw && !PROFILES.includes(raw)) {
|
|
8
|
+
// Fail-closed to read-only, but say so — a typo'd profile otherwise looks
|
|
9
|
+
// like every write tool silently breaking.
|
|
10
|
+
process.stderr.write(`GOOGLE_PROFILE="${raw}" is not valid (${PROFILES.join(' | ')}); using read-only\n`);
|
|
11
|
+
}
|
|
7
12
|
return {
|
|
8
13
|
profile: PROFILES.includes(raw) ? raw : 'read-only',
|
|
9
14
|
readOnly: /^(1|true|yes)$/i.test(env.GOOGLE_READ_ONLY ?? ''),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "5.2.0-alpha.
|
|
3
|
+
"version": "5.2.0-alpha.7",
|
|
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",
|