Gmail AutoAuth MCP Server

MIT License
54,356
77
  • Linux
  • Apple
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { google } from 'googleapis'; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { OAuth2Client } from 'google-auth-library'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import http from 'http'; import open from 'open'; import os from 'os'; import {createEmailMessage} from "./utl.js"; 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'); // Type definitions for Gmail API responses interface GmailMessagePart { partId?: string; mimeType?: string; filename?: string; headers?: Array<{ name: string; value: string; }>; body?: { attachmentId?: string; size?: number; data?: string; }; parts?: GmailMessagePart[]; } interface EmailAttachment { id: string; filename: string; mimeType: string; size: number; } interface EmailContent { text: string; html: string; } // OAuth2 configuration let oauth2Client: OAuth2Client; /** * Recursively extract email body content from MIME message parts * Handles complex email structures with nested parts */ function extractEmailContent(messagePart: GmailMessagePart): EmailContent { // Initialize containers for different content types let textContent = ''; let htmlContent = ''; // If the part has a body with data, process it based on MIME type if (messagePart.body && messagePart.body.data) { const content = Buffer.from(messagePart.body.data, 'base64').toString('utf8'); // Store content based on its MIME type if (messagePart.mimeType === 'text/plain') { textContent = content; } else if (messagePart.mimeType === 'text/html') { htmlContent = content; } } // If the part has nested parts, recursively process them if (messagePart.parts && messagePart.parts.length > 0) { for (const part of messagePart.parts) { const { text, html } = extractEmailContent(part); if (text) textContent += text; if (html) htmlContent += html; } } // Return both plain text and HTML content return { text: textContent, html: htmlContent }; } async function loadCredentials() { try { // Create config directory if it doesn't exist if (!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); } oauth2Client = new OAuth2Client( keys.client_id, keys.client_secret, 'http://localhost:3000/oauth2callback' ); 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() { const server = http.createServer(); server.listen(3000); return new Promise<void>((resolve, reject) => { const authUrl = oauth2Client.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/gmail.modify'], }); 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; } 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); } }); }); } // Schema definitions const SendEmailSchema = z.object({ to: z.array(z.string()).describe("List of recipient email addresses"), subject: z.string().describe("Email subject"), body: z.string().describe("Email body content"), cc: z.array(z.string()).optional().describe("List of CC recipients"), bcc: z.array(z.string()).optional().describe("List of BCC recipients"), }); const ReadEmailSchema = z.object({ messageId: z.string().describe("ID of the email message to retrieve"), }); const SearchEmailsSchema = z.object({ query: z.string().describe("Gmail search query (e.g., 'from:example@gmail.com')"), maxResults: z.number().optional().describe("Maximum number of results to return"), }); // Updated schema to include removeLabelIds const ModifyEmailSchema = z.object({ messageId: z.string().describe("ID of the email message to modify"), labelIds: z.array(z.string()).optional().describe("List of label IDs to apply"), addLabelIds: z.array(z.string()).optional().describe("List of label IDs to add to the message"), removeLabelIds: z.array(z.string()).optional().describe("List of label IDs to remove from the message"), }); const DeleteEmailSchema = z.object({ messageId: z.string().describe("ID of the email message to delete"), }); // New schema for listing email labels const ListEmailLabelsSchema = z.object({}).describe("Retrieves all available Gmail labels"); // Main function async function main() { await loadCredentials(); if (process.argv[2] === 'auth') { await authenticate(); console.log('Authentication completed successfully'); process.exit(0); } // Initialize Gmail API const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); // Server implementation const server = new Server({ name: "gmail", version: "1.0.0", capabilities: { tools: {}, }, }); // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "send_email", description: "Sends a new email", inputSchema: zodToJsonSchema(SendEmailSchema), }, { name: "draft_email", description: "Draft a new email", inputSchema: zodToJsonSchema(SendEmailSchema), }, { name: "read_email", description: "Retrieves the content of a specific email", inputSchema: zodToJsonSchema(ReadEmailSchema), }, { name: "search_emails", description: "Searches for emails using Gmail search syntax", inputSchema: zodToJsonSchema(SearchEmailsSchema), }, { name: "modify_email", description: "Modifies email labels (move to different folders)", inputSchema: zodToJsonSchema(ModifyEmailSchema), }, { name: "delete_email", description: "Permanently deletes an email", inputSchema: zodToJsonSchema(DeleteEmailSchema), }, { name: "list_email_labels", description: "Retrieves all available Gmail labels", inputSchema: zodToJsonSchema(ListEmailLabelsSchema), }, ], })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; async function handleEmailAction(action: "send" | "draft", validatedArgs: any) { const message = createEmailMessage(validatedArgs); const encodedMessage = Buffer.from(message).toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); if (action === "send") { const response = await gmail.users.messages.send({ userId: 'me', requestBody: { raw: encodedMessage, }, }); return { content: [ { type: "text", text: `Email sent successfully with ID: ${response.data.id}`, }, ], }; } else { const response = await gmail.users.drafts.create({ userId: 'me', requestBody: { message: { raw: encodedMessage, }, }, }); return { content: [ { type: "text", text: `Email draft created successfully with ID: ${response.data.id}`, }, ], }; } } try { switch (name) { case "send_email": case "draft_email": { const validatedArgs = SendEmailSchema.parse(args); const action = name === "send_email" ? "send" : "draft"; return await handleEmailAction(action, validatedArgs); } case "read_email": { const validatedArgs = ReadEmailSchema.parse(args); const response = await gmail.users.messages.get({ userId: 'me', id: validatedArgs.messageId, format: 'full', }); const headers = response.data.payload?.headers || []; const subject = headers.find(h => h.name?.toLowerCase() === 'subject')?.value || ''; const from = headers.find(h => h.name?.toLowerCase() === 'from')?.value || ''; const to = headers.find(h => h.name?.toLowerCase() === 'to')?.value || ''; const date = headers.find(h => h.name?.toLowerCase() === 'date')?.value || ''; // Extract email content using the recursive function const { text, html } = extractEmailContent(response.data.payload as GmailMessagePart || {}); // Use plain text content if available, otherwise use HTML content // (optionally, you could implement HTML-to-text conversion here) let body = text || html || ''; // If we only have HTML content, add a note for the user const contentTypeNote = !text && html ? '[Note: This email is HTML-formatted. Plain text version not available.]\n\n' : ''; // Get attachment information const attachments: EmailAttachment[] = []; const processAttachmentParts = (part: GmailMessagePart, path: string = '') => { if (part.body && part.body.attachmentId) { const filename = part.filename || `attachment-${part.body.attachmentId}`; attachments.push({ id: part.body.attachmentId, filename: filename, mimeType: part.mimeType || 'application/octet-stream', size: part.body.size || 0 }); } if (part.parts) { part.parts.forEach((subpart: GmailMessagePart) => processAttachmentParts(subpart, `${path}/parts`) ); } }; if (response.data.payload) { processAttachmentParts(response.data.payload as GmailMessagePart); } // Add attachment info to output if any are present const attachmentInfo = attachments.length > 0 ? `\n\nAttachments (${attachments.length}):\n` + attachments.map(a => `- ${a.filename} (${a.mimeType}, ${Math.round(a.size/1024)} KB)`).join('\n') : ''; return { content: [ { type: "text", text: `Subject: ${subject}\nFrom: ${from}\nTo: ${to}\nDate: ${date}\n\n${contentTypeNote}${body}${attachmentInfo}`, }, ], }; } case "search_emails": { const validatedArgs = SearchEmailsSchema.parse(args); const response = await gmail.users.messages.list({ userId: 'me', q: validatedArgs.query, maxResults: validatedArgs.maxResults || 10, }); const messages = response.data.messages || []; const results = await Promise.all( messages.map(async (msg) => { const detail = await gmail.users.messages.get({ userId: 'me', id: msg.id!, format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date'], }); const headers = detail.data.payload?.headers || []; return { id: msg.id, subject: headers.find(h => h.name === 'Subject')?.value || '', from: headers.find(h => h.name === 'From')?.value || '', date: headers.find(h => h.name === 'Date')?.value || '', }; }) ); return { content: [ { type: "text", text: results.map(r => `ID: ${r.id}\nSubject: ${r.subject}\nFrom: ${r.from}\nDate: ${r.date}\n` ).join('\n'), }, ], }; } // Updated implementation for the modify_email handler case "modify_email": { const validatedArgs = ModifyEmailSchema.parse(args); // Prepare request body const requestBody: any = {}; if (validatedArgs.labelIds) { requestBody.addLabelIds = validatedArgs.labelIds; } if (validatedArgs.addLabelIds) { requestBody.addLabelIds = validatedArgs.addLabelIds; } if (validatedArgs.removeLabelIds) { requestBody.removeLabelIds = validatedArgs.removeLabelIds; } await gmail.users.messages.modify({ userId: 'me', id: validatedArgs.messageId, requestBody: requestBody, }); return { content: [ { type: "text", text: `Email ${validatedArgs.messageId} labels updated successfully`, }, ], }; } case "delete_email": { const validatedArgs = DeleteEmailSchema.parse(args); await gmail.users.messages.delete({ userId: 'me', id: validatedArgs.messageId, }); return { content: [ { type: "text", text: `Email ${validatedArgs.messageId} deleted successfully`, }, ], }; } case "list_email_labels": { const response = await gmail.users.labels.list({ userId: 'me', }); const labels = response.data.labels || []; const formattedLabels = labels.map(label => ({ id: label.id, name: label.name, type: label.type, // Include additional useful information about each label messageListVisibility: label.messageListVisibility, labelListVisibility: label.labelListVisibility, // Only include count if it's a system label (as custom labels don't typically have counts) messagesTotal: label.messagesTotal, messagesUnread: label.messagesUnread, color: label.color })); // Group labels by type (system vs user) for better organization const systemLabels = formattedLabels.filter(label => label.type === 'system'); const userLabels = formattedLabels.filter(label => label.type === 'user'); return { content: [ { type: "text", text: `Found ${labels.length} labels (${systemLabels.length} system, ${userLabels.length} user):\n\n` + "System Labels:\n" + systemLabels.map(l => `ID: ${l.id}\nName: ${l.name}\n`).join('\n') + "\nUser Labels:\n" + userLabels.map(l => `ID: ${l.id}\nName: ${l.name}\n`).join('\n') }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error: any) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }); const transport = new StdioServerTransport(); server.connect(transport); } main().catch((error) => { console.error('Server error:', error); process.exit(1); });