├── .gitignore ├── launch ├── src ├── types │ └── tool-handler.ts ├── services │ └── gauth.ts ├── server.ts └── tools │ ├── calendar.ts │ └── gmail.ts ├── tsconfig.json ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /node_modules/ 3 | /.*.json 4 | -------------------------------------------------------------------------------- /launch: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd "$(dirname "$0")" 5 | 6 | if test ! -e node_modules 7 | then 8 | npm install > /dev/null 2>&1 9 | fi 10 | 11 | if test ! -e dist/server.js 12 | then 13 | npm run build > /dev/null 2>&1 14 | fi 15 | 16 | node dist/server.js 17 | -------------------------------------------------------------------------------- /src/types/tool-handler.ts: -------------------------------------------------------------------------------- 1 | import { Tool, TextContent, ImageContent, EmbeddedResource } from '@modelcontextprotocol/sdk/types.js'; 2 | 3 | export interface ToolHandler { 4 | name: string; 5 | getToolDescription(): Tool; 6 | runTool(args: Record): Promise>; 7 | } 8 | 9 | export const USER_ID_ARG = 'user_id'; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "declaration": true 12 | }, 13 | "include": ["src/**/*"] 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-gmail", 3 | "description": "Google Suite server for Model Context Protocol", 4 | "version": "1.0.0", 5 | "type": "module", 6 | "bin": { 7 | "mcp-gmail": "./dist/server.js" 8 | }, 9 | "files": [ 10 | "dist", 11 | "src", 12 | "package.json", 13 | "package-lock.json", 14 | "tsconfig.json", 15 | "README.md", 16 | "LICENSE" 17 | ], 18 | "engines": { 19 | "node": ">=18" 20 | }, 21 | "dependencies": { 22 | "@modelcontextprotocol/sdk": "latest", 23 | "axios": "^1.6.0", 24 | "dotenv": "^16.0.0", 25 | "googleapis": "^133.0.0" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "^20.17.28", 29 | "ts-node": "^10.9.2", 30 | "typescript": "^5.0.0" 31 | }, 32 | "scripts": { 33 | "prepublishOnly": "npm run build", 34 | "build": "tsc", 35 | "start": "node --loader ts-node/esm src/server.ts", 36 | "dev": "node --loader ts-node/esm src/server.ts" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jean-Christophe Hoelt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/services/gauth.ts: -------------------------------------------------------------------------------- 1 | import { google } from 'googleapis'; 2 | import { OAuth2Client, Credentials } from 'google-auth-library'; 3 | import * as fs from 'fs/promises'; 4 | import * as path from 'path'; 5 | import { fileURLToPath } from 'url'; 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 | 9 | const REDIRECT_URI = 'http://localhost:4100/code'; 10 | const SCOPES = [ 11 | 'openid', 12 | 'https://www.googleapis.com/auth/userinfo.email', 13 | 'https://mail.google.com/', 14 | 'https://www.googleapis.com/auth/calendar' 15 | ]; 16 | 17 | export interface AccountInfo { 18 | email: string; 19 | accountType: string; 20 | extraInfo?: string; 21 | 22 | toDescription(): string; 23 | } 24 | 25 | interface ServerConfig { 26 | gauthFile: string; 27 | accountsFile: string; 28 | credentialsDir: string; 29 | } 30 | 31 | class AccountInfoImpl implements AccountInfo { 32 | constructor( 33 | public email: string, 34 | public accountType: string, 35 | public extraInfo: string = '' 36 | ) {} 37 | 38 | toDescription(): string { 39 | return `Account for email: ${this.email} of type: ${this.accountType}. Extra info for: ${this.extraInfo}`; 40 | } 41 | } 42 | 43 | export class GetCredentialsError extends Error { 44 | constructor(public authorizationUrl: string) { 45 | super('Error getting credentials'); 46 | } 47 | } 48 | 49 | export class CodeExchangeError extends GetCredentialsError {} 50 | export class NoRefreshTokenError extends GetCredentialsError {} 51 | export class NoUserIdError extends Error {} 52 | 53 | export class GAuthService { 54 | private oauth2Client?: OAuth2Client; 55 | private config: ServerConfig; 56 | 57 | constructor(config: ServerConfig) { 58 | this.config = config; 59 | } 60 | 61 | getConfig(): ServerConfig { 62 | return this.config; 63 | } 64 | 65 | async initialize(): Promise { 66 | try { 67 | const gauthPath = path.resolve(process.cwd(), this.config.gauthFile); 68 | const gauthData = await fs.readFile(gauthPath, 'utf8'); 69 | const credentials = JSON.parse(gauthData); 70 | 71 | if (!credentials.installed) { 72 | throw new Error('Invalid OAuth2 credentials format in gauth file'); 73 | } 74 | 75 | this.oauth2Client = new google.auth.OAuth2( 76 | credentials.installed.client_id, 77 | credentials.installed.client_secret, 78 | REDIRECT_URI 79 | ); 80 | } catch (error) { 81 | throw new Error(`Failed to initialize OAuth2 client: ${(error as Error).message}`); 82 | } 83 | } 84 | 85 | getClient(): OAuth2Client { 86 | if (!this.oauth2Client) { 87 | throw new Error('OAuth2 client not initialized. Call initialize() first.'); 88 | } 89 | return this.oauth2Client; 90 | } 91 | 92 | private getCredentialFilename(userId: string): string { 93 | return path.join(this.config.credentialsDir, `.oauth2.${userId}.json`); 94 | } 95 | 96 | async getAccountInfo(): Promise { 97 | try { 98 | const accountsPath = path.resolve(process.cwd(), this.config.accountsFile); 99 | const data = await fs.readFile(accountsPath, 'utf8'); 100 | const { accounts } = JSON.parse(data); 101 | 102 | if (!Array.isArray(accounts)) { 103 | throw new Error('Invalid accounts format in accounts file'); 104 | } 105 | 106 | return accounts.map((acc: any) => new AccountInfoImpl( 107 | acc.email, 108 | acc.account_type, 109 | acc.extra_info 110 | )); 111 | } catch (error) { 112 | console.error('Error reading accounts file:', error); 113 | return []; 114 | } 115 | } 116 | 117 | async getStoredCredentials(userId: string): Promise { 118 | if (!this.oauth2Client) { 119 | return null; 120 | } 121 | 122 | try { 123 | const credFilePath = this.getCredentialFilename(userId); 124 | const data = await fs.readFile(credFilePath, 'utf8'); 125 | const credentials = JSON.parse(data); 126 | this.oauth2Client.setCredentials(credentials); 127 | return this.oauth2Client; 128 | } catch (error) { 129 | console.warn(`No stored OAuth2 credentials yet for user: ${userId}`); 130 | return null; 131 | } 132 | } 133 | 134 | async storeCredentials(client: OAuth2Client, userId: string): Promise { 135 | const credFilePath = this.getCredentialFilename(userId); 136 | await fs.mkdir(path.dirname(credFilePath), { recursive: true }); 137 | await fs.writeFile(credFilePath, JSON.stringify(client.credentials, null, 2)); 138 | } 139 | 140 | async exchangeCode(authorizationCode: string): Promise { 141 | if (!this.oauth2Client) { 142 | throw new Error('OAuth2 client not initialized. Call initialize() first.'); 143 | } 144 | 145 | try { 146 | const { tokens } = await this.oauth2Client.getToken(authorizationCode); 147 | this.oauth2Client.setCredentials(tokens); 148 | return this.oauth2Client; 149 | } catch (error) { 150 | console.error('Error exchanging code:', error); 151 | throw new CodeExchangeError(''); 152 | } 153 | } 154 | 155 | async getUserInfo(client: OAuth2Client): Promise { 156 | const oauth2 = google.oauth2({ version: 'v2', auth: client }); 157 | try { 158 | const { data } = await oauth2.userinfo.get(); 159 | if (data && data.id) { 160 | return data; 161 | } 162 | throw new NoUserIdError(); 163 | } catch (error) { 164 | console.error('Error getting user info:', error); 165 | throw error; 166 | } 167 | } 168 | 169 | async getAuthorizationUrl(emailAddress: string, state: any): Promise { 170 | if (!this.oauth2Client) { 171 | throw new Error('OAuth2 client not initialized. Call initialize() first.'); 172 | } 173 | 174 | return this.oauth2Client.generateAuthUrl({ 175 | access_type: 'offline', 176 | scope: SCOPES, 177 | state: JSON.stringify(state), 178 | prompt: 'consent', 179 | login_hint: emailAddress 180 | }); 181 | } 182 | 183 | async getCredentials(authorizationCode: string, state: any): Promise { 184 | let emailAddress = ''; 185 | try { 186 | const credentials = await this.exchangeCode(authorizationCode); 187 | const userInfo = await this.getUserInfo(credentials); 188 | emailAddress = userInfo.email; 189 | 190 | if (credentials.credentials.refresh_token) { 191 | await this.storeCredentials(credentials, emailAddress); 192 | return credentials; 193 | } else { 194 | const storedCredentials = await this.getStoredCredentials(emailAddress); 195 | if (storedCredentials?.credentials.refresh_token) { 196 | return storedCredentials; 197 | } 198 | } 199 | } catch (error) { 200 | if (error instanceof CodeExchangeError) { 201 | console.error('An error occurred during code exchange.'); 202 | error.authorizationUrl = await this.getAuthorizationUrl(emailAddress, state); 203 | throw error; 204 | } 205 | if (error instanceof NoUserIdError) { 206 | console.error('No user ID could be retrieved.'); 207 | } 208 | const authorizationUrl = await this.getAuthorizationUrl(emailAddress, state); 209 | throw new NoRefreshTokenError(authorizationUrl); 210 | } 211 | 212 | const authorizationUrl = await this.getAuthorizationUrl(emailAddress, state); 213 | throw new NoRefreshTokenError(authorizationUrl); 214 | } 215 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Google Workspace Server 2 | 3 | A Model Context Protocol server for Google Workspace services. This server provides tools to interact with Gmail and Google Calendar through the MCP protocol. 4 | 5 | ## Features 6 | 7 | - **Multiple Google Account Support** 8 | - Use and switch between multiple Google accounts 9 | - Each account can have custom metadata and descriptions 10 | 11 | - **Gmail Integration** 12 | - Query emails with advanced search 13 | - Read full email content and attachments 14 | - Create and manage drafts 15 | - Reply to emails 16 | - Archive emails 17 | - Handle attachments 18 | - Bulk operations support 19 | 20 | - **Calendar Integration** 21 | - List available calendars 22 | - View calendar events 23 | - Create new events 24 | - Delete events 25 | - Support for multiple calendars 26 | - Custom timezone support 27 | 28 | ## Example Prompts 29 | 30 | Try these example prompts with your AI assistant: 31 | 32 | ### Gmail 33 | - "Retrieve my latest unread messages" 34 | - "Search my emails from the Scrum Master" 35 | - "Retrieve all emails from accounting" 36 | - "Take the email about ABC and summarize it" 37 | - "Write a nice response to Alice's last email and upload a draft" 38 | - "Reply to Bob's email with a Thank you note. Store it as draft" 39 | 40 | ### Calendar 41 | - "What do I have on my agenda tomorrow?" 42 | - "Check my private account's Family agenda for next week" 43 | - "I need to plan an event with Tim for 2hrs next week. Suggest some time slots" 44 | 45 | ## Prerequisites 46 | 47 | - Node.js >= 18 48 | - A Google Cloud project with Gmail and Calendar APIs enabled 49 | - OAuth 2.0 credentials for Google APIs 50 | 51 | ## Installation 52 | 53 | 1. Clone the repository: 54 | ```bash 55 | git clone https://github.com/j3k0/mcp-google-workspace.git 56 | cd mcp-google-workspace 57 | ``` 58 | 59 | 2. Install dependencies: 60 | ```bash 61 | npm install 62 | ``` 63 | 64 | 3. Build the TypeScript code: 65 | ```bash 66 | npm run build 67 | ``` 68 | 69 | ## Configuration 70 | 71 | ### OAuth 2.0 Setup 72 | 73 | Google Workspace (G Suite) APIs require OAuth2 authorization. Follow these steps to set up authentication: 74 | 75 | 1. Create OAuth2 Credentials: 76 | - Go to the [Google Cloud Console](https://console.cloud.google.com/) 77 | - Create a new project or select an existing one 78 | - Enable the Gmail API and Google Calendar API for your project 79 | - Go to "Credentials" → "Create Credentials" → "OAuth client ID" 80 | - Select "Desktop app" or "Web application" as the application type 81 | - Configure the OAuth consent screen with required information 82 | - Add authorized redirect URIs (include `http://localhost:4100/code` for local development) 83 | 84 | 2. Required OAuth2 Scopes: 85 | ```json 86 | [ 87 | "openid", 88 | "https://mail.google.com/", 89 | "https://www.googleapis.com/auth/calendar", 90 | "https://www.googleapis.com/auth/userinfo.email" 91 | ] 92 | ``` 93 | 94 | 3. Create a `.gauth.json` file in the project root with your Google OAuth 2.0 credentials: 95 | ```json 96 | { 97 | "installed": { 98 | "client_id": "your_client_id", 99 | "project_id": "your_project_id", 100 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 101 | "token_uri": "https://oauth2.googleapis.com/token", 102 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 103 | "client_secret": "your_client_secret", 104 | "redirect_uris": ["http://localhost:4100/code"] 105 | } 106 | } 107 | ``` 108 | 109 | 4. Create a `.accounts.json` file to specify which Google accounts can use the server: 110 | ```json 111 | { 112 | "accounts": [ 113 | { 114 | "email": "your.email@gmail.com", 115 | "account_type": "personal", 116 | "extra_info": "Primary account with Family Calendar" 117 | } 118 | ] 119 | } 120 | ``` 121 | 122 | You can specify multiple accounts. Make sure they have access in your Google Auth app. The `extra_info` field is especially useful as you can add information here that you want to tell the AI about the account (e.g., whether it has a specific calendar). 123 | 124 | ### Claude Desktop Configuration 125 | 126 | Configure Claude Desktop to use the mcp-google-workspace server: 127 | 128 | On MacOS: Edit `~/Library/Application\ Support/Claude/claude_desktop_config.json` 129 | 130 | On Windows: Edit `%APPDATA%/Claude/claude_desktop_config.json` 131 | 132 |
133 | Development/Unpublished Servers Configuration 134 | 135 | ```json 136 | { 137 | "mcpServers": { 138 | "mcp-google-workspace": { 139 | "command": "/mcp-google-workspace/launch" 140 | } 141 | } 142 | } 143 | ``` 144 |
145 | 146 |
147 | Published Servers Configuration 148 | 149 | ```json 150 | { 151 | "mcpServers": { 152 | "mcp-google-workspace": { 153 | "command": "npx", 154 | "args": [ 155 | "mcp-google-workspace" 156 | ] 157 | } 158 | } 159 | } 160 | ``` 161 |
162 | 163 | ## Usage 164 | 165 | 1. Start the server: 166 | ```bash 167 | npm start 168 | ``` 169 | 170 | Optional arguments: 171 | - `--gauth-file`: Path to the OAuth2 credentials file (default: ./.gauth.json) 172 | - `--accounts-file`: Path to the accounts configuration file (default: ./.accounts.json) 173 | - `--credentials-dir`: Directory to store OAuth credentials (default: current directory) 174 | 175 | 2. The server will start and listen for MCP commands via stdin/stdout. 176 | 177 | 3. On first run for each account, it will: 178 | - Open a browser window for OAuth2 authentication 179 | - Listen on port 4100 for the OAuth2 callback 180 | - Store the credentials for future use in a file named `.oauth2.{email}.json` 181 | 182 | ## Available Tools 183 | 184 | ### Account Management 185 | 186 | 1. `gmail_list_accounts` / `calendar_list_accounts` 187 | - List all configured Google accounts 188 | - View account metadata and descriptions 189 | - No user_id required 190 | 191 | ### Gmail Tools 192 | 193 | 1. `gmail_query_emails` 194 | - Search emails with Gmail's query syntax (e.g., 'is:unread', 'from:example@gmail.com', 'newer_than:2d', 'has:attachment') 195 | - Returns emails in reverse chronological order 196 | - Includes metadata and content summary 197 | 198 | 2. `gmail_get_email` 199 | - Retrieve complete email content by ID 200 | - Includes full message body and attachment info 201 | 202 | 3. `gmail_bulk_get_emails` 203 | - Retrieve multiple emails by ID in a single request 204 | - Efficient for batch processing 205 | 206 | 4. `gmail_create_draft` 207 | - Create new email drafts 208 | - Support for CC recipients 209 | 210 | 5. `gmail_delete_draft` 211 | - Delete draft emails by ID 212 | 213 | 6. `gmail_reply` 214 | - Reply to existing emails 215 | - Option to send immediately or save as draft 216 | - Support for "Reply All" via CC 217 | 218 | 7. `gmail_get_attachment` 219 | - Download email attachments 220 | - Save to disk or return as embedded resource 221 | 222 | 8. `gmail_bulk_save_attachments` 223 | - Save multiple attachments in a single operation 224 | 225 | 9. `gmail_archive` / `gmail_bulk_archive` 226 | - Move emails out of inbox 227 | - Support for individual or bulk operations 228 | 229 | ### Calendar Tools 230 | 231 | 1. `calendar_list` 232 | - List all accessible calendars 233 | - Includes calendar metadata, access roles, and timezone information 234 | 235 | 2. `calendar_get_events` 236 | - Retrieve events in a date range 237 | - Support for multiple calendars 238 | - Filter options (deleted events, max results) 239 | - Timezone customization 240 | 241 | 3. `calendar_create_event` 242 | - Create new calendar events 243 | - Support for attendees and notifications 244 | - Location and description fields 245 | - Timezone handling 246 | 247 | 4. `calendar_delete_event` 248 | - Delete events by ID 249 | - Option for cancellation notifications 250 | 251 | ## Development 252 | 253 | - Source code is in TypeScript under the `src/` directory 254 | - Build output goes to `dist/` directory 255 | - Uses ES modules for better modularity 256 | - Follows Google API best practices 257 | 258 | ### Project Structure 259 | 260 | ``` 261 | mcp-google-workspace/ 262 | ├── src/ 263 | │ ├── server.ts # Main server implementation 264 | │ ├── services/ 265 | │ │ └── gauth.ts # Google authentication service 266 | │ ├── tools/ 267 | │ │ ├── gmail.ts # Gmail tools implementation 268 | │ │ └── calendar.ts # Calendar tools implementation 269 | │ └── types/ 270 | │ └── tool-handler.ts # Common types and interfaces 271 | ├── .gauth.json # OAuth2 credentials 272 | ├── .accounts.json # Account configuration 273 | ├── package.json # Project dependencies 274 | └── tsconfig.json # TypeScript configuration 275 | ``` 276 | 277 | ### Development Commands 278 | 279 | - `npm run build`: Build TypeScript code 280 | - `npm start`: Start the server 281 | - `npm run dev`: Start in development mode with auto-reload 282 | 283 | ## Contributing 284 | 285 | 1. Fork the repository 286 | 2. Create a feature branch 287 | 3. Commit your changes 288 | 4. Push to the branch 289 | 5. Create a Pull Request 290 | 291 | ## License 292 | 293 | MIT License - see LICENSE file for details -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as dotenv from 'dotenv'; 4 | import { parseArgs } from 'node:util'; 5 | import { createServer, IncomingMessage, ServerResponse } from 'http'; 6 | import { parse as parseUrl } from 'url'; 7 | import { parse as parseQueryString } from 'querystring'; 8 | import { spawn } from 'child_process'; 9 | import * as fs from 'fs/promises'; 10 | import * as path from 'path'; 11 | 12 | // Load environment variables from .env file as fallback 13 | dotenv.config(); 14 | 15 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 16 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 17 | import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; 18 | import { GmailTools } from './tools/gmail.js'; 19 | import { CalendarTools } from './tools/calendar.js'; 20 | import { GAuthService } from './services/gauth.js'; 21 | import { ToolHandler } from './types/tool-handler.js'; 22 | 23 | // Configure logging 24 | const logger = { 25 | info: (msg: string) => console.error(`[INFO] ${msg}`), 26 | error: (msg: string, error?: Error) => { 27 | console.error(`[ERROR] ${msg}`); 28 | if (error?.stack) console.error(error.stack); 29 | } 30 | }; 31 | 32 | interface ServerConfig { 33 | gauthFile: string; 34 | accountsFile: string; 35 | credentialsDir: string; 36 | } 37 | 38 | class OAuthServer { 39 | private server: ReturnType; 40 | private gauth: GAuthService; 41 | 42 | constructor(gauth: GAuthService) { 43 | this.gauth = gauth; 44 | this.server = createServer(this.handleRequest.bind(this)); 45 | } 46 | 47 | private async handleRequest(req: IncomingMessage, res: ServerResponse) { 48 | const url = parseUrl(req.url || ''); 49 | if (url.pathname !== '/code') { 50 | res.writeHead(404); 51 | res.end(); 52 | return; 53 | } 54 | 55 | const query = parseQueryString(url.query || ''); 56 | if (!query.code) { 57 | res.writeHead(400); 58 | res.end(); 59 | return; 60 | } 61 | 62 | res.writeHead(200); 63 | res.write('Auth successful! You can close the tab!'); 64 | res.end(); 65 | 66 | const storage = {}; 67 | await this.gauth.getCredentials(query.code as string, storage); 68 | this.server.close(); 69 | } 70 | 71 | listen(port: number = 4100) { 72 | this.server.listen(port); 73 | } 74 | } 75 | 76 | class GoogleWorkspaceServer { 77 | private server: Server; 78 | private gauth: GAuthService; 79 | private tools!: { 80 | gmail: GmailTools; 81 | calendar: CalendarTools; 82 | }; 83 | 84 | constructor(config: ServerConfig) { 85 | logger.info('Starting Google Workspace MCP Server...'); 86 | 87 | // Initialize services 88 | this.gauth = new GAuthService(config); 89 | 90 | // Initialize server 91 | this.server = new Server( 92 | { name: "mcp-google-workspace", version: "1.0.0" }, 93 | { capabilities: { tools: {} } } 94 | ); 95 | } 96 | 97 | private async initializeTools() { 98 | // Initialize tools after OAuth2 client is ready 99 | this.tools = { 100 | gmail: new GmailTools(this.gauth), 101 | calendar: new CalendarTools(this.gauth) 102 | }; 103 | 104 | this.setupHandlers(); 105 | } 106 | 107 | private async startAuthFlow(userId: string) { 108 | const authUrl = await this.gauth.getAuthorizationUrl(userId, {}); 109 | spawn('open', [authUrl]); 110 | 111 | const oauthServer = new OAuthServer(this.gauth); 112 | oauthServer.listen(4100); 113 | } 114 | 115 | private async setupOAuth2(userId: string) { 116 | const accounts = await this.gauth.getAccountInfo(); 117 | if (accounts.length === 0) { 118 | throw new Error("No accounts specified in .gauth.json"); 119 | } 120 | if (!accounts.some(a => a.email === userId)) { 121 | throw new Error(`Account for email: ${userId} not specified in .gauth.json`); 122 | } 123 | 124 | let credentials = await this.gauth.getStoredCredentials(userId); 125 | if (!credentials) { 126 | await this.startAuthFlow(userId); 127 | } else { 128 | const tokens = credentials.credentials; 129 | if (tokens.expiry_date && tokens.expiry_date < Date.now()) { 130 | logger.error("credentials expired, trying refresh"); 131 | } 132 | 133 | // Refresh access token if needed 134 | const userInfo = await this.gauth.getUserInfo(credentials); 135 | await this.gauth.storeCredentials(credentials, userId); 136 | } 137 | } 138 | 139 | private setupHandlers() { 140 | // List available tools 141 | this.server.setRequestHandler(ListToolsRequestSchema, async () => { 142 | return { 143 | tools: [ 144 | ...this.tools.gmail.getTools(), 145 | ...this.tools.calendar.getTools() 146 | ] 147 | }; 148 | }); 149 | 150 | // Handle tool calls 151 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 152 | const { name, arguments: args } = request.params; 153 | 154 | try { 155 | if (typeof args !== 'object' || args === null) { 156 | return { 157 | isError: true, 158 | content: [{ type: "text", text: JSON.stringify({ 159 | error: "arguments must be dictionary", 160 | success: false 161 | }, null, 2) }] 162 | }; 163 | } 164 | 165 | // Special case for list_accounts tools which don't require user_id 166 | if (name === 'gmail_list_accounts' || name === 'calendar_list_accounts') { 167 | try { 168 | // Route tool calls to appropriate handler 169 | let result; 170 | if (name.startsWith('gmail_')) { 171 | result = await this.tools.gmail.handleTool(name, args); 172 | } else if (name.startsWith('calendar_')) { 173 | result = await this.tools.calendar.handleTool(name, args); 174 | } else { 175 | throw new Error(`Unknown tool: ${name}`); 176 | } 177 | 178 | return { content: result }; 179 | } catch (error) { 180 | logger.error(`Error handling tool ${name}:`, error as Error); 181 | return { 182 | isError: true, 183 | content: [{ type: "text", text: JSON.stringify({ 184 | error: `Tool execution failed: ${(error as Error).message}`, 185 | success: false 186 | }, null, 2) }] 187 | }; 188 | } 189 | } 190 | 191 | // For all other tools, require user_id 192 | if (!args.user_id) { 193 | return { 194 | isError: true, 195 | content: [{ type: "text", text: JSON.stringify({ 196 | error: "user_id argument is missing in dictionary", 197 | success: false 198 | }, null, 2) }] 199 | }; 200 | } 201 | 202 | try { 203 | await this.setupOAuth2(args.user_id as string); 204 | } catch (error) { 205 | logger.error("OAuth2 setup failed:", error as Error); 206 | return { 207 | isError: true, 208 | content: [{ type: "text", text: JSON.stringify({ 209 | error: `OAuth2 setup failed: ${(error as Error).message}`, 210 | success: false 211 | }, null, 2) }] 212 | }; 213 | } 214 | 215 | // Route tool calls to appropriate handler 216 | try { 217 | let result; 218 | if (name.startsWith('gmail_')) { 219 | result = await this.tools.gmail.handleTool(name, args); 220 | } else if (name.startsWith('calendar_')) { 221 | result = await this.tools.calendar.handleTool(name, args); 222 | } else { 223 | throw new Error(`Unknown tool: ${name}`); 224 | } 225 | 226 | return { content: result }; 227 | } catch (error) { 228 | logger.error(`Error handling tool ${name}:`, error as Error); 229 | return { 230 | isError: true, 231 | content: [{ type: "text", text: JSON.stringify({ 232 | error: `Tool execution failed: ${(error as Error).message}`, 233 | success: false 234 | }, null, 2) }] 235 | }; 236 | } 237 | } catch (error) { 238 | logger.error("Unexpected error in call_tool:", error as Error); 239 | return { 240 | isError: true, 241 | content: [{ type: "text", text: JSON.stringify({ 242 | error: `Unexpected error: ${(error as Error).message}`, 243 | success: false 244 | }, null, 2) }] 245 | }; 246 | } 247 | }); 248 | } 249 | 250 | async start() { 251 | try { 252 | // Initialize OAuth2 client first 253 | await this.gauth.initialize(); 254 | 255 | // Initialize tools after OAuth2 is ready 256 | await this.initializeTools(); 257 | 258 | // Check for existing credentials 259 | const accounts = await this.gauth.getAccountInfo(); 260 | for (const account of accounts) { 261 | const creds = await this.gauth.getStoredCredentials(account.email); 262 | if (creds) { 263 | logger.info(`found credentials for ${account.email}`); 264 | } 265 | } 266 | 267 | // Start server 268 | const transport = new StdioServerTransport(); 269 | logger.info('Connecting to transport...'); 270 | await this.server.connect(transport); 271 | logger.info('Server ready!'); 272 | } catch (error) { 273 | logger.error("Server error:", error as Error); 274 | throw error; // Let the error propagate to stop the server 275 | } 276 | } 277 | } 278 | 279 | // Parse command line arguments 280 | const { values } = parseArgs({ 281 | args: process.argv.slice(2), 282 | options: { 283 | 'gauth-file': { type: 'string', default: './.gauth.json' }, 284 | 'accounts-file': { type: 'string', default: './.accounts.json' }, 285 | 'credentials-dir': { type: 'string', default: '.' } 286 | } 287 | }); 288 | 289 | const config: ServerConfig = { 290 | gauthFile: values['gauth-file'] as string, 291 | accountsFile: values['accounts-file'] as string, 292 | credentialsDir: values['credentials-dir'] as string 293 | }; 294 | 295 | // Start the server 296 | const server = new GoogleWorkspaceServer(config); 297 | server.start().catch(error => { 298 | logger.error("Fatal error:", error as Error); 299 | process.exit(1); 300 | }); -------------------------------------------------------------------------------- /src/tools/calendar.ts: -------------------------------------------------------------------------------- 1 | import { Tool, TextContent, ImageContent, EmbeddedResource } from '@modelcontextprotocol/sdk/types.js'; 2 | import { GAuthService } from '../services/gauth.js'; 3 | import { google } from 'googleapis'; 4 | import { USER_ID_ARG } from '../types/tool-handler.js'; 5 | 6 | const CALENDAR_ID_ARG = 'calendar_id'; 7 | 8 | export class CalendarTools { 9 | private calendar: ReturnType; 10 | 11 | constructor(private gauth: GAuthService) { 12 | this.calendar = google.calendar({ version: 'v3', auth: this.gauth.getClient() }); 13 | } 14 | 15 | getTools(): Tool[] { 16 | return [ 17 | { 18 | name: 'calendar_list_accounts', 19 | description: 'Lists all configured Google accounts that can be used with the calendar tools. This tool does not require a user_id as it lists available accounts before selection.', 20 | inputSchema: { 21 | type: 'object', 22 | properties: {}, 23 | additionalProperties: false, 24 | required: [] 25 | } 26 | }, 27 | { 28 | name: 'calendar_list', 29 | description: `Lists all calendars accessible by the user. 30 | Call it before any other tool whenever the user specifies a particular agenda (Family, Holidays, etc.). 31 | Returns detailed calendar metadata including access roles and timezone information.`, 32 | inputSchema: { 33 | type: 'object', 34 | properties: { 35 | [USER_ID_ARG]: { 36 | type: 'string', 37 | description: 'Email address of the user' 38 | } 39 | }, 40 | required: [USER_ID_ARG] 41 | } 42 | }, 43 | { 44 | name: 'calendar_get_events', 45 | description: 'Retrieves calendar events from the user\'s Google Calendar within a specified time range.', 46 | inputSchema: { 47 | type: 'object', 48 | properties: { 49 | [USER_ID_ARG]: { 50 | type: 'string', 51 | description: 'Email address of the user' 52 | }, 53 | [CALENDAR_ID_ARG]: { 54 | type: 'string', 55 | description: 'Calendar ID to fetch events from. Use "primary" for the primary calendar.', 56 | default: 'primary' 57 | }, 58 | time_min: { 59 | type: 'string', 60 | description: 'Start time in RFC3339 format (e.g. 2024-12-01T00:00:00Z). Defaults to current time if not specified.' 61 | }, 62 | time_max: { 63 | type: 'string', 64 | description: 'End time in RFC3339 format (e.g. 2024-12-31T23:59:59Z). Optional.' 65 | }, 66 | max_results: { 67 | type: 'integer', 68 | description: 'Maximum number of events to return (1-2500)', 69 | minimum: 1, 70 | maximum: 2500, 71 | default: 250 72 | }, 73 | show_deleted: { 74 | type: 'boolean', 75 | description: 'Whether to include deleted events', 76 | default: false 77 | }, 78 | timezone: { 79 | type: 'string', 80 | description: 'Timezone for the events (e.g. \'America/New_York\'). Defaults to UTC.', 81 | default: 'UTC' 82 | } 83 | }, 84 | required: [USER_ID_ARG] 85 | } 86 | }, 87 | { 88 | name: 'calendar_create_event', 89 | description: 'Creates a new event in the specified Google Calendar.', 90 | inputSchema: { 91 | type: 'object', 92 | properties: { 93 | [USER_ID_ARG]: { 94 | type: 'string', 95 | description: 'Email address of the user' 96 | }, 97 | [CALENDAR_ID_ARG]: { 98 | type: 'string', 99 | description: 'Calendar ID to create the event in. Use "primary" for the primary calendar.', 100 | default: 'primary' 101 | }, 102 | summary: { 103 | type: 'string', 104 | description: 'Title of the event' 105 | }, 106 | start_time: { 107 | type: 'string', 108 | description: 'Start time in RFC3339 format (e.g. 2024-12-01T10:00:00Z)' 109 | }, 110 | end_time: { 111 | type: 'string', 112 | description: 'End time in RFC3339 format (e.g. 2024-12-01T11:00:00Z)' 113 | }, 114 | location: { 115 | type: 'string', 116 | description: 'Location of the event (optional)' 117 | }, 118 | description: { 119 | type: 'string', 120 | description: 'Description or notes for the event (optional)' 121 | }, 122 | attendees: { 123 | type: 'array', 124 | items: { 125 | type: 'string' 126 | }, 127 | description: 'List of attendee email addresses (optional)' 128 | }, 129 | send_notifications: { 130 | type: 'boolean', 131 | description: 'Whether to send notifications to attendees', 132 | default: true 133 | }, 134 | timezone: { 135 | type: 'string', 136 | description: 'Timezone for the event (e.g. \'America/New_York\'). Defaults to UTC.', 137 | default: 'UTC' 138 | } 139 | }, 140 | required: [USER_ID_ARG, 'summary', 'start_time', 'end_time'] 141 | } 142 | }, 143 | { 144 | name: 'calendar_delete_event', 145 | description: 'Deletes an event from the specified Google Calendar.', 146 | inputSchema: { 147 | type: 'object', 148 | properties: { 149 | [USER_ID_ARG]: { 150 | type: 'string', 151 | description: 'Email address of the user' 152 | }, 153 | [CALENDAR_ID_ARG]: { 154 | type: 'string', 155 | description: 'Calendar ID containing the event. Use "primary" for the primary calendar.', 156 | default: 'primary' 157 | }, 158 | event_id: { 159 | type: 'string', 160 | description: 'The ID of the calendar event to delete' 161 | }, 162 | send_notifications: { 163 | type: 'boolean', 164 | description: 'Whether to send cancellation notifications to attendees', 165 | default: true 166 | } 167 | }, 168 | required: [USER_ID_ARG, 'event_id'] 169 | } 170 | } 171 | ]; 172 | } 173 | 174 | async handleTool(name: string, args: Record): Promise> { 175 | switch (name) { 176 | case 'calendar_list_accounts': 177 | return this.listAccounts(); 178 | case 'calendar_list': 179 | return this.listCalendars(args); 180 | case 'calendar_get_events': 181 | return this.getCalendarEvents(args); 182 | case 'calendar_create_event': 183 | return this.createCalendarEvent(args); 184 | case 'calendar_delete_event': 185 | return this.deleteCalendarEvent(args); 186 | default: 187 | throw new Error(`Unknown tool: ${name}`); 188 | } 189 | } 190 | 191 | private async listAccounts(): Promise> { 192 | try { 193 | const accounts = await this.gauth.getAccountInfo(); 194 | const accountList = accounts.map(account => ({ 195 | email: account.email, 196 | accountType: account.accountType, 197 | extraInfo: account.extraInfo, 198 | description: account.toDescription() 199 | })); 200 | 201 | if (accountList.length === 0) { 202 | return [{ 203 | type: 'text', 204 | text: JSON.stringify({ 205 | message: 'No accounts configured. Please check your .accounts.json file.', 206 | accounts: [] 207 | }, null, 2) 208 | }]; 209 | } 210 | 211 | return [{ 212 | type: 'text', 213 | text: JSON.stringify({ 214 | message: `Found ${accountList.length} configured account(s)`, 215 | accounts: accountList 216 | }, null, 2) 217 | }]; 218 | } catch (error) { 219 | console.error('Error listing accounts:', error); 220 | return [{ 221 | type: 'text', 222 | text: JSON.stringify({ 223 | error: `Failed to list accounts: ${(error as Error).message}`, 224 | accounts: [] 225 | }, null, 2) 226 | }]; 227 | } 228 | } 229 | 230 | private async listCalendars(args: Record): Promise> { 231 | const userId = args[USER_ID_ARG]; 232 | if (!userId) { 233 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 234 | } 235 | 236 | try { 237 | console.error('Attempting to list calendars...'); 238 | const response = await this.calendar.calendarList.list(); 239 | const calendars = response.data.items?.map(calendar => ({ 240 | id: calendar.id, 241 | summary: calendar.summary, 242 | primary: calendar.primary || false, 243 | timeZone: calendar.timeZone, 244 | etag: calendar.etag, 245 | accessRole: calendar.accessRole 246 | })) || []; 247 | 248 | console.error(`Successfully retrieved ${calendars.length} calendars`); 249 | return [{ 250 | type: 'text', 251 | text: JSON.stringify(calendars, null, 2) 252 | }]; 253 | } catch (error) { 254 | console.error('Error listing calendars:', error); 255 | throw error; 256 | } 257 | } 258 | 259 | private async getCalendarEvents(args: Record): Promise> { 260 | const userId = args[USER_ID_ARG]; 261 | if (!userId) { 262 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 263 | } 264 | 265 | try { 266 | const timeMin = args.time_min || new Date().toISOString(); 267 | const maxResults = Math.min(Math.max(1, args.max_results || 250), 2500); 268 | const calendarId = args[CALENDAR_ID_ARG] || 'primary'; 269 | const timezone = args.timezone || 'UTC'; 270 | 271 | const params = { 272 | calendarId, 273 | timeMin, 274 | maxResults, 275 | singleEvents: true, 276 | orderBy: 'startTime' as const, 277 | showDeleted: args.show_deleted || false 278 | }; 279 | 280 | if (args.time_max) { 281 | Object.assign(params, { timeMax: args.time_max }); 282 | } 283 | 284 | const response = await this.calendar.events.list(params); 285 | const events = response.data.items?.map(event => ({ 286 | id: event.id, 287 | summary: event.summary, 288 | description: event.description, 289 | start: event.start, 290 | end: event.end, 291 | status: event.status, 292 | creator: event.creator, 293 | organizer: event.organizer, 294 | attendees: event.attendees, 295 | location: event.location, 296 | hangoutLink: event.hangoutLink, 297 | conferenceData: event.conferenceData, 298 | recurringEventId: event.recurringEventId 299 | })) || []; 300 | 301 | return [{ 302 | type: 'text', 303 | text: JSON.stringify(events, null, 2) 304 | }]; 305 | } catch (error) { 306 | console.error('Error getting calendar events:', error); 307 | throw error; 308 | } 309 | } 310 | 311 | private async createCalendarEvent(args: Record): Promise> { 312 | const userId = args[USER_ID_ARG]; 313 | const required = ['summary', 'start_time', 'end_time']; 314 | 315 | if (!userId) { 316 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 317 | } 318 | if (!required.every(key => key in args)) { 319 | throw new Error(`Missing required arguments: ${required.filter(key => !(key in args)).join(', ')}`); 320 | } 321 | 322 | try { 323 | const timezone = args.timezone || 'UTC'; 324 | const event = { 325 | summary: args.summary, 326 | location: args.location, 327 | description: args.description, 328 | start: { 329 | dateTime: args.start_time, 330 | timeZone: timezone 331 | }, 332 | end: { 333 | dateTime: args.end_time, 334 | timeZone: timezone 335 | }, 336 | attendees: args.attendees?.map((email: string) => ({ email })) 337 | }; 338 | 339 | const response = await this.calendar.events.insert({ 340 | calendarId: args[CALENDAR_ID_ARG] || 'primary', 341 | requestBody: event, 342 | sendUpdates: args.send_notifications ? 'all' : 'none' 343 | }); 344 | 345 | return [{ 346 | type: 'text', 347 | text: JSON.stringify(response.data, null, 2) 348 | }]; 349 | } catch (error) { 350 | console.error('Error creating calendar event:', error); 351 | throw error; 352 | } 353 | } 354 | 355 | private async deleteCalendarEvent(args: Record): Promise> { 356 | const userId = args[USER_ID_ARG]; 357 | const eventId = args.event_id; 358 | 359 | if (!userId) { 360 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 361 | } 362 | if (!eventId) { 363 | throw new Error('Missing required argument: event_id'); 364 | } 365 | 366 | try { 367 | await this.calendar.events.delete({ 368 | calendarId: args[CALENDAR_ID_ARG] || 'primary', 369 | eventId: eventId, 370 | sendUpdates: args.send_notifications ? 'all' : 'none' 371 | }); 372 | 373 | return [{ 374 | type: 'text', 375 | text: JSON.stringify({ 376 | success: true, 377 | message: 'Event successfully deleted' 378 | }, null, 2) 379 | }]; 380 | } catch (error) { 381 | console.error('Error deleting calendar event:', error); 382 | return [{ 383 | type: 'text', 384 | text: JSON.stringify({ 385 | success: false, 386 | message: 'Failed to delete event' 387 | }, null, 2) 388 | }]; 389 | } 390 | } 391 | } -------------------------------------------------------------------------------- /src/tools/gmail.ts: -------------------------------------------------------------------------------- 1 | import { Tool, TextContent, ImageContent, EmbeddedResource } from '@modelcontextprotocol/sdk/types.js'; 2 | import { GAuthService } from '../services/gauth.js'; 3 | import { google } from 'googleapis'; 4 | import { USER_ID_ARG } from '../types/tool-handler.js'; 5 | import { Buffer } from 'buffer'; 6 | import fs from 'fs'; 7 | 8 | function decodeBase64Data(fileData: string): Buffer { 9 | const standardBase64Data = fileData.replace(/-/g, '+').replace(/_/g, '/'); 10 | const padding = '='.repeat((4 - standardBase64Data.length % 4) % 4); 11 | return Buffer.from(standardBase64Data + padding, 'base64'); 12 | } 13 | 14 | export class GmailTools { 15 | private gmail: ReturnType; 16 | 17 | constructor(private gauth: GAuthService) { 18 | this.gmail = google.gmail({ version: 'v1', auth: this.gauth.getClient() }); 19 | } 20 | 21 | // Helper methods for email content extraction 22 | private decodeBase64UrlString(base64UrlString: string): string { 23 | try { 24 | const base64String = base64UrlString.replace(/-/g, '+').replace(/_/g, '/'); 25 | const padding = '='.repeat((4 - base64String.length % 4) % 4); 26 | const base64 = base64String + padding; 27 | return Buffer.from(base64, 'base64').toString('utf-8'); 28 | } catch (error) { 29 | console.error('Error decoding base64 string:', error); 30 | return '[Error decoding content]'; 31 | } 32 | } 33 | 34 | private extractEmailText(payload: any): string { 35 | // For simple text emails 36 | if (payload.mimeType === 'text/plain' && payload.body?.data) { 37 | return this.decodeBase64UrlString(payload.body.data); 38 | } 39 | 40 | // For HTML-only emails, we'll still return the HTML content 41 | if (payload.mimeType === 'text/html' && payload.body?.data) { 42 | return this.decodeBase64UrlString(payload.body.data); 43 | } 44 | 45 | // For multipart emails, look for text/plain part first, then text/html 46 | if (payload.parts && Array.isArray(payload.parts)) { 47 | // First try to find text/plain part 48 | const textPart = payload.parts.find((part: any) => part.mimeType === 'text/plain'); 49 | if (textPart && textPart.body?.data) { 50 | return this.decodeBase64UrlString(textPart.body.data); 51 | } 52 | 53 | // If no text/plain, try text/html 54 | const htmlPart = payload.parts.find((part: any) => part.mimeType === 'text/html'); 55 | if (htmlPart && htmlPart.body?.data) { 56 | return this.decodeBase64UrlString(htmlPart.body.data); 57 | } 58 | 59 | // Recursively check nested multipart structures 60 | for (const part of payload.parts) { 61 | if (part.parts) { 62 | const nestedText = this.extractEmailText(part); 63 | if (nestedText) { 64 | return nestedText; 65 | } 66 | } 67 | } 68 | } 69 | 70 | return ''; 71 | } 72 | 73 | private extractEmailHeaders(headers: any[]): Record { 74 | const result: Record = {}; 75 | const importantHeaders = ['from', 'to', 'cc', 'bcc', 'subject', 'date', 'reply-to']; 76 | 77 | if (headers && Array.isArray(headers)) { 78 | headers.forEach(header => { 79 | if (header.name && header.value) { 80 | const headerName = header.name.toLowerCase(); 81 | if (importantHeaders.includes(headerName)) { 82 | result[headerName] = header.value; 83 | } 84 | } 85 | }); 86 | } 87 | return result; 88 | } 89 | 90 | getTools(): Tool[] { 91 | return ([ 92 | { 93 | name: 'gmail_list_accounts', 94 | description: 'Lists all configured Google accounts that can be used with the Gmail tools. This tool does not require a user_id as it lists available accounts before selection.', 95 | inputSchema: { 96 | type: 'object', 97 | properties: {}, 98 | additionalProperties: false, 99 | required: [] 100 | } 101 | } as Tool, 102 | { 103 | name: 'gmail_query_emails', 104 | description: `Query Gmail emails based on an optional search query. 105 | Returns emails in reverse chronological order (newest first). 106 | Returns metadata such as subject and also a short summary of the content.`, 107 | inputSchema: { 108 | type: 'object', 109 | properties: { 110 | [USER_ID_ARG]: { 111 | type: 'string', 112 | description: 'Email address of the user' 113 | }, 114 | query: { 115 | type: 'string', 116 | description: `Gmail search query (optional). Examples: 117 | - a $string: Search email body, subject, and sender information for $string 118 | - 'is:unread' for unread emails 119 | - 'from:example@gmail.com' for emails from a specific sender 120 | - 'newer_than:2d' for emails from last 2 days 121 | - 'has:attachment' for emails with attachments 122 | If not provided, returns recent emails without filtering.` 123 | }, 124 | max_results: { 125 | type: 'integer', 126 | description: 'Maximum number of emails to retrieve (1-500)', 127 | minimum: 1, 128 | maximum: 500, 129 | default: 100 130 | } 131 | }, 132 | required: [USER_ID_ARG] 133 | } 134 | }, 135 | { 136 | name: 'gmail_get_email', 137 | description: 'Retrieves a complete Gmail email message by its ID, including the full message body and attachment IDs.', 138 | inputSchema: { 139 | type: 'object', 140 | properties: { 141 | [USER_ID_ARG]: { 142 | type: 'string', 143 | description: 'Email address of the user' 144 | }, 145 | email_id: { 146 | type: 'string', 147 | description: 'The ID of the Gmail message to retrieve' 148 | } 149 | }, 150 | required: ['email_id', USER_ID_ARG] 151 | } 152 | }, 153 | { 154 | name: 'gmail_bulk_get_emails', 155 | description: 'Retrieves multiple Gmail email messages by their IDs in a single request, including the full message bodies and attachment IDs.', 156 | inputSchema: { 157 | type: 'object', 158 | properties: { 159 | [USER_ID_ARG]: { 160 | type: 'string', 161 | description: 'Email address of the user' 162 | }, 163 | email_ids: { 164 | type: 'array', 165 | items: { 166 | type: 'string' 167 | }, 168 | description: 'List of Gmail message IDs to retrieve' 169 | } 170 | }, 171 | required: ['email_ids', USER_ID_ARG] 172 | } 173 | }, 174 | { 175 | name: 'gmail_create_draft', 176 | description: `Creates a draft email message from scratch in Gmail with specified recipient, subject, body, and optional CC recipients. 177 | 178 | Do NOT use this tool when you want to draft or send a REPLY to an existing message. This tool does NOT include any previous message content. Use the reply_gmail_email tool 179 | with send=false instead.`, 180 | inputSchema: { 181 | type: 'object', 182 | properties: { 183 | [USER_ID_ARG]: { 184 | type: 'string', 185 | description: 'Email address of the user' 186 | }, 187 | to: { 188 | type: 'string', 189 | description: 'Email address of the recipient' 190 | }, 191 | subject: { 192 | type: 'string', 193 | description: 'Subject line of the email' 194 | }, 195 | body: { 196 | type: 'string', 197 | description: 'Body content of the email' 198 | }, 199 | cc: { 200 | type: 'array', 201 | items: { 202 | type: 'string' 203 | }, 204 | description: 'Optional list of email addresses to CC' 205 | } 206 | }, 207 | required: ['to', 'subject', 'body', USER_ID_ARG] 208 | } 209 | }, 210 | { 211 | name: 'gmail_delete_draft', 212 | description: 'Deletes a Gmail draft message by its ID. This action cannot be undone.', 213 | inputSchema: { 214 | type: 'object', 215 | properties: { 216 | [USER_ID_ARG]: { 217 | type: 'string', 218 | description: 'Email address of the user' 219 | }, 220 | draft_id: { 221 | type: 'string', 222 | description: 'The ID of the draft to delete' 223 | } 224 | }, 225 | required: ['draft_id', USER_ID_ARG] 226 | } 227 | }, 228 | { 229 | name: 'gmail_reply', 230 | description: `Creates a reply to an existing Gmail email message and either sends it or saves as draft. 231 | 232 | Use this tool if you want to draft a reply. Use the 'cc' argument if you want to perform a "reply all".`, 233 | inputSchema: { 234 | type: 'object', 235 | properties: { 236 | [USER_ID_ARG]: { 237 | type: 'string', 238 | description: 'Email address of the user' 239 | }, 240 | original_message_id: { 241 | type: 'string', 242 | description: 'The ID of the Gmail message to reply to' 243 | }, 244 | reply_body: { 245 | type: 'string', 246 | description: 'The body content of your reply message' 247 | }, 248 | send: { 249 | type: 'boolean', 250 | description: 'If true, sends the reply immediately. If false, saves as draft.', 251 | default: false 252 | }, 253 | cc: { 254 | type: 'array', 255 | items: { 256 | type: 'string' 257 | }, 258 | description: 'Optional list of email addresses to CC on the reply' 259 | } 260 | }, 261 | required: ['original_message_id', 'reply_body', USER_ID_ARG] 262 | } 263 | }, 264 | { 265 | name: 'gmail_get_attachment', 266 | description: 'Retrieves a Gmail attachment by its ID.', 267 | inputSchema: { 268 | type: 'object', 269 | properties: { 270 | [USER_ID_ARG]: { 271 | type: 'string', 272 | description: 'Email address of the user' 273 | }, 274 | message_id: { 275 | type: 'string', 276 | description: 'The ID of the Gmail message containing the attachment' 277 | }, 278 | attachment_id: { 279 | type: 'string', 280 | description: 'The ID of the attachment to retrieve' 281 | }, 282 | mime_type: { 283 | type: 'string', 284 | description: 'The MIME type of the attachment' 285 | }, 286 | filename: { 287 | type: 'string', 288 | description: 'The filename of the attachment' 289 | }, 290 | save_to_disk: { 291 | type: 'string', 292 | description: 'The fullpath to save the attachment to disk. If not provided, the attachment is returned as a resource.' 293 | } 294 | }, 295 | required: ['message_id', 'attachment_id', 'mime_type', 'filename', USER_ID_ARG] 296 | } 297 | }, 298 | { 299 | name: 'gmail_bulk_save_attachments', 300 | description: 'Saves multiple Gmail attachments to disk by their message IDs and attachment IDs in a single request.', 301 | inputSchema: { 302 | type: 'object', 303 | properties: { 304 | [USER_ID_ARG]: { 305 | type: 'string', 306 | description: 'Email address of the user' 307 | }, 308 | attachments: { 309 | type: 'array', 310 | items: { 311 | type: 'object', 312 | properties: { 313 | message_id: { 314 | type: 'string', 315 | description: 'ID of the Gmail message containing the attachment' 316 | }, 317 | part_id: { 318 | type: 'string', 319 | description: 'ID of the part containing the attachment' 320 | }, 321 | save_path: { 322 | type: 'string', 323 | description: 'Path where the attachment should be saved' 324 | } 325 | }, 326 | required: ['message_id', 'part_id', 'save_path'] 327 | } 328 | } 329 | }, 330 | required: ['attachments', USER_ID_ARG] 331 | } 332 | }, 333 | { 334 | name: 'gmail_archive', 335 | description: 'Archives a Gmail message by removing it from the inbox.', 336 | inputSchema: { 337 | type: 'object', 338 | properties: { 339 | [USER_ID_ARG]: { 340 | type: 'string', 341 | description: 'Email address of the user' 342 | }, 343 | message_id: { 344 | type: 'string', 345 | description: 'The ID of the Gmail message to archive' 346 | } 347 | }, 348 | required: ['message_id', USER_ID_ARG] 349 | } 350 | }, 351 | { 352 | name: 'gmail_bulk_archive', 353 | description: 'Archives multiple Gmail messages by removing them from the inbox.', 354 | inputSchema: { 355 | type: 'object', 356 | properties: { 357 | [USER_ID_ARG]: { 358 | type: 'string', 359 | description: 'Email address of the user' 360 | }, 361 | message_ids: { 362 | type: 'array', 363 | items: { 364 | type: 'string' 365 | }, 366 | description: 'List of Gmail message IDs to archive' 367 | } 368 | }, 369 | required: ['message_ids', USER_ID_ARG] 370 | } 371 | } 372 | ] as Tool[]).filter(tool => ( 373 | (process.env.GMAIL_ALLOW_SENDING === 'true') 374 | ? true 375 | : (tool.name !== 'gmail_reply' && tool.name !== 'gmail_create_draft'))); 376 | } 377 | 378 | async handleTool(name: string, args: Record): Promise> { 379 | switch (name) { 380 | case 'gmail_list_accounts': 381 | return this.listAccounts(); 382 | case 'gmail_query_emails': 383 | return this.queryEmails(args); 384 | case 'gmail_get_email': 385 | return this.getEmailById(args); 386 | case 'gmail_bulk_get_emails': 387 | return this.bulkGetEmails(args); 388 | case 'gmail_create_draft': 389 | return this.createDraft(args); 390 | case 'gmail_delete_draft': 391 | return this.deleteDraft(args); 392 | case 'gmail_reply': 393 | return this.reply(args); 394 | case 'gmail_get_attachment': 395 | return this.getAttachment(args); 396 | case 'gmail_bulk_save_attachments': 397 | return this.bulkSaveAttachments(args); 398 | case 'gmail_archive': 399 | return this.archive(args); 400 | case 'gmail_bulk_archive': 401 | return this.bulkArchive(args); 402 | // Add other tool handlers here... 403 | default: 404 | throw new Error(`Unknown tool: ${name}`); 405 | } 406 | } 407 | 408 | private async listAccounts(): Promise> { 409 | try { 410 | const accounts = await this.gauth.getAccountInfo(); 411 | const accountList = accounts.map(account => ({ 412 | email: account.email, 413 | accountType: account.accountType, 414 | extraInfo: account.extraInfo, 415 | description: account.toDescription() 416 | })); 417 | 418 | if (accountList.length === 0) { 419 | return [{ 420 | type: 'text', 421 | text: JSON.stringify({ 422 | message: 'No accounts configured. Please check your .accounts.json file.', 423 | accounts: [] 424 | }, null, 2) 425 | }]; 426 | } 427 | 428 | return [{ 429 | type: 'text', 430 | text: JSON.stringify({ 431 | message: `Found ${accountList.length} configured account(s)`, 432 | accounts: accountList 433 | }, null, 2) 434 | }]; 435 | } catch (error) { 436 | console.error('Error listing accounts:', error); 437 | return [{ 438 | type: 'text', 439 | text: JSON.stringify({ 440 | error: `Failed to list accounts: ${(error as Error).message}`, 441 | accounts: [] 442 | }, null, 2) 443 | }]; 444 | } 445 | } 446 | 447 | private async queryEmails(args: Record): Promise> { 448 | const userId = args[USER_ID_ARG]; 449 | if (!userId) { 450 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 451 | } 452 | 453 | try { 454 | const response = await this.gmail.users.messages.list({ 455 | userId, 456 | q: args.query, 457 | maxResults: args.max_results || 100 458 | }); 459 | 460 | const messages = response.data.messages || []; 461 | const emails = await Promise.all( 462 | messages.map(async (msg) => { 463 | const email = await this.gmail.users.messages.get({ 464 | userId, 465 | id: msg.id!, 466 | format: 'metadata', 467 | metadataHeaders: ['From', 'To', 'Subject', 'Date'] 468 | }); 469 | 470 | // Extract headers into a more readable format 471 | const headers: Record = {}; 472 | email.data.payload?.headers?.forEach(header => { 473 | if (header.name && header.value) { 474 | headers[header.name.toLowerCase()] = header.value; 475 | } 476 | }); 477 | 478 | return { 479 | id: email.data.id, 480 | threadId: email.data.threadId, 481 | labelIds: email.data.labelIds, 482 | snippet: email.data.snippet, 483 | internalDate: email.data.internalDate, 484 | headers 485 | }; 486 | }) 487 | ); 488 | 489 | return [{ 490 | type: 'text', 491 | text: JSON.stringify(emails, null, 2) 492 | }]; 493 | } catch (error) { 494 | console.error('Error querying emails:', error); 495 | throw error; 496 | } 497 | } 498 | 499 | private async getEmailById(args: Record): Promise> { 500 | const userId = args[USER_ID_ARG]; 501 | const emailId = args.email_id; 502 | 503 | if (!userId) { 504 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 505 | } 506 | if (!emailId) { 507 | throw new Error('Missing required argument: email_id'); 508 | } 509 | 510 | try { 511 | const email = await this.gmail.users.messages.get({ 512 | userId, 513 | id: emailId, 514 | format: 'full' 515 | }); 516 | 517 | // Extract headers 518 | const headers = this.extractEmailHeaders(email.data.payload?.headers || []); 519 | 520 | // Extract text content 521 | const textContent = this.extractEmailText(email.data.payload || {}); 522 | 523 | // Get attachments if any 524 | const attachments: Record = {}; 525 | if (email.data.payload?.parts) { 526 | for (const part of email.data.payload.parts) { 527 | if (part.body?.attachmentId) { 528 | attachments[part.partId!] = { 529 | filename: part.filename, 530 | mimeType: part.mimeType, 531 | attachmentId: part.body.attachmentId 532 | }; 533 | } 534 | } 535 | } 536 | 537 | // Create simplified email object 538 | const result = { 539 | id: email.data.id, 540 | threadId: email.data.threadId, 541 | labelIds: email.data.labelIds, 542 | headers, 543 | textContent, 544 | hasAttachments: Object.keys(attachments).length > 0, 545 | attachments: Object.keys(attachments).length > 0 ? attachments : undefined 546 | }; 547 | 548 | return [{ 549 | type: 'text', 550 | text: JSON.stringify(result, null, 2) 551 | }]; 552 | } catch (error) { 553 | console.error('Error getting email:', error); 554 | throw error; 555 | } 556 | } 557 | 558 | private async bulkGetEmails(args: Record): Promise> { 559 | const userId = args[USER_ID_ARG]; 560 | const emailIds = args.email_ids; 561 | 562 | if (!userId) { 563 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 564 | } 565 | if (!emailIds || emailIds.length === 0) { 566 | throw new Error('Missing required argument: email_ids'); 567 | } 568 | 569 | try { 570 | const emails = await Promise.all( 571 | emailIds.map(async (emailId: string) => { 572 | const email = await this.gmail.users.messages.get({ 573 | userId, 574 | id: emailId, 575 | format: 'full' 576 | }); 577 | 578 | // Extract headers 579 | const headers = this.extractEmailHeaders(email.data.payload?.headers || []); 580 | 581 | // Extract text content 582 | const textContent = this.extractEmailText(email.data.payload || {}); 583 | 584 | // Get attachments if any 585 | const attachments: Record = {}; 586 | if (email.data.payload?.parts) { 587 | for (const part of email.data.payload.parts) { 588 | if (part.body?.attachmentId) { 589 | attachments[part.partId!] = { 590 | filename: part.filename, 591 | mimeType: part.mimeType, 592 | attachmentId: part.body.attachmentId 593 | }; 594 | } 595 | } 596 | } 597 | 598 | // Create simplified email object 599 | return { 600 | id: email.data.id, 601 | threadId: email.data.threadId, 602 | labelIds: email.data.labelIds, 603 | headers, 604 | textContent, 605 | hasAttachments: Object.keys(attachments).length > 0, 606 | attachments: Object.keys(attachments).length > 0 ? attachments : undefined 607 | }; 608 | }) 609 | ); 610 | 611 | return [{ 612 | type: 'text', 613 | text: JSON.stringify(emails, null, 2) 614 | }]; 615 | } catch (error) { 616 | console.error('Error getting emails:', error); 617 | throw error; 618 | } 619 | } 620 | 621 | private async createDraft(args: Record): Promise> { 622 | const userId = args[USER_ID_ARG]; 623 | const to = args.to; 624 | const subject = args.subject; 625 | const body = args.body; 626 | const cc = args.cc || []; 627 | 628 | if (!userId) { 629 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 630 | } 631 | if (!to) { 632 | throw new Error('Missing required argument: to'); 633 | } 634 | if (!subject) { 635 | throw new Error('Missing required argument: subject'); 636 | } 637 | if (!body) { 638 | throw new Error('Missing required argument: body'); 639 | } 640 | 641 | try { 642 | const message = { 643 | raw: Buffer.from( 644 | `To: ${to}\r\n` + 645 | `Subject: ${subject}\r\n` + 646 | `Cc: ${cc.join(', ')}\r\n` + 647 | `Content-Type: text/plain; charset="UTF-8"\r\n` + 648 | `\r\n` + 649 | `${body}` 650 | ).toString('base64url') 651 | }; 652 | 653 | const draft = await this.gmail.users.drafts.create({ 654 | userId, 655 | requestBody: { 656 | message 657 | } 658 | }); 659 | 660 | return [{ 661 | type: 'text', 662 | text: JSON.stringify(draft.data, null, 2) 663 | }]; 664 | } catch (error) { 665 | console.error('Error creating draft:', error); 666 | throw error; 667 | } 668 | } 669 | 670 | private async deleteDraft(args: Record): Promise> { 671 | const userId = args[USER_ID_ARG]; 672 | const draftId = args.draft_id; 673 | 674 | if (!userId) { 675 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 676 | } 677 | if (!draftId) { 678 | throw new Error('Missing required argument: draft_id'); 679 | } 680 | 681 | try { 682 | await this.gmail.users.drafts.delete({ 683 | userId, 684 | id: draftId 685 | }); 686 | 687 | return [{ 688 | type: 'text', 689 | text: `Draft ${draftId} deleted successfully` 690 | }]; 691 | } catch (error) { 692 | console.error('Error deleting draft:', error); 693 | throw error; 694 | } 695 | } 696 | 697 | private async reply(args: Record): Promise> { 698 | const userId = args[USER_ID_ARG]; 699 | const originalMessageId = args.original_message_id; 700 | const replyBody = args.reply_body; 701 | // NEVER SEND EMAILS 702 | const send = false; // args.send || false; 703 | const cc = args.cc || []; 704 | 705 | if (!userId) { 706 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 707 | } 708 | if (!originalMessageId) { 709 | throw new Error('Missing required argument: original_message_id'); 710 | } 711 | if (!replyBody) { 712 | throw new Error('Missing required argument: reply_body'); 713 | } 714 | 715 | try { 716 | // First get the original message to extract headers 717 | const originalMessage = await this.gmail.users.messages.get({ 718 | userId, 719 | id: originalMessageId 720 | }); 721 | 722 | const headers = originalMessage.data.payload?.headers?.reduce((acc: Record, header) => { 723 | if (header.name && header.value) { 724 | acc[header.name.toLowerCase()] = header.value; 725 | } 726 | return acc; 727 | }, {}); 728 | 729 | if (!headers) { 730 | throw new Error('Could not extract headers from original message'); 731 | } 732 | 733 | // Get the threadId from the original message 734 | const threadId = originalMessage.data.threadId; 735 | if (!threadId) { 736 | throw new Error('Could not extract threadId from original message'); 737 | } 738 | 739 | const message = { 740 | raw: Buffer.from( 741 | `In-Reply-To: ${originalMessageId}\r\n` + 742 | `References: ${originalMessageId}\r\n` + 743 | `Subject: Re: ${headers.subject || ''}\r\n` + 744 | `To: ${headers.from || ''}\r\n` + 745 | `Cc: ${cc.join(', ')}\r\n` + 746 | `Content-Type: text/plain; charset="UTF-8"\r\n` + 747 | `\r\n` + 748 | `${replyBody}` 749 | ).toString('base64url'), 750 | threadId: threadId 751 | }; 752 | 753 | if (send) { 754 | await this.gmail.users.messages.send({ 755 | userId, 756 | requestBody: { 757 | raw: message.raw, 758 | threadId: message.threadId 759 | } 760 | }); 761 | return [{ 762 | type: 'text', 763 | text: 'Reply sent successfully' 764 | }]; 765 | } else { 766 | const draft = await this.gmail.users.drafts.create({ 767 | userId, 768 | requestBody: { 769 | message 770 | } 771 | }); 772 | return [{ 773 | type: 'text', 774 | text: JSON.stringify(draft.data, null, 2) 775 | }]; 776 | } 777 | } catch (error) { 778 | console.error('Error replying to email:', error); 779 | throw error; 780 | } 781 | } 782 | 783 | private async getAttachment(args: Record): Promise> { 784 | const userId = args[USER_ID_ARG]; 785 | const messageId = args.message_id; 786 | const attachmentId = args.attachment_id; 787 | const mimeType = args.mime_type; 788 | const filename = args.filename; 789 | const saveToDisk = args.save_to_disk; 790 | 791 | if (!userId) { 792 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 793 | } 794 | if (!messageId) { 795 | throw new Error('Missing required argument: message_id'); 796 | } 797 | if (!attachmentId) { 798 | throw new Error('Missing required argument: attachment_id'); 799 | } 800 | if (!mimeType) { 801 | throw new Error('Missing required argument: mime_type'); 802 | } 803 | if (!filename) { 804 | throw new Error('Missing required argument: filename'); 805 | } 806 | 807 | try { 808 | const attachment = await this.gmail.users.messages.attachments.get({ 809 | userId, 810 | messageId, 811 | id: attachmentId 812 | }); 813 | 814 | const attachmentData = attachment.data.data; 815 | if (!attachmentData) { 816 | throw new Error('Attachment data not found'); 817 | } 818 | 819 | const decodedData = Buffer.from(attachmentData, 'base64').toString('utf-8'); 820 | const decodedContent = this.decodeBase64UrlString(decodedData); 821 | 822 | if (saveToDisk) { 823 | fs.writeFileSync(saveToDisk, decodedContent); 824 | return [{ 825 | type: 'text', 826 | text: `Attachment saved to ${saveToDisk}` 827 | }]; 828 | } else { 829 | return [{ 830 | type: 'text', 831 | text: decodedContent 832 | }]; 833 | } 834 | } catch (error) { 835 | console.error('Error getting attachment:', error); 836 | throw error; 837 | } 838 | } 839 | 840 | private async bulkSaveAttachments(args: Record): Promise> { 841 | const userId = args[USER_ID_ARG]; 842 | const attachments = args.attachments; 843 | 844 | if (!userId) { 845 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 846 | } 847 | if (!attachments || attachments.length === 0) { 848 | throw new Error('Missing required argument: attachments'); 849 | } 850 | 851 | try { 852 | const results = await Promise.all( 853 | attachments.map(async (attachmentInfo: any) => { 854 | const messageId = attachmentInfo.message_id; 855 | const partId = attachmentInfo.part_id; 856 | const savePath = attachmentInfo.save_path; 857 | 858 | if (!messageId || !partId || !savePath) { 859 | throw new Error('Missing required arguments: message_id, part_id, or save_path'); 860 | } 861 | 862 | const attachmentData = await this.gmail.users.messages.attachments.get({ 863 | userId, 864 | messageId, 865 | id: partId 866 | }); 867 | 868 | const fileData = attachmentData.data.data; 869 | if (!fileData) { 870 | throw new Error('Attachment data not found'); 871 | } 872 | 873 | const decodedData = Buffer.from(fileData, 'base64').toString('utf-8'); 874 | const decodedContent = this.decodeBase64UrlString(decodedData); 875 | 876 | fs.writeFileSync(savePath, decodedContent); 877 | 878 | return { 879 | messageId, 880 | partId, 881 | savePath, 882 | status: 'success' 883 | }; 884 | }) 885 | ); 886 | 887 | return [{ 888 | type: 'text', 889 | text: JSON.stringify(results, null, 2) 890 | }]; 891 | } catch (error) { 892 | console.error('Error saving attachments:', error); 893 | throw error; 894 | } 895 | } 896 | 897 | private async archive(args: Record): Promise> { 898 | const userId = args[USER_ID_ARG]; 899 | const messageId = args.message_id; 900 | 901 | if (!userId) { 902 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 903 | } 904 | if (!messageId) { 905 | throw new Error('Missing required argument: message_id'); 906 | } 907 | 908 | try { 909 | await this.gmail.users.messages.trash({ 910 | userId, 911 | id: messageId 912 | }); 913 | 914 | return [{ 915 | type: 'text', 916 | text: `Message ${messageId} archived successfully` 917 | }]; 918 | } catch (error) { 919 | console.error('Error archiving message:', error); 920 | throw error; 921 | } 922 | } 923 | 924 | private async bulkArchive(args: Record): Promise> { 925 | const userId = args[USER_ID_ARG]; 926 | const messageIds = args.message_ids; 927 | 928 | if (!userId) { 929 | throw new Error(`Missing required argument: ${USER_ID_ARG}`); 930 | } 931 | if (!messageIds || messageIds.length === 0) { 932 | throw new Error('Missing required argument: message_ids'); 933 | } 934 | 935 | try { 936 | const results = await Promise.all( 937 | messageIds.map(async (messageId: string) => { 938 | await this.gmail.users.messages.trash({ 939 | userId, 940 | id: messageId 941 | }); 942 | return { 943 | messageId, 944 | status: 'archived' 945 | }; 946 | }) 947 | ); 948 | 949 | return [{ 950 | type: 'text', 951 | text: JSON.stringify(results, null, 2) 952 | }]; 953 | } catch (error) { 954 | console.error('Error archiving messages:', error); 955 | throw error; 956 | } 957 | } 958 | } 959 | --------------------------------------------------------------------------------