mcp-google-multi 4.3.0-alpha.1 → 4.3.0-alpha.3
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/README.md +35 -4
- package/dist/accounts.d.ts +1 -0
- package/dist/accounts.js +4 -1
- package/dist/auth.js +7 -6
- package/dist/client.js +8 -19
- package/dist/index.js +5 -0
- package/dist/migrate-tokens.d.ts +1 -0
- package/dist/migrate-tokens.js +22 -0
- package/dist/token-store.d.ts +7 -0
- package/dist/token-store.js +66 -0
- package/dist/tools/gmail-mime.d.ts +9 -0
- package/dist/tools/gmail-mime.js +35 -0
- package/dist/tools/gmail.js +33 -11
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A local [MCP](https://modelcontextprotocol.io) server that gives Claude Code (and any MCP client) access to **Gmail**, **Google Drive**, **Google Calendar**, **Google Sheets**, **Google Docs**, **Google Contacts**, **Google Search Console**, **Google Tasks**, **Google Meet**, and optionally **Google Forms**, **Google Chat**, and **Google Workspace Admin** APIs across multiple Google accounts simultaneously.
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
|
+
|
|
5
7
|
## Features
|
|
6
8
|
|
|
7
9
|
- **Multi-account** — manage any number of Google accounts from a single server
|
|
@@ -20,9 +22,9 @@ A local [MCP](https://modelcontextprotocol.io) server that gives Claude Code (an
|
|
|
20
22
|
| `gmail_search` | Search messages with Gmail query syntax |
|
|
21
23
|
| `gmail_read` | Read a full message by ID |
|
|
22
24
|
| `gmail_read_thread` | Read all messages in a thread |
|
|
23
|
-
| `gmail_send` | Send an email |
|
|
25
|
+
| `gmail_send` | Send an email (optional `htmlBody` for multipart/alternative) |
|
|
24
26
|
| `gmail_download_attachment` | Download an email attachment to local disk |
|
|
25
|
-
| `gmail_create_draft` | Create a draft |
|
|
27
|
+
| `gmail_create_draft` | Create a draft (optional `htmlBody` for multipart/alternative) |
|
|
26
28
|
| `gmail_modify_labels` | Add/remove labels on a message (star, archive, mark read, etc.) |
|
|
27
29
|
| `gmail_trash` | Move a message to Trash (recoverable) |
|
|
28
30
|
| `gmail_delete` | Permanently delete a message (irreversible) |
|
|
@@ -249,14 +251,25 @@ Enable with `GOOGLE_OPTIONAL_SCOPES=alertcenter` in `.env`.
|
|
|
249
251
|
|
|
250
252
|
### 2. Install & Configure
|
|
251
253
|
|
|
254
|
+
**Option A — from npm (no clone):**
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
npm install -g mcp-google-multi # installs the `mcp-google-multi` CLI
|
|
258
|
+
# …or run on demand without installing: npx mcp-google-multi
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Published with channel dist-tags — `@latest` (stable), `@dev` (alpha), `@staging` (beta); e.g. `npm i -g mcp-google-multi@dev` for the latest alpha.
|
|
262
|
+
|
|
263
|
+
**Option B — from source:**
|
|
264
|
+
|
|
252
265
|
```bash
|
|
253
266
|
git clone https://github.com/bakissation/mcp-google-multi.git
|
|
254
267
|
cd mcp-google-multi
|
|
255
|
-
npm install
|
|
268
|
+
npm install && npm run build
|
|
256
269
|
cp .env.example .env
|
|
257
270
|
```
|
|
258
271
|
|
|
259
|
-
|
|
272
|
+
Create a `.env` (from source, `.env.example` is the template; from npm, make one in the directory you'll run the server from) with:
|
|
260
273
|
|
|
261
274
|
```env
|
|
262
275
|
GOOGLE_CLIENT_ID=your_client_id_here
|
|
@@ -310,6 +323,24 @@ Or add it manually to your Claude Code MCP config:
|
|
|
310
323
|
}
|
|
311
324
|
```
|
|
312
325
|
|
|
326
|
+
If you installed from npm, point at the package instead and pass config via `env` (authenticate first with `mcp-google-multi auth --account <alias>`):
|
|
327
|
+
|
|
328
|
+
```json
|
|
329
|
+
{
|
|
330
|
+
"mcpServers": {
|
|
331
|
+
"google-multi": {
|
|
332
|
+
"command": "npx",
|
|
333
|
+
"args": ["-y", "mcp-google-multi"],
|
|
334
|
+
"env": {
|
|
335
|
+
"GOOGLE_CLIENT_ID": "your_client_id_here",
|
|
336
|
+
"GOOGLE_CLIENT_SECRET": "your_client_secret_here",
|
|
337
|
+
"GOOGLE_ACCOUNTS": "work:you@company.com,personal:you@gmail.com"
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
313
344
|
## Migrating from v3
|
|
314
345
|
|
|
315
346
|
v4.0.0 grows the base OAuth scope set (Tasks and Meet are now included by default). Existing v3 tokens lack those scopes and the corresponding tools will 403.
|
package/dist/accounts.d.ts
CHANGED
package/dist/accounts.js
CHANGED
|
@@ -3,7 +3,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
5
5
|
dotenv.config({ path: path.resolve(__dirname, '..', '.env') });
|
|
6
|
-
const tokenDir =
|
|
6
|
+
const tokenDir = process.env.TOKEN_STORE_PATH
|
|
7
|
+
? path.resolve(process.env.TOKEN_STORE_PATH)
|
|
8
|
+
: path.resolve(__dirname, '..', 'tokens');
|
|
7
9
|
/**
|
|
8
10
|
* Parse accounts from the GOOGLE_ACCOUNTS env var.
|
|
9
11
|
* Format: "alias1:email1,alias2:email2,..."
|
|
@@ -39,6 +41,7 @@ function parseAccounts() {
|
|
|
39
41
|
configs[alias] = {
|
|
40
42
|
email,
|
|
41
43
|
tokenPath: path.join(tokenDir, alias, 'token.json'),
|
|
44
|
+
encPath: path.join(tokenDir, `${alias}.enc`),
|
|
42
45
|
};
|
|
43
46
|
}
|
|
44
47
|
if (aliases.length === 0) {
|
package/dist/auth.js
CHANGED
|
@@ -2,11 +2,10 @@ import { google } from 'googleapis';
|
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
|
-
import fs from 'node:fs/promises';
|
|
6
|
-
import path from 'node:path';
|
|
7
5
|
import open from 'open';
|
|
8
6
|
import destroyer from 'server-destroy';
|
|
9
7
|
import { ACCOUNTS, ACCOUNT_CONFIG } from './accounts.js';
|
|
8
|
+
import { writeToken } from './token-store.js';
|
|
10
9
|
// ─── Scope tiers ────────────────────────────────────────────────────────
|
|
11
10
|
//
|
|
12
11
|
// BASE: always granted. Existing v3 surface + Tasks + Meet (added in v4.0.0).
|
|
@@ -98,6 +97,10 @@ export async function runAuthFlow(args) {
|
|
|
98
97
|
}
|
|
99
98
|
const config = ACCOUNT_CONFIG[alias];
|
|
100
99
|
const scopes = resolveScopesForAccount(alias);
|
|
100
|
+
if (!process.env.MASTER_KEY) {
|
|
101
|
+
console.error('MASTER_KEY is not set. Generate one (openssl rand -base64 32) and add it to .env before authenticating.');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
101
104
|
const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
|
|
102
105
|
// CSRF protection for the OAuth callback (RFC 6749 §10.12).
|
|
103
106
|
const expectedState = randomBytes(32).toString('hex');
|
|
@@ -145,13 +148,11 @@ export async function runAuthFlow(args) {
|
|
|
145
148
|
return;
|
|
146
149
|
}
|
|
147
150
|
const { tokens } = await oauth2Client.getToken(code);
|
|
148
|
-
|
|
149
|
-
// 0o600: tokens grant full account access; keep them user-only.
|
|
150
|
-
await fs.writeFile(config.tokenPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
151
|
+
writeToken(alias, tokens);
|
|
151
152
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
152
153
|
res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
153
154
|
server.destroy();
|
|
154
|
-
console.log(`Token saved
|
|
155
|
+
console.log(`Token saved (encrypted) for ${alias}.`);
|
|
155
156
|
resolve();
|
|
156
157
|
}
|
|
157
158
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { google } from 'googleapis';
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
2
|
import { ACCOUNT_CONFIG } from './accounts.js';
|
|
3
|
+
import { readToken, writeToken } from './token-store.js';
|
|
4
4
|
export async function getClient(account) {
|
|
5
5
|
const config = ACCOUNT_CONFIG[account];
|
|
6
6
|
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
|
@@ -8,26 +8,15 @@ export async function getClient(account) {
|
|
|
8
8
|
'Check that .env exists in the project root or pass them as env vars.');
|
|
9
9
|
}
|
|
10
10
|
const oauth2Client = new google.auth.OAuth2(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
throw new Error(`No token file found for account "${account}" at ${config.tokenPath}. ` +
|
|
18
|
-
`Run: node dist/index.js auth --account ${account}`);
|
|
11
|
+
const tokenData = readToken(account);
|
|
12
|
+
if (!tokenData) {
|
|
13
|
+
throw new Error(`No token found for account "${account}" (${config.email}). ` +
|
|
14
|
+
`Run: npx mcp-google-multi auth --account ${account}`);
|
|
19
15
|
}
|
|
20
16
|
oauth2Client.setCredentials(tokenData);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const existing = JSON.parse(await fs.readFile(config.tokenPath, 'utf-8'));
|
|
25
|
-
const merged = { ...existing, ...tokens };
|
|
26
|
-
await fs.writeFile(config.tokenPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
await fs.writeFile(config.tokenPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
30
|
-
}
|
|
17
|
+
oauth2Client.on('tokens', (tokens) => {
|
|
18
|
+
const existing = readToken(account) ?? {};
|
|
19
|
+
writeToken(account, { ...existing, ...tokens });
|
|
31
20
|
});
|
|
32
21
|
return oauth2Client;
|
|
33
22
|
}
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,11 @@ async function main() {
|
|
|
26
26
|
await runAuthFlow(process.argv);
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
|
+
if (process.argv.includes('migrate-tokens')) {
|
|
30
|
+
const { runMigrateTokens } = await import('./migrate-tokens.js');
|
|
31
|
+
runMigrateTokens();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
29
34
|
// MCP server mode — no console.log (stdio is the MCP channel)
|
|
30
35
|
const server = new McpServer({
|
|
31
36
|
name: 'mcp-google-multi',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runMigrateTokens(): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { ACCOUNTS, ACCOUNT_CONFIG } from './accounts.js';
|
|
3
|
+
import { writeToken, hasToken } from './token-store.js';
|
|
4
|
+
export function runMigrateTokens() {
|
|
5
|
+
let migrated = 0;
|
|
6
|
+
let skipped = 0;
|
|
7
|
+
for (const alias of ACCOUNTS) {
|
|
8
|
+
const plain = ACCOUNT_CONFIG[alias].tokenPath;
|
|
9
|
+
if (!fs.existsSync(plain))
|
|
10
|
+
continue;
|
|
11
|
+
if (hasToken(alias)) {
|
|
12
|
+
console.log(`• ${alias}: encrypted token already exists, skipping`);
|
|
13
|
+
skipped++;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
writeToken(alias, JSON.parse(fs.readFileSync(plain, 'utf8')));
|
|
17
|
+
console.log(`✓ ${alias}: migrated ${plain} → encrypted store`);
|
|
18
|
+
migrated++;
|
|
19
|
+
}
|
|
20
|
+
console.log(`Done. ${migrated} migrated, ${skipped} skipped. ` +
|
|
21
|
+
'Delete the plaintext tokens/<alias>/token.json once verified.');
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TokenData } from './types.js';
|
|
2
|
+
export declare function deriveKey(masterKey: string): Buffer;
|
|
3
|
+
export declare function encryptToken(data: object, masterKey: string): string;
|
|
4
|
+
export declare function decryptToken(fileContents: string, masterKey: string): TokenData;
|
|
5
|
+
export declare function readToken(alias: string): TokenData | null;
|
|
6
|
+
export declare function writeToken(alias: string, data: object): void;
|
|
7
|
+
export declare function hasToken(alias: string): boolean;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ACCOUNT_CONFIG } from './accounts.js';
|
|
5
|
+
const ENC_VERSION = 1;
|
|
6
|
+
const ALGO = 'aes-256-gcm';
|
|
7
|
+
export function deriveKey(masterKey) {
|
|
8
|
+
if (!masterKey) {
|
|
9
|
+
throw new Error('MASTER_KEY is required to encrypt/decrypt tokens. Generate one with: openssl rand -base64 32');
|
|
10
|
+
}
|
|
11
|
+
const raw = Buffer.from(masterKey, 'base64');
|
|
12
|
+
if (raw.length === 32)
|
|
13
|
+
return raw;
|
|
14
|
+
return createHash('sha256').update(masterKey, 'utf8').digest();
|
|
15
|
+
}
|
|
16
|
+
export function encryptToken(data, masterKey) {
|
|
17
|
+
const key = deriveKey(masterKey);
|
|
18
|
+
const iv = randomBytes(12);
|
|
19
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
20
|
+
const ciphertext = Buffer.concat([
|
|
21
|
+
cipher.update(Buffer.from(JSON.stringify(data), 'utf8')),
|
|
22
|
+
cipher.final(),
|
|
23
|
+
]);
|
|
24
|
+
const file = {
|
|
25
|
+
v: ENC_VERSION,
|
|
26
|
+
iv: iv.toString('base64'),
|
|
27
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
28
|
+
data: ciphertext.toString('base64'),
|
|
29
|
+
};
|
|
30
|
+
return JSON.stringify(file);
|
|
31
|
+
}
|
|
32
|
+
export function decryptToken(fileContents, masterKey) {
|
|
33
|
+
const key = deriveKey(masterKey);
|
|
34
|
+
const file = JSON.parse(fileContents);
|
|
35
|
+
if (file.v !== ENC_VERSION) {
|
|
36
|
+
throw new Error(`Unsupported token file version: ${file.v}`);
|
|
37
|
+
}
|
|
38
|
+
const decipher = createDecipheriv(ALGO, key, Buffer.from(file.iv, 'base64'));
|
|
39
|
+
decipher.setAuthTag(Buffer.from(file.tag, 'base64'));
|
|
40
|
+
const plaintext = Buffer.concat([
|
|
41
|
+
decipher.update(Buffer.from(file.data, 'base64')),
|
|
42
|
+
decipher.final(),
|
|
43
|
+
]);
|
|
44
|
+
return JSON.parse(plaintext.toString('utf8'));
|
|
45
|
+
}
|
|
46
|
+
function masterKey() {
|
|
47
|
+
return process.env.MASTER_KEY ?? '';
|
|
48
|
+
}
|
|
49
|
+
export function readToken(alias) {
|
|
50
|
+
let contents;
|
|
51
|
+
try {
|
|
52
|
+
contents = fs.readFileSync(ACCOUNT_CONFIG[alias].encPath, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return decryptToken(contents, masterKey());
|
|
58
|
+
}
|
|
59
|
+
export function writeToken(alias, data) {
|
|
60
|
+
const p = ACCOUNT_CONFIG[alias].encPath;
|
|
61
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
62
|
+
fs.writeFileSync(p, encryptToken(data, masterKey()), { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
export function hasToken(alias) {
|
|
65
|
+
return fs.existsSync(ACCOUNT_CONFIG[alias].encPath);
|
|
66
|
+
}
|
|
@@ -14,3 +14,12 @@ 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
|
+
* Build a multipart/alternative body so HTML-capable clients render the rich
|
|
19
|
+
* version and plain clients fall back. Returns the header value AND the body.
|
|
20
|
+
* Caller composes the full message: headers (including this Content-Type) + CRLF + body.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildMultipartAlternative(plainBody: string, htmlBody: string): {
|
|
23
|
+
contentType: string;
|
|
24
|
+
body: string;
|
|
25
|
+
};
|
package/dist/tools/gmail-mime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
/**
|
|
2
3
|
* RFC 2047 encoded-word (`=?utf-8?B?...?=`) for non-ASCII header text.
|
|
3
4
|
* Long values are split into multiple <=75-char encoded-words separated
|
|
@@ -62,3 +63,37 @@ export function encodeAddressHeader(value) {
|
|
|
62
63
|
export function normalizeBodyLineEndings(body) {
|
|
63
64
|
return body.replace(/\r\n|\r|\n/g, '\r\n');
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* RFC 2046 §5.1.1 boundary token: 1-70 chars from a restricted set, no trailing space.
|
|
68
|
+
* randomBytes hex output is only [0-9a-f], all of which are bcharsnospace.
|
|
69
|
+
*/
|
|
70
|
+
function generateMimeBoundary() {
|
|
71
|
+
// 5-char prefix + 32 hex chars = 37 chars, well under the 70-char limit.
|
|
72
|
+
return `=_gm_${randomBytes(16).toString('hex')}`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build a multipart/alternative body so HTML-capable clients render the rich
|
|
76
|
+
* version and plain clients fall back. Returns the header value AND the body.
|
|
77
|
+
* Caller composes the full message: headers (including this Content-Type) + CRLF + body.
|
|
78
|
+
*/
|
|
79
|
+
export function buildMultipartAlternative(plainBody, htmlBody) {
|
|
80
|
+
const boundary = generateMimeBoundary();
|
|
81
|
+
const parts = [
|
|
82
|
+
`--${boundary}`,
|
|
83
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
84
|
+
'Content-Transfer-Encoding: 8bit',
|
|
85
|
+
'',
|
|
86
|
+
normalizeBodyLineEndings(plainBody),
|
|
87
|
+
`--${boundary}`,
|
|
88
|
+
'Content-Type: text/html; charset="UTF-8"',
|
|
89
|
+
'Content-Transfer-Encoding: 8bit',
|
|
90
|
+
'',
|
|
91
|
+
normalizeBodyLineEndings(htmlBody),
|
|
92
|
+
`--${boundary}--`,
|
|
93
|
+
'',
|
|
94
|
+
];
|
|
95
|
+
return {
|
|
96
|
+
contentType: `multipart/alternative; boundary="${boundary}"`,
|
|
97
|
+
body: parts.join('\r\n'),
|
|
98
|
+
};
|
|
99
|
+
}
|
package/dist/tools/gmail.js
CHANGED
|
@@ -3,7 +3,7 @@ import { google } from 'googleapis';
|
|
|
3
3
|
import { ACCOUNTS } from '../accounts.js';
|
|
4
4
|
import { getClient } from '../client.js';
|
|
5
5
|
import { handleGoogleApiError } from './_errors.js';
|
|
6
|
-
import { encodeAddressHeader, encodeHeaderValue, normalizeBodyLineEndings } from './gmail-mime.js';
|
|
6
|
+
import { buildMultipartAlternative, encodeAddressHeader, encodeHeaderValue, normalizeBodyLineEndings } from './gmail-mime.js';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
const accountEnum = z.enum(ACCOUNTS);
|
|
@@ -163,14 +163,16 @@ export function registerGmailTools(server) {
|
|
|
163
163
|
account: accountEnum.describe('Google account alias'),
|
|
164
164
|
to: z.string().describe('Recipient(s), comma-separated'),
|
|
165
165
|
subject: z.string().describe('Email subject'),
|
|
166
|
-
body: z.string().describe('Plain text body'),
|
|
166
|
+
body: z.string().describe('Plain text body (always required; also used as fallback when htmlBody is set)'),
|
|
167
|
+
htmlBody: z.string().optional()
|
|
168
|
+
.describe('Optional HTML body. When set, sends multipart/alternative so HTML-capable clients render the rich version. Use bare tags only: <p>, <a>, <br>, <strong>, <em>, <ul><li>.'),
|
|
167
169
|
cc: z.string().optional().describe('CC recipients, comma-separated'),
|
|
168
170
|
replyToMessageId: z.string().optional()
|
|
169
171
|
.describe('Message ID to reply to (sets In-Reply-To and References headers)'),
|
|
170
172
|
replyToThreadId: z.string().optional()
|
|
171
173
|
.describe('Thread ID to send the message in'),
|
|
172
174
|
},
|
|
173
|
-
}, async ({ account, to, subject, body, cc, replyToMessageId, replyToThreadId }) => {
|
|
175
|
+
}, async ({ account, to, subject, body, htmlBody, cc, replyToMessageId, replyToThreadId }) => {
|
|
174
176
|
try {
|
|
175
177
|
const auth = await getClient(account);
|
|
176
178
|
const gmail = google.gmail({ version: 'v1', auth });
|
|
@@ -180,16 +182,25 @@ export function registerGmailTools(server) {
|
|
|
180
182
|
`To: ${encodeAddressHeader(to)}`,
|
|
181
183
|
`Subject: ${encodeHeaderValue(subject)}`,
|
|
182
184
|
'MIME-Version: 1.0',
|
|
183
|
-
'Content-Type: text/plain; charset="UTF-8"',
|
|
184
|
-
'Content-Transfer-Encoding: 8bit',
|
|
185
185
|
];
|
|
186
|
+
let bodyText;
|
|
187
|
+
if (htmlBody) {
|
|
188
|
+
const { contentType, body: mp } = buildMultipartAlternative(body, htmlBody);
|
|
189
|
+
headers.push(`Content-Type: ${contentType}`);
|
|
190
|
+
bodyText = mp;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
headers.push('Content-Type: text/plain; charset="UTF-8"');
|
|
194
|
+
headers.push('Content-Transfer-Encoding: 8bit');
|
|
195
|
+
bodyText = normalizeBodyLineEndings(body);
|
|
196
|
+
}
|
|
186
197
|
if (cc)
|
|
187
198
|
headers.push(`Cc: ${encodeAddressHeader(cc)}`);
|
|
188
199
|
if (replyToMessageId) {
|
|
189
200
|
headers.push(`In-Reply-To: ${replyToMessageId}`);
|
|
190
201
|
headers.push(`References: ${replyToMessageId}`);
|
|
191
202
|
}
|
|
192
|
-
const rawMessage = [...headers, '',
|
|
203
|
+
const rawMessage = [...headers, '', bodyText].join('\r\n');
|
|
193
204
|
const encoded = Buffer.from(rawMessage, 'utf-8').toString('base64url');
|
|
194
205
|
const sendParams = {
|
|
195
206
|
userId: 'me',
|
|
@@ -249,12 +260,14 @@ export function registerGmailTools(server) {
|
|
|
249
260
|
account: accountEnum.describe('Google account alias'),
|
|
250
261
|
to: z.string().describe('Recipient(s), comma-separated'),
|
|
251
262
|
subject: z.string().describe('Email subject'),
|
|
252
|
-
body: z.string().describe('Plain text body'),
|
|
263
|
+
body: z.string().describe('Plain text body (always required; also used as fallback when htmlBody is set)'),
|
|
264
|
+
htmlBody: z.string().optional()
|
|
265
|
+
.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>.'),
|
|
253
266
|
cc: z.string().optional().describe('CC recipients, comma-separated'),
|
|
254
267
|
replyToThreadId: z.string().optional()
|
|
255
268
|
.describe('Thread ID to associate the draft with'),
|
|
256
269
|
},
|
|
257
|
-
}, async ({ account, to, subject, body, cc, replyToThreadId }) => {
|
|
270
|
+
}, async ({ account, to, subject, body, htmlBody, cc, replyToThreadId }) => {
|
|
258
271
|
try {
|
|
259
272
|
const auth = await getClient(account);
|
|
260
273
|
const gmail = google.gmail({ version: 'v1', auth });
|
|
@@ -264,12 +277,21 @@ export function registerGmailTools(server) {
|
|
|
264
277
|
`To: ${encodeAddressHeader(to)}`,
|
|
265
278
|
`Subject: ${encodeHeaderValue(subject)}`,
|
|
266
279
|
'MIME-Version: 1.0',
|
|
267
|
-
'Content-Type: text/plain; charset="UTF-8"',
|
|
268
|
-
'Content-Transfer-Encoding: 8bit',
|
|
269
280
|
];
|
|
281
|
+
let bodyText;
|
|
282
|
+
if (htmlBody) {
|
|
283
|
+
const { contentType, body: mp } = buildMultipartAlternative(body, htmlBody);
|
|
284
|
+
headers.push(`Content-Type: ${contentType}`);
|
|
285
|
+
bodyText = mp;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
headers.push('Content-Type: text/plain; charset="UTF-8"');
|
|
289
|
+
headers.push('Content-Transfer-Encoding: 8bit');
|
|
290
|
+
bodyText = normalizeBodyLineEndings(body);
|
|
291
|
+
}
|
|
270
292
|
if (cc)
|
|
271
293
|
headers.push(`Cc: ${encodeAddressHeader(cc)}`);
|
|
272
|
-
const rawMessage = [...headers, '',
|
|
294
|
+
const rawMessage = [...headers, '', bodyText].join('\r\n');
|
|
273
295
|
const encoded = Buffer.from(rawMessage, 'utf-8').toString('base64url');
|
|
274
296
|
const draftParams = {
|
|
275
297
|
userId: 'me',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "4.3.0-alpha.
|
|
3
|
+
"version": "4.3.0-alpha.3",
|
|
4
4
|
"description": "MCP server for Gmail, Google Drive, Calendar, Sheets, Docs, and Contacts with multi-account support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"prepublishOnly": "npm run build",
|
|
45
45
|
"watch": "tsc --watch",
|
|
46
46
|
"auth": "node dist/index.js auth --account",
|
|
47
|
+
"migrate-tokens": "node dist/index.js migrate-tokens",
|
|
47
48
|
"lint": "eslint .",
|
|
48
49
|
"typecheck": "tsc --noEmit",
|
|
49
50
|
"test": "vitest run",
|
|
@@ -67,6 +68,6 @@
|
|
|
67
68
|
"semantic-release": "^25.0.3",
|
|
68
69
|
"typescript": "^6.0.3",
|
|
69
70
|
"typescript-eslint": "^8.59.1",
|
|
70
|
-
"vitest": "^
|
|
71
|
+
"vitest": "^3.2.4"
|
|
71
72
|
}
|
|
72
73
|
}
|