mcp-google-multi 4.3.0-alpha.2 → 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 CHANGED
@@ -22,9 +22,9 @@ A local [MCP](https://modelcontextprotocol.io) server that gives Claude Code (an
22
22
  | `gmail_search` | Search messages with Gmail query syntax |
23
23
  | `gmail_read` | Read a full message by ID |
24
24
  | `gmail_read_thread` | Read all messages in a thread |
25
- | `gmail_send` | Send an email |
25
+ | `gmail_send` | Send an email (optional `htmlBody` for multipart/alternative) |
26
26
  | `gmail_download_attachment` | Download an email attachment to local disk |
27
- | `gmail_create_draft` | Create a draft |
27
+ | `gmail_create_draft` | Create a draft (optional `htmlBody` for multipart/alternative) |
28
28
  | `gmail_modify_labels` | Add/remove labels on a message (star, archive, mark read, etc.) |
29
29
  | `gmail_trash` | Move a message to Trash (recoverable) |
30
30
  | `gmail_delete` | Permanently delete a message (irreversible) |
@@ -1,6 +1,7 @@
1
1
  export interface AccountConfig {
2
2
  email: string;
3
3
  tokenPath: string;
4
+ encPath: string;
4
5
  }
5
6
  /** Tuple of account aliases (at least one) — usable with z.enum() */
6
7
  export declare const ACCOUNTS: [string, ...string[]];
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 = path.resolve(__dirname, '..', 'tokens');
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
- await fs.mkdir(path.dirname(config.tokenPath), { recursive: true });
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 to ${config.tokenPath}`);
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
- let tokenData;
12
- try {
13
- const raw = await fs.readFile(config.tokenPath, 'utf-8');
14
- tokenData = JSON.parse(raw);
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
- // 0o600: tokens grant full account access; keep them user-only.
22
- oauth2Client.on('tokens', async (tokens) => {
23
- try {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "4.3.0-alpha.2",
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",