uhbarp-gmail-mcp-server
Version:
Gmail MCP Server for managing Gmail through natural language interactions with full OAuth2 authentication support
329 lines (328 loc) • 12.1 kB
JavaScript
import { google } from 'googleapis';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import open from 'open';
import { createServer } from 'http';
import { URL } from 'url';
import { logger } from './api.js';
// Scopes required for Gmail operations
const SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels'
];
// Configuration directory and file paths
const CONFIG_DIR = path.join(os.homedir(), '.gmail-mcp');
const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
const TOKEN_FILE = path.join(CONFIG_DIR, 'token.json');
/**
* Gmail authentication manager class
*/
export class GmailAuth {
constructor() {
this.oAuth2Client = null;
this.credentials = null;
this.ensureConfigDir();
}
/**
* Ensure configuration directory exists
*/
ensureConfigDir() {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
logger.log('Created Gmail MCP configuration directory');
}
}
/**
* Load credentials from file or environment
*/
async loadCredentials() {
try {
// Try environment variables first
if (process.env.GMAIL_CLIENT_ID && process.env.GMAIL_CLIENT_SECRET) {
this.credentials = {
client_id: process.env.GMAIL_CLIENT_ID,
client_secret: process.env.GMAIL_CLIENT_SECRET,
redirect_uris: ['http://localhost:3000/oauth2callback']
};
logger.log('Loaded Gmail credentials from environment variables');
return true;
}
// Try credentials file
if (fs.existsSync(CREDENTIALS_FILE)) {
const credentialsData = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
const parsedCredentials = JSON.parse(credentialsData);
// Handle both desktop and web app credential formats
if (parsedCredentials.installed) {
this.credentials = parsedCredentials.installed;
}
else if (parsedCredentials.web) {
this.credentials = parsedCredentials.web;
}
else {
this.credentials = parsedCredentials;
}
logger.log('Loaded Gmail credentials from file');
return true;
}
return false;
}
catch (error) {
logger.error('Error loading Gmail credentials:', error);
return false;
}
}
/**
* Save credentials to file
*/
async saveCredentials(credentials) {
try {
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2));
logger.log('Saved Gmail credentials to file');
}
catch (error) {
logger.error('Error saving Gmail credentials:', error);
throw new Error('Failed to save credentials');
}
}
/**
* Initialize OAuth2 client
*/
initializeOAuth2Client() {
if (!this.credentials) {
throw new Error('Credentials not loaded');
}
this.oAuth2Client = new google.auth.OAuth2(this.credentials.client_id, this.credentials.client_secret, this.credentials.redirect_uris?.[0] || 'http://localhost:3000/oauth2callback');
return this.oAuth2Client;
}
/**
* Load stored access token
*/
loadStoredToken() {
try {
if (fs.existsSync(TOKEN_FILE)) {
const tokenData = fs.readFileSync(TOKEN_FILE, 'utf8');
return JSON.parse(tokenData);
}
}
catch (error) {
logger.error('Error loading stored token:', error);
}
return null;
}
/**
* Save access token to file
*/
saveToken(token) {
try {
fs.writeFileSync(TOKEN_FILE, JSON.stringify(token, null, 2));
logger.log('Saved Gmail access token');
}
catch (error) {
logger.error('Error saving token:', error);
}
}
/**
* Start local server for OAuth2 callback
*/
startCallbackServer() {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
if (req.url?.startsWith('/oauth2callback')) {
const url = new URL(req.url, 'http://localhost:3000');
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
res.end(`<html><body><h1>Authentication Error</h1><p>${error}</p></body></html>`);
server.close();
reject(new Error(`OAuth2 error: ${error}`));
return;
}
if (code) {
res.end(`<html><body><h1>Authentication Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`);
server.close();
resolve(code);
return;
}
res.end('<html><body><h1>Invalid Request</h1></body></html>');
}
else {
res.end('<html><body><h1>Gmail MCP Server OAuth2</h1><p>Waiting for authentication...</p></body></html>');
}
});
server.listen(3000, () => {
logger.log('OAuth2 callback server started on port 3000');
});
server.on('error', (err) => {
reject(err);
});
});
}
/**
* Perform OAuth2 authentication flow
*/
async authenticate() {
if (!await this.loadCredentials()) {
throw new Error('Gmail credentials not found. Please run with --setup-auth flag first.');
}
const auth = this.initializeOAuth2Client();
// Try to load existing token
const storedToken = this.loadStoredToken();
if (storedToken) {
auth.setCredentials(storedToken);
// Check if token is still valid
try {
await auth.getAccessToken();
logger.log('Using existing valid token');
return auth;
}
catch (error) {
logger.log('Stored token invalid, requesting new token');
}
}
// Generate authentication URL
const authUrl = auth.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent'
});
logger.log('Starting OAuth2 authentication flow');
console.error('\nStarting Gmail authentication...');
console.error('If browser doesn\'t open automatically, visit this URL:');
console.error(authUrl);
try {
// Start callback server and open browser
const codePromise = this.startCallbackServer();
await open(authUrl);
// Wait for authorization code
const code = await codePromise;
// Exchange code for tokens
const { tokens } = await auth.getToken(code);
auth.setCredentials(tokens);
// Save tokens for future use
this.saveToken(tokens);
logger.log('Gmail authentication completed successfully');
console.error('Authentication successful!\n');
return auth;
}
catch (error) {
logger.error('Authentication failed:', error);
throw new Error(`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get authenticated Gmail API client
*/
async getGmailClient() {
const auth = await this.authenticate();
return google.gmail({ version: 'v1', auth });
}
/**
* Prompt user for credentials setup
*/
async setupCredentials() {
console.log('\n=== Gmail MCP Server Setup ===');
console.log('To use this server, you need to set up Google API credentials.');
console.log('\nPlease follow these steps:');
console.log('1. Go to https://console.developers.google.com/');
console.log('2. Create a new project or select existing project');
console.log('3. Enable the Gmail API');
console.log('4. Create credentials (OAuth 2.0 Client ID)');
console.log('5. Set redirect URI to: http://localhost:3000/oauth2callback');
console.log('6. Download the credentials JSON file');
console.log(`7. Save it as: ${CREDENTIALS_FILE}`);
console.log('\nAlternatively, set environment variables:');
console.log('- GMAIL_CLIENT_ID');
console.log('- GMAIL_CLIENT_SECRET');
console.log('\nFor detailed instructions, visit: https://developers.google.com/gmail/api/quickstart/nodejs');
}
/**
* Check if user credentials are configured
*/
async isConfigured() {
try {
fs.accessSync(CREDENTIALS_FILE);
return true;
}
catch {
return false;
}
}
/**
* Check if user is currently authenticated (has valid tokens)
*/
async isAuthenticated() {
try {
fs.accessSync(TOKEN_FILE);
const tokenData = fs.readFileSync(TOKEN_FILE, 'utf8');
const tokens = JSON.parse(tokenData);
// Check if we have both access and refresh tokens
if (!tokens.access_token) {
return false;
}
// If we have a refresh token, we can always get a new access token
if (tokens.refresh_token) {
return true;
}
// Check if access token is still valid (not expired)
if (tokens.expiry_date && tokens.expiry_date > Date.now()) {
return true;
}
return false;
}
catch {
return false;
}
}
/**
* Reset authentication (clear stored tokens)
*/
resetAuth() {
try {
if (fs.existsSync(TOKEN_FILE)) {
fs.unlinkSync(TOKEN_FILE);
logger.log('Cleared stored authentication tokens');
}
}
catch (error) {
logger.error('Error clearing tokens:', error);
}
}
/**
* Get authentication URL without starting the full authentication flow
* This allows for manual authentication control
*/
async getAuthUrl() {
if (!await this.loadCredentials()) {
throw new Error('Gmail credentials not found. Please configure OAuth2 credentials first.');
}
const auth = this.initializeOAuth2Client();
// Generate authentication URL
const authUrl = auth.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent'
});
// Start callback server to handle the response
this.startCallbackServer().then(async (code) => {
try {
// Exchange code for tokens
const { tokens } = await auth.getToken(code);
auth.setCredentials(tokens);
// Save tokens for future use
this.saveToken(tokens);
logger.log('Gmail authentication completed successfully via manual link');
}
catch (error) {
logger.error('Authentication failed via manual link:', error);
}
}).catch((error) => {
logger.error('Callback server error:', error);
});
return authUrl;
}
}
// Export singleton instance
export const gmailAuth = new GmailAuth();