From 1c542ad18f78359f44f5322464df281a8d232944 Mon Sep 17 00:00:00 2001 From: nicholas-anthony-ai <188956207+nicholas-anthony-ai@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:14:14 +1100 Subject: [PATCH] Add multi-account support (#19) Allow managing multiple Gmail accounts from a single server instance. Each tool accepts an optional `account` parameter; omitting it uses the default account for full backwards compatibility. - Replace singleton OAuth2Client with per-account Gmail client map - Add loadAllAccounts() to discover credentials-*.json files - Add authenticate(accountName) for per-account auth flow - Add `account` param to all 19 existing tool schemas - Add list_accounts tool to show configured accounts - Update README with multi-account usage docs Co-Authored-By: Claude Opus 4.6 --- README.md | 58 ++++++++- src/index.ts | 333 +++++++++++++++++++++++++++++++++------------------ 2 files changed, 272 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 7d41cc0a..a8f46517 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ A Model Context Protocol (MCP) server for Gmail integration in Claude Desktop wi ## Features +- **Multi-account support** - manage multiple Gmail accounts from a single server instance - Send emails with subject, content, **attachments**, and recipients - **Full attachment support** - send and receive file attachments - **Download email attachments** to local filesystem @@ -194,11 +195,66 @@ npx @gongrzhe/server-gmail-autoauth-mcp auth https://gmail.gongrzhe.com/oauth2ca This approach allows authentication flows to work properly in environments where localhost isn't accessible, such as containerized applications or cloud servers. +## Multi-Account Support + +You can configure multiple Gmail accounts and switch between them using the optional `account` parameter on any tool. + +### Authenticating Additional Accounts + +```bash +# Authenticate your default account (same as before) +npx @gongrzhe/server-gmail-autoauth-mcp auth + +# Authenticate a "work" account +npx @gongrzhe/server-gmail-autoauth-mcp auth work + +# Authenticate a "personal" account +npx @gongrzhe/server-gmail-autoauth-mcp auth personal + +# With custom callback URL for cloud environments +npx @gongrzhe/server-gmail-autoauth-mcp auth work https://gmail.example.com/oauth2callback +``` + +Credentials are stored as: +- `~/.gmail-mcp/credentials.json` - "default" account +- `~/.gmail-mcp/credentials-work.json` - "work" account +- `~/.gmail-mcp/credentials-personal.json` - "personal" account + +### Using Multiple Accounts + +All tools accept an optional `account` parameter. Omit it to use the default account: + +```json +// Search default account +{ "query": "from:boss@company.com" } + +// Search work account +{ "query": "from:boss@company.com", "account": "work" } + +// Search personal account +{ "query": "from:friend@gmail.com", "account": "personal" } +``` + +Use `list_accounts` to see all configured accounts. + +### Backwards Compatibility + +- Existing single-account setups work without any changes +- The `GMAIL_CREDENTIALS_PATH` environment variable still works (loads as "default") +- All tools work without the `account` parameter (uses default) + ## Available Tools The server provides the following tools that can be used through Claude Desktop: -### 1. Send Email (`send_email`) +### 1. List Accounts (`list_accounts`) +Lists all configured Gmail accounts. + +```json +{} +``` + +### 2. Send Email (`send_email`) Sends a new email immediately. Supports plain text, HTML, or multipart emails **with optional file attachments**. diff --git a/src/index.ts b/src/index.ts index a2fc967a..69543e8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Configuration paths const CONFIG_DIR = path.join(os.homedir(), '.gmail-mcp'); const OAUTH_PATH = process.env.GMAIL_OAUTH_PATH || path.join(CONFIG_DIR, 'gcp-oauth.keys.json'); -const CREDENTIALS_PATH = process.env.GMAIL_CREDENTIALS_PATH || path.join(CONFIG_DIR, 'credentials.json'); +// Multi-account support +const gmailClients: Map> = new Map(); +const defaultAccount = 'default'; // Type definitions for Gmail API responses interface GmailMessagePart { @@ -56,8 +58,53 @@ interface EmailContent { html: string; } -// OAuth2 configuration -let oauth2Client: OAuth2Client; +function getCredentialsPath(accountName: string): string { + if (accountName === 'default') { + return path.join(CONFIG_DIR, 'credentials.json'); + } + return path.join(CONFIG_DIR, `credentials-${accountName}.json`); +} + +function getOAuth2Client(callbackUrl?: string): OAuth2Client { + const localOAuthPath = path.join(process.cwd(), 'gcp-oauth.keys.json'); + + if (fs.existsSync(localOAuthPath) && !fs.existsSync(OAUTH_PATH)) { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } + fs.copyFileSync(localOAuthPath, OAUTH_PATH); + console.log('OAuth keys found in current directory, copied to global config.'); + } + + if (!fs.existsSync(OAUTH_PATH)) { + console.error('Error: OAuth keys file not found. Please place gcp-oauth.keys.json in current directory or', CONFIG_DIR); + process.exit(1); + } + + const keysContent = JSON.parse(fs.readFileSync(OAUTH_PATH, 'utf8')); + const keys = keysContent.installed || keysContent.web; + + if (!keys) { + console.error('Error: Invalid OAuth keys file format. File should contain either "installed" or "web" credentials.'); + process.exit(1); + } + + return new OAuth2Client( + keys.client_id, + keys.client_secret, + callbackUrl || "http://localhost:3000/oauth2callback" + ); +} + +function getGmailClient(account?: string) { + const name = account || defaultAccount; + const client = gmailClients.get(name); + if (!client) { + const available = Array.from(gmailClients.keys()).join(', '); + throw new Error(`Account "${name}" not found. Available: ${available}`); + } + return client; +} /** * Recursively extract email body content from MIME message parts @@ -93,101 +140,116 @@ function extractEmailContent(messagePart: GmailMessagePart): EmailContent { return { text: textContent, html: htmlContent }; } -async function loadCredentials() { - try { - // Create config directory if it doesn't exist - if (!process.env.GMAIL_OAUTH_PATH && !CREDENTIALS_PATH &&!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); - } - - // Check for OAuth keys in current directory first, then in config directory - const localOAuthPath = path.join(process.cwd(), 'gcp-oauth.keys.json'); - let oauthPath = OAUTH_PATH; - - if (fs.existsSync(localOAuthPath)) { - // If found in current directory, copy to config directory - fs.copyFileSync(localOAuthPath, OAUTH_PATH); - console.log('OAuth keys found in current directory, copied to global config.'); - } - - if (!fs.existsSync(OAUTH_PATH)) { - console.error('Error: OAuth keys file not found. Please place gcp-oauth.keys.json in current directory or', CONFIG_DIR); - process.exit(1); - } - - const keysContent = JSON.parse(fs.readFileSync(OAUTH_PATH, 'utf8')); - const keys = keysContent.installed || keysContent.web; - - if (!keys) { - console.error('Error: Invalid OAuth keys file format. File should contain either "installed" or "web" credentials.'); - process.exit(1); - } - - const callback = process.argv[2] === 'auth' && process.argv[3] - ? process.argv[3] - : "http://localhost:3000/oauth2callback"; - - oauth2Client = new OAuth2Client( - keys.client_id, - keys.client_secret, - callback - ); +async function loadAllAccounts() { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } + + // If GMAIL_CREDENTIALS_PATH env var is set, load just that as "default" + if (process.env.GMAIL_CREDENTIALS_PATH) { + const credPath = process.env.GMAIL_CREDENTIALS_PATH; + if (fs.existsSync(credPath)) { + try { + const client = getOAuth2Client(); + const credentials = JSON.parse(fs.readFileSync(credPath, 'utf8')); + client.setCredentials(credentials); + gmailClients.set('default', google.gmail({ version: 'v1', auth: client })); + } catch (error) { + console.error('Failed to load credentials from GMAIL_CREDENTIALS_PATH:', error); + } + } + return; + } + + // Glob credentials files in config dir + const files = fs.readdirSync(CONFIG_DIR).filter(f => /^credentials(-[\w-]+)?\.json$/.test(f)); + + for (const file of files) { + const match = file.match(/^credentials(?:-([\w-]+))?\.json$/); + if (!match) continue; + + const accountName = match[1] || 'default'; + const credPath = path.join(CONFIG_DIR, file); + + try { + const client = getOAuth2Client(); + const credentials = JSON.parse(fs.readFileSync(credPath, 'utf8')); + client.setCredentials(credentials); + gmailClients.set(accountName, google.gmail({ version: 'v1', auth: client })); + } catch (error) { + console.error(`Failed to load account "${accountName}":`, error); + } + } + + if (gmailClients.size === 0) { + console.error('No Gmail accounts configured. Run "npm run auth" first.'); + } +} - if (fs.existsSync(CREDENTIALS_PATH)) { - const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8')); - oauth2Client.setCredentials(credentials); - } - } catch (error) { - console.error('Error loading credentials:', error); - process.exit(1); - } +async function authenticate(accountName?: string, callbackUrl?: string) { + const name = accountName || 'default'; + const credPath = getCredentialsPath(name); + + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } + + const oauth2Client = getOAuth2Client(callbackUrl); + + const httpServer = http.createServer(); + httpServer.listen(3000); + + return new Promise((resolve, reject) => { + const authUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + scope: [ + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.settings.basic' + ], + }); + + console.log(`Authenticating account "${name}"...`); + console.log('Please visit this URL to authenticate:', authUrl); + open(authUrl); + + httpServer.on('request', async (req, res) => { + if (!req.url?.startsWith('/oauth2callback')) return; + + const url = new URL(req.url, 'http://localhost:3000'); + const code = url.searchParams.get('code'); + + if (!code) { + res.writeHead(400); + res.end('No code provided'); + reject(new Error('No code provided')); + return; + } + + try { + const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + fs.writeFileSync(credPath, JSON.stringify(tokens)); + + res.writeHead(200); + res.end(`Authentication successful for account "${name}"! You can close this window.`); + httpServer.close(); + resolve(); + } catch (error) { + res.writeHead(500); + res.end('Authentication failed'); + reject(error); + } + }); + }); } -async function authenticate() { - const server = http.createServer(); - server.listen(3000); - - return new Promise((resolve, reject) => { - const authUrl = oauth2Client.generateAuthUrl({ - access_type: 'offline', - scope: [ - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.settings.basic' - ], - }); - - console.log('Please visit this URL to authenticate:', authUrl); - open(authUrl); - - server.on('request', async (req, res) => { - if (!req.url?.startsWith('/oauth2callback')) return; - - const url = new URL(req.url, 'http://localhost:3000'); - const code = url.searchParams.get('code'); - - if (!code) { - res.writeHead(400); - res.end('No code provided'); - reject(new Error('No code provided')); - return; - } +// Account parameter added to all tool schemas for multi-account support +const AccountParam = { + account: z.string().optional().describe("Gmail account name. Omit for default. Use list_accounts to see available."), +}; - try { - const { tokens } = await oauth2Client.getToken(code); - oauth2Client.setCredentials(tokens); - fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(tokens)); - - res.writeHead(200); - res.end('Authentication successful! You can close this window.'); - server.close(); - resolve(); - } catch (error) { - res.writeHead(500); - res.end('Authentication failed'); - reject(error); - } - }); - }); +function withAccountParam(schema: z.ZodObject) { + return schema.extend(AccountParam); } // Schema definitions @@ -321,16 +383,24 @@ const DownloadAttachmentSchema = z.object({ // Main function async function main() { - await loadCredentials(); - if (process.argv[2] === 'auth') { - await authenticate(); + let accountName: string | undefined; + let callbackUrl: string | undefined; + + for (const arg of process.argv.slice(3)) { + if (arg.startsWith('http')) { + callbackUrl = arg; + } else { + accountName = arg; + } + } + + await authenticate(accountName, callbackUrl); console.log('Authentication completed successfully'); process.exit(0); } - // Initialize Gmail API - const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + await loadAllAccounts(); // Server implementation const server = new Server({ @@ -344,100 +414,105 @@ async function main() { // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ + { + name: "list_accounts", + description: "Lists all configured Gmail accounts", + inputSchema: zodToJsonSchema(z.object({})), + }, { name: "send_email", description: "Sends a new email", - inputSchema: zodToJsonSchema(SendEmailSchema), + inputSchema: zodToJsonSchema(withAccountParam(SendEmailSchema)), }, { name: "draft_email", description: "Draft a new email", - inputSchema: zodToJsonSchema(SendEmailSchema), + inputSchema: zodToJsonSchema(withAccountParam(SendEmailSchema)), }, { name: "read_email", description: "Retrieves the content of a specific email", - inputSchema: zodToJsonSchema(ReadEmailSchema), + inputSchema: zodToJsonSchema(withAccountParam(ReadEmailSchema)), }, { name: "search_emails", description: "Searches for emails using Gmail search syntax", - inputSchema: zodToJsonSchema(SearchEmailsSchema), + inputSchema: zodToJsonSchema(withAccountParam(SearchEmailsSchema)), }, { name: "modify_email", description: "Modifies email labels (move to different folders)", - inputSchema: zodToJsonSchema(ModifyEmailSchema), + inputSchema: zodToJsonSchema(withAccountParam(ModifyEmailSchema)), }, { name: "delete_email", description: "Permanently deletes an email", - inputSchema: zodToJsonSchema(DeleteEmailSchema), + inputSchema: zodToJsonSchema(withAccountParam(DeleteEmailSchema)), }, { name: "list_email_labels", description: "Retrieves all available Gmail labels", - inputSchema: zodToJsonSchema(ListEmailLabelsSchema), + inputSchema: zodToJsonSchema(withAccountParam(ListEmailLabelsSchema)), }, { name: "batch_modify_emails", description: "Modifies labels for multiple emails in batches", - inputSchema: zodToJsonSchema(BatchModifyEmailsSchema), + inputSchema: zodToJsonSchema(withAccountParam(BatchModifyEmailsSchema)), }, { name: "batch_delete_emails", description: "Permanently deletes multiple emails in batches", - inputSchema: zodToJsonSchema(BatchDeleteEmailsSchema), + inputSchema: zodToJsonSchema(withAccountParam(BatchDeleteEmailsSchema)), }, { name: "create_label", description: "Creates a new Gmail label", - inputSchema: zodToJsonSchema(CreateLabelSchema), + inputSchema: zodToJsonSchema(withAccountParam(CreateLabelSchema)), }, { name: "update_label", description: "Updates an existing Gmail label", - inputSchema: zodToJsonSchema(UpdateLabelSchema), + inputSchema: zodToJsonSchema(withAccountParam(UpdateLabelSchema)), }, { name: "delete_label", description: "Deletes a Gmail label", - inputSchema: zodToJsonSchema(DeleteLabelSchema), + inputSchema: zodToJsonSchema(withAccountParam(DeleteLabelSchema)), }, { name: "get_or_create_label", description: "Gets an existing label by name or creates it if it doesn't exist", - inputSchema: zodToJsonSchema(GetOrCreateLabelSchema), + inputSchema: zodToJsonSchema(withAccountParam(GetOrCreateLabelSchema)), }, { name: "create_filter", description: "Creates a new Gmail filter with custom criteria and actions", - inputSchema: zodToJsonSchema(CreateFilterSchema), + inputSchema: zodToJsonSchema(withAccountParam(CreateFilterSchema)), }, { name: "list_filters", description: "Retrieves all Gmail filters", - inputSchema: zodToJsonSchema(ListFiltersSchema), + inputSchema: zodToJsonSchema(withAccountParam(ListFiltersSchema)), }, { name: "get_filter", description: "Gets details of a specific Gmail filter", - inputSchema: zodToJsonSchema(GetFilterSchema), + inputSchema: zodToJsonSchema(withAccountParam(GetFilterSchema)), }, { name: "delete_filter", description: "Deletes a Gmail filter", - inputSchema: zodToJsonSchema(DeleteFilterSchema), + inputSchema: zodToJsonSchema(withAccountParam(DeleteFilterSchema)), }, { name: "create_filter_from_template", description: "Creates a filter using a pre-defined template for common scenarios", - inputSchema: zodToJsonSchema(CreateFilterFromTemplateSchema), + inputSchema: zodToJsonSchema(withAccountParam(CreateFilterFromTemplateSchema)), }, { name: "download_attachment", description: "Downloads an email attachment to a specified location", - inputSchema: zodToJsonSchema(DownloadAttachmentSchema), + inputSchema: zodToJsonSchema(withAccountParam(DownloadAttachmentSchema)), }, ], })) @@ -445,6 +520,28 @@ async function main() { server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + // Handle list_accounts before resolving gmail client + if (name === 'list_accounts') { + const accounts = Array.from(gmailClients.keys()); + const accountList = accounts.map(a => + a === defaultAccount ? `${a} (default)` : a + ).join('\n'); + + return { + content: [ + { + type: "text", + text: accounts.length > 0 + ? `Configured accounts:\n${accountList}` + : 'No accounts configured. Run auth first.', + }, + ], + }; + } + + const account = (args as any)?.account as string | undefined; + const gmail = getGmailClient(account); + async function handleEmailAction(action: "send" | "draft", validatedArgs: any) { let message: string;