#
tokens: 14455/50000 8/8 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── Dockerfile
├── LICENSE
├── package-lock.json
├── package.json
├── pyproject.toml
├── README.md
├── server.js
├── src
│   └── mcp_server_headless_gmail
│       ├── __init__.py
│       └── server.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Environment variables
 2 | .env
 3 | 
 4 | # Python
 5 | __pycache__/
 6 | *.py[cod]
 7 | *$py.class
 8 | *.so
 9 | .Python
10 | build/
11 | develop-eggs/
12 | dist/
13 | downloads/
14 | eggs/
15 | .eggs/
16 | lib/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | wheels/
22 | *.egg-info/
23 | .installed.cfg
24 | *.egg
25 | 
26 | # Virtual Environment
27 | venv/
28 | env/
29 | ENV/
30 | 
31 | # IDE
32 | .idea/
33 | .vscode/
34 | *.swp
35 | *.swo
36 | 
37 | # OS
38 | .DS_Store
39 | Thumbs.db 
40 | 
41 | # Node
42 | node_modules/
43 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # MCP Headless Gmail Server (NPM & Docker)
  2 | 
  3 | [![npm version](https://img.shields.io/npm/v/@peakmojo/mcp-server-headless-gmail.svg)](https://www.npmjs.com/package/@peakmojo/mcp-server-headless-gmail) [![Docker Pulls](https://img.shields.io/docker/pulls/buryhuang/mcp-headless-gmail)](https://hub.docker.com/r/buryhuang/mcp-headless-gmail) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
  4 | 
  5 | A MCP (Model Context Protocol) server that provides get, send Gmails without local credential or token setup.
  6 | 
  7 | <a href="https://glama.ai/mcp/servers/@baryhuang/mcp-headless-gmail">
  8 |   <img width="380" height="200" src="https://glama.ai/mcp/servers/@baryhuang/mcp-headless-gmail/badge" alt="Headless Gmail Server MCP server" />
  9 | </a>
 10 | 
 11 | ## Why MCP Headless Gmail Server?
 12 | ### Critical Advantages
 13 | - **Headless & Remote Operation**: Unlike other MCP Gmail solutions that require running outside of docker and local file access, this server can run completely headless in remote environments with no browser no local file access.
 14 | - **Decoupled Architecture**: Any client can complete the OAuth flow independently, then pass credentials as context to this MCP server, creating a complete separation between credential storage and server implementation.
 15 | 
 16 | ### Nice but not critical
 17 | - **Focused Functionality**: In many use cases, especially for marketing applications, only Gmail access is needed without additional Google services like Calendar, making this focused implementation ideal.
 18 | - **Docker-Ready**: Designed with containerization in mind for a well-isolated, environment-independent, one-click setup.
 19 | - **Reliable Dependencies**: Built on the well-maintained google-api-python-client library.
 20 | 
 21 | ## Features
 22 | 
 23 | - Get most recent emails from Gmail with the first 1k characters of the body
 24 | - Get full email body content in 1k chunks using offset parameter
 25 | - Send emails through Gmail
 26 | - Refresh access tokens separately
 27 | - Automatic refresh token handling
 28 | 
 29 | ## Prerequisites
 30 | 
 31 | - Python 3.10 or higher
 32 | - Google API credentials (client ID, client secret, access token, and refresh token)
 33 | 
 34 | ## Installation
 35 | 
 36 | ```bash
 37 | # Clone the repository
 38 | git clone https://github.com/baryhuang/mcp-headless-gmail.git
 39 | cd mcp-headless-gmail
 40 | 
 41 | # Install dependencies
 42 | pip install -e .
 43 | ```
 44 | 
 45 | ## Docker
 46 | 
 47 | ### Building the Docker Image
 48 | 
 49 | ```bash
 50 | # Build the Docker image
 51 | docker build -t mcp-headless-gmail .
 52 | ```
 53 | 
 54 | ## Usage with Claude Desktop
 55 | 
 56 | You can configure Claude Desktop to use the Docker image by adding the following to your Claude configuration:
 57 | 
 58 | docker
 59 | ```json
 60 | {
 61 |   "mcpServers": {
 62 |     "gmail": {
 63 |       "command": "docker",
 64 |       "args": [
 65 |         "run",
 66 |         "-i",
 67 |         "--rm",
 68 |         "buryhuang/mcp-headless-gmail:latest"
 69 |       ]
 70 |     }
 71 |   }
 72 | }
 73 | ```
 74 | 
 75 | npm version
 76 | ```json
 77 | {
 78 |   "mcpServers": {
 79 |     "gmail": {
 80 |       "command": "npx",
 81 |       "args": [
 82 |         "@peakmojo/mcp-server-headless-gmail"
 83 |       ]
 84 |     }
 85 |   }
 86 | }
 87 | ```
 88 | 
 89 | Note: With this configuration, you'll need to provide your Google API credentials in the tool calls as shown in the [Using the Tools](#using-the-tools) section. Gmail credentials are not passed as environment variables to maintain separation between credential storage and server implementation.
 90 | 
 91 | ## Cross-Platform Publishing
 92 | 
 93 | To publish the Docker image for multiple platforms, you can use the `docker buildx` command. Follow these steps:
 94 | 
 95 | 1. **Create a new builder instance** (if you haven't already):
 96 |    ```bash
 97 |    docker buildx create --use
 98 |    ```
 99 | 
100 | 2. **Build and push the image for multiple platforms**:
101 |    ```bash
102 |    docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t buryhuang/mcp-headless-gmail:latest --push .
103 |    ```
104 | 
105 | 3. **Verify the image is available for the specified platforms**:
106 |    ```bash
107 |    docker buildx imagetools inspect buryhuang/mcp-headless-gmail:latest
108 |    ```
109 | 
110 | ## Usage
111 | 
112 | The server provides Gmail functionality through MCP tools. Authentication handling is simplified with a dedicated token refresh tool.
113 | 
114 | ### Starting the Server
115 | 
116 | ```bash
117 | mcp-server-headless-gmail
118 | ```
119 | 
120 | ### Using the Tools
121 | 
122 | When using an MCP client like Claude, you have two main ways to handle authentication:
123 | 
124 | #### Refreshing Tokens (First Step or When Tokens Expire)
125 | 
126 | If you have both access and refresh tokens:
127 | ```json
128 | {
129 |   "google_access_token": "your_access_token",
130 |   "google_refresh_token": "your_refresh_token",
131 |   "google_client_id": "your_client_id",
132 |   "google_client_secret": "your_client_secret"
133 | }
134 | ```
135 | 
136 | If your access token has expired, you can refresh with just the refresh token:
137 | ```json
138 | {
139 |   "google_refresh_token": "your_refresh_token",
140 |   "google_client_id": "your_client_id",
141 |   "google_client_secret": "your_client_secret"
142 | }
143 | ```
144 | 
145 | This will return a new access token and its expiration time, which you can use for subsequent calls.
146 | 
147 | #### Getting Recent Emails
148 | 
149 | Retrieves recent emails with the first 1k characters of each email body:
150 | 
151 | ```json
152 | {
153 |   "google_access_token": "your_access_token",
154 |   "max_results": 5,
155 |   "unread_only": false
156 | }
157 | ```
158 | 
159 | Response includes:
160 | - Email metadata (id, threadId, from, to, subject, date, etc.)
161 | - First 1000 characters of the email body
162 | - `body_size_bytes`: Total size of the email body in bytes
163 | - `contains_full_body`: Boolean indicating if the entire body is included (true) or truncated (false)
164 | 
165 | #### Getting Full Email Body Content
166 | 
167 | For emails with bodies larger than 1k characters, you can retrieve the full content in chunks:
168 | 
169 | ```json
170 | {
171 |   "google_access_token": "your_access_token",
172 |   "message_id": "message_id_from_get_recent_emails",
173 |   "offset": 0
174 | }
175 | ```
176 | 
177 | You can also get email content by thread ID:
178 | 
179 | ```json
180 | {
181 |   "google_access_token": "your_access_token",
182 |   "thread_id": "thread_id_from_get_recent_emails",
183 |   "offset": 1000
184 | }
185 | ```
186 | 
187 | The response includes:
188 | - A 1k chunk of the email body starting from the specified offset
189 | - `body_size_bytes`: Total size of the email body
190 | - `chunk_size`: Size of the returned chunk
191 | - `contains_full_body`: Boolean indicating if the chunk contains the remainder of the body
192 | 
193 | To retrieve the entire email body of a long message, make sequential calls increasing the offset by 1000 each time until `contains_full_body` is true.
194 | 
195 | #### Sending an Email
196 | 
197 | ```json
198 | {
199 |   "google_access_token": "your_access_token",
200 |   "to": "[email protected]",
201 |   "subject": "Hello from MCP Gmail",
202 |   "body": "This is a test email sent via MCP Gmail server",
203 |   "html_body": "<p>This is a <strong>test email</strong> sent via MCP Gmail server</p>"
204 | }
205 | ```
206 | 
207 | ### Token Refresh Workflow
208 | 
209 | 1. Start by calling the `gmail_refresh_token` tool with either:
210 |    - Your full credentials (access token, refresh token, client ID, and client secret), or
211 |    - Just your refresh token, client ID, and client secret if the access token has expired
212 | 2. Use the returned new access token for subsequent API calls.
213 | 3. If you get a response indicating token expiration, call the `gmail_refresh_token` tool again to get a new token.
214 | 
215 | This approach simplifies most API calls by not requiring client credentials for every operation, while still enabling token refresh when needed.
216 | 
217 | ## Obtaining Google API Credentials
218 | 
219 | To obtain the required Google API credentials, follow these steps:
220 | 
221 | 1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
222 | 2. Create a new project
223 | 3. Enable the Gmail API
224 | 4. Configure OAuth consent screen
225 | 5. Create OAuth client ID credentials (select "Desktop app" as the application type)
226 | 6. Save the client ID and client secret
227 | 7. Use OAuth 2.0 to obtain access and refresh tokens with the following scopes:
228 |    - `https://www.googleapis.com/auth/gmail.readonly` (for reading emails)
229 |    - `https://www.googleapis.com/auth/gmail.send` (for sending emails)
230 | 
231 | ## Token Refreshing
232 | 
233 | This server implements automatic token refreshing. When your access token expires, the Google API client will use the refresh token, client ID, and client secret to obtain a new access token without requiring user intervention.
234 | 
235 | ## Security Note
236 | 
237 | This server requires direct access to your Google API credentials. Always keep your tokens and credentials secure and never share them with untrusted parties.
238 | 
239 | ## License
240 | 
241 | See the LICENSE file for details.
242 | 
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
 1 | # Use Python base image
 2 | FROM python:3.11-slim
 3 | 
 4 | # Install the project into `/app`
 5 | WORKDIR /app
 6 | 
 7 | # Copy the entire project
 8 | COPY . /app
 9 | 
10 | # Install the package
11 | RUN pip install --no-cache-dir -e .
12 | 
13 | # Run the server
14 | ENTRYPOINT ["mcp-server-headless-gmail"] 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "mcp-server-headless-gmail"
 3 | version = "0.1.0"
 4 | description = "A simple Gmail MCP server"
 5 | readme = "README.md"
 6 | requires-python = ">=3.10"
 7 | dependencies = ["mcp>=1.4.1", "python-dotenv>=1.0.1", "google-api-python-client>=2.127.0", "google-auth>=2.34.0", "google-auth-oauthlib>=1.2.0", "google-auth-httplib2>=0.2.0", "python-dateutil>=2.8.2"]
 8 | 
 9 | [build-system]
10 | requires = ["hatchling"]
11 | build-backend = "hatchling.build"
12 | 
13 | [tool.uv]
14 | dev-dependencies = ["pyright>=1.1.389"]
15 | 
16 | [project.scripts]
17 | mcp-server-headless-gmail = "mcp_server_headless_gmail:main" 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "@peakmojo/mcp-server-headless-gmail",
 3 |   "version": "0.1.0",
 4 |   "description": "A simple Gmail MCP server (no authentication flow)",
 5 |   "type": "module",
 6 |   "publishConfig": {
 7 |     "access": "public"
 8 |   },
 9 |   "main": "server.js",
10 |   "bin": {
11 |     "mcp-server-headless-gmail": "server.js"
12 |   },
13 |   "scripts": {
14 |     "start": "node server.js"
15 |   },
16 |   "dependencies": {
17 |     "@modelcontextprotocol/sdk": "^1.4.1",
18 |     "googleapis": "^133.0.0",
19 |     "luxon": "^3.4.4",
20 |     "zod": "^3.22.4",
21 |     "node-fetch": "^3.3.2"
22 |   },
23 |   "engines": {
24 |     "node": ">=18.0.0"
25 |   },
26 |   "license": "MIT"
27 | } 
```

--------------------------------------------------------------------------------
/src/mcp_server_headless_gmail/__init__.py:
--------------------------------------------------------------------------------

```python
 1 | import argparse
 2 | import asyncio
 3 | import logging
 4 | from . import server
 5 | 
 6 | logging.basicConfig(level=logging.DEBUG)
 7 | logger = logging.getLogger('mcp_headless_gmail')
 8 | 
 9 | def main():
10 |     logger.debug("Starting mcp-server-headless-gmail main()")
11 |     parser = argparse.ArgumentParser(description='Headless Gmail MCP Server')
12 |     args = parser.parse_args()
13 |     
14 |     # Run the async main function
15 |     logger.debug("About to run server.main()")
16 |     asyncio.run(server.main())
17 |     logger.debug("Server main() completed")
18 | 
19 | if __name__ == "__main__":
20 |     main()
21 | 
22 | # Expose important items at package level
23 | __all__ = ["main", "server"] 
```

--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------

```javascript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
  4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
  5 | import { z } from 'zod';
  6 | import { google } from 'googleapis';
  7 | import { Buffer } from 'buffer';
  8 | import { DateTime } from 'luxon';
  9 | 
 10 | const logLevels = {
 11 |   ERROR: 0,
 12 |   WARN: 1,
 13 |   INFO: 2,
 14 |   DEBUG: 3
 15 | };
 16 | 
 17 | class Logger {
 18 |   constructor(name) {
 19 |     this.name = name;
 20 |     this.logLevel = process.env.LOG_LEVEL ? 
 21 |       logLevels[(process.env.LOG_LEVEL || 'INFO').toUpperCase()] : 
 22 |       logLevels.INFO;
 23 |   }
 24 | 
 25 |   log(level, message) {
 26 |     if (logLevels[level] <= this.logLevel) {
 27 |       const timestamp = new Date().toISOString();
 28 |       console.error(`${timestamp} - ${this.name} - ${level} - ${message}`);
 29 |     }
 30 |   }
 31 | 
 32 |   info(message) { this.log('INFO', message); }
 33 |   warn(message) { this.log('WARN', message); }
 34 |   error(message) { this.log('ERROR', message); }
 35 |   debug(message) { this.log('DEBUG', message); }
 36 | }
 37 | 
 38 | const logger = new Logger('gmail-mcp');
 39 | 
 40 | class GmailClient {
 41 |   constructor({ accessToken, refreshToken, clientId, clientSecret } = {}) {
 42 |     if (!accessToken && !refreshToken) {
 43 |       throw new Error('Either accessToken or refreshToken must be provided');
 44 |     }
 45 |     this.accessToken = accessToken;
 46 |     this.refreshToken = refreshToken;
 47 |     this.clientId = clientId;
 48 |     this.clientSecret = clientSecret;
 49 |     this.tokenUri = 'https://oauth2.googleapis.com/token';
 50 | 
 51 |     // Always create the OAuth2 client
 52 |     this.oauth2Client = new google.auth.OAuth2(clientId, clientSecret);
 53 | 
 54 |     // Set credentials if available
 55 |     this.oauth2Client.setCredentials({
 56 |       access_token: accessToken,
 57 |       refresh_token: refreshToken,
 58 |       client_id: clientId,
 59 |       client_secret: clientSecret
 60 |     });
 61 | 
 62 |     // Create the Gmail API client with the OAuth2 client
 63 |     this.gmail = google.gmail({
 64 |       version: 'v1',
 65 |       auth: this.oauth2Client
 66 |     });
 67 |   }
 68 | 
 69 |   async _handleTokenRefresh(operation) {
 70 |     try {
 71 |       return await operation();
 72 |     } catch (error) {
 73 |       logger.error(`Request error: ${error.message}`);
 74 |       if (error.response) {
 75 |         const statusCode = error.response.status;
 76 |         if (statusCode === 401) {
 77 |           return JSON.stringify({
 78 |             error: 'Unauthorized. Token might be expired. Try refreshing your token.',
 79 |             details: error.message
 80 |           });
 81 |         } else {
 82 |           return JSON.stringify({
 83 |             error: `Gmail API error: ${statusCode}`,
 84 |             details: error.message
 85 |           });
 86 |         }
 87 |       }
 88 |       return JSON.stringify({
 89 |         error: 'Request to Gmail API failed',
 90 |         details: error.message
 91 |       });
 92 |     }
 93 |   }
 94 | 
 95 |   async refreshAccessToken(clientId, clientSecret) {
 96 |     logger.debug(`Starting refreshAccessToken with clientId=${clientId?.slice(0, 5)}...`);
 97 |     if (!this.refreshToken) {
 98 |       return JSON.stringify({
 99 |         error: 'No refresh token provided',
100 |         status: 'error'
101 |       });
102 |     }
103 |     try {
104 |       const oauth2Client = new google.auth.OAuth2(clientId, clientSecret);
105 |       oauth2Client.setCredentials({ refresh_token: this.refreshToken });
106 |       const { credentials } = await oauth2Client.refreshAccessToken();
107 |       this.accessToken = credentials.access_token;
108 |       const expiresIn = credentials.expiry_date ? Math.floor((credentials.expiry_date - Date.now()) / 1000) : 3600;
109 |       const expiry = credentials.expiry_date ? new Date(credentials.expiry_date).toISOString() : null;
110 |       return JSON.stringify({
111 |         access_token: this.accessToken,
112 |         expires_at: expiry,
113 |         expires_in: expiresIn,
114 |         status: 'success'
115 |       });
116 |     } catch (error) {
117 |       logger.error(`Exception in refreshAccessToken: ${error.message}`);
118 |       return JSON.stringify({
119 |         error: error.message,
120 |         status: 'error'
121 |       });
122 |     }
123 |   }
124 | 
125 |   async getRecentEmails({ maxResults = 10, unreadOnly = false } = {}) {
126 |     const operation = async () => {
127 |       if (!this.gmail) {
128 |         throw new Error('Gmail service not initialized. No valid access token provided.');
129 |       }
130 |       const query = unreadOnly ? 'is:unread' : '';
131 |       const res = await this.gmail.users.messages.list({
132 |         userId: 'me',
133 |         maxResults,
134 |         labelIds: ['INBOX'],
135 |         q: query
136 |       });
137 |       const messages = res.data.messages || [];
138 |       const emails = [];
139 |       for (const message of messages) {
140 |         const msg = await this.gmail.users.messages.get({
141 |           userId: 'me',
142 |           id: message.id,
143 |           format: 'full'
144 |         });
145 |         const payload = msg.data.payload || {};
146 |         const headers = (payload.headers || []).reduce((acc, h) => {
147 |           const name = h.name.toLowerCase();
148 |           if (['from', 'to', 'subject', 'date'].includes(name)) {
149 |             acc[name] = h.value;
150 |           }
151 |           return acc;
152 |         }, {});
153 |         const { body, body_size_bytes, contains_full_body } = this.extractPlainTextBody(payload);
154 |         emails.push({
155 |           id: msg.data.id,
156 |           threadId: msg.data.threadId,
157 |           labelIds: msg.data.labelIds,
158 |           snippet: msg.data.snippet,
159 |           from: headers.from || '',
160 |           to: headers.to || '',
161 |           subject: headers.subject || '',
162 |           date: headers.date || '',
163 |           internalDate: msg.data.internalDate,
164 |           body,
165 |           body_size_bytes,
166 |           contains_full_body
167 |         });
168 |       }
169 |       return JSON.stringify({ emails });
170 |     };
171 |     return await this._handleTokenRefresh(operation);
172 |   }
173 | 
174 |   extractPlainTextBody(payload) {
175 |     let body = '';
176 |     let body_size_bytes = 0;
177 |     let contains_full_body = true;
178 |     function extract(parts) {
179 |       if (!parts) return;
180 |       for (const part of parts) {
181 |         if (part.mimeType === 'text/plain' && part.body && part.body.data) {
182 |           const decoded = Buffer.from(part.body.data, 'base64').toString('utf-8');
183 |           body += decoded;
184 |           body_size_bytes += Buffer.byteLength(decoded);
185 |         }
186 |         if (part.parts) extract(part.parts);
187 |       }
188 |     }
189 |     if (payload.body && payload.body.data) {
190 |       const decoded = Buffer.from(payload.body.data, 'base64').toString('utf-8');
191 |       body = decoded;
192 |       body_size_bytes = Buffer.byteLength(decoded);
193 |     }
194 |     if (payload.parts) extract(payload.parts);
195 |     if (body.length > 1000) {
196 |       body = body.slice(0, 1000);
197 |       contains_full_body = false;
198 |     }
199 |     return { body, body_size_bytes, contains_full_body };
200 |   }
201 | 
202 |   async sendEmail({ to, subject, body, html_body }) {
203 |     const operation = async () => {
204 |       if (!this.gmail) {
205 |         throw new Error('Gmail service not initialized. No valid access token provided.');
206 |       }
207 |       const messageParts = [
208 |         `To: ${to}`,
209 |         `Subject: ${subject}`,
210 |         'Content-Type: multipart/alternative; boundary="boundary"',
211 |         '',
212 |         '--boundary',
213 |         'Content-Type: text/plain; charset="UTF-8"',
214 |         '',
215 |         body,
216 |         '--boundary',
217 |         'Content-Type: text/html; charset="UTF-8"',
218 |         '',
219 |         html_body || '',
220 |         '--boundary--'
221 |       ];
222 |       const rawMessage = Buffer.from(messageParts.join('\r\n')).toString('base64').replace(/\+/g, '-').replace(/\//g, '_');
223 |       const res = await this.gmail.users.messages.send({
224 |         userId: 'me',
225 |         requestBody: { raw: rawMessage }
226 |       });
227 |       return JSON.stringify({
228 |         messageId: res.data.id,
229 |         threadId: res.data.threadId,
230 |         labelIds: res.data.labelIds
231 |       });
232 |     };
233 |     return await this._handleTokenRefresh(operation);
234 |   }
235 | 
236 |   async getEmailBodyChunk({ message_id, thread_id, offset = 0 }) {
237 |     const operation = async () => {
238 |       if (!this.gmail) {
239 |         throw new Error('Gmail service not initialized. No valid access token provided.');
240 |       }
241 |       let local_message_id = message_id;
242 |       if (!local_message_id && thread_id) {
243 |         const thread = await this.gmail.users.threads.get({ userId: 'me', id: thread_id });
244 |         if (!thread.data.messages || !thread.data.messages.length) {
245 |           return JSON.stringify({
246 |             error: `No messages found in thread ${thread_id}`,
247 |             status: 'error'
248 |           });
249 |         }
250 |         local_message_id = thread.data.messages[0].id;
251 |       }
252 |       if (!local_message_id) {
253 |         return JSON.stringify({
254 |           error: 'Either message_id or thread_id must be provided',
255 |           status: 'error'
256 |         });
257 |       }
258 |       const msg = await this.gmail.users.messages.get({
259 |         userId: 'me',
260 |         id: local_message_id,
261 |         format: 'full'
262 |       });
263 |       const payload = msg.data.payload || {};
264 |       const { body, body_size_bytes } = this.extractPlainTextBody(payload);
265 |       const chunk = offset >= body.length ? '' : body.slice(offset, offset + 1000);
266 |       const contains_full_body = (offset + chunk.length >= body.length);
267 |       return JSON.stringify({
268 |         message_id: local_message_id,
269 |         thread_id: msg.data.threadId,
270 |         body: chunk,
271 |         body_size_bytes,
272 |         offset,
273 |         chunk_size: chunk.length,
274 |         contains_full_body,
275 |         status: 'success'
276 |       });
277 |     };
278 |     return await this._handleTokenRefresh(operation);
279 |   }
280 | }
281 | 
282 | async function main() {
283 |   logger.info('Starting Gmail MCP server');
284 |   try {
285 |     const server = new McpServer({
286 |       name: 'gmail-client',
287 |       version: '0.1.0'
288 |     });
289 | 
290 |     server.tool(
291 |       'gmail_refresh_token',
292 |       'Refresh the access token using the refresh token and client credentials',
293 |       {
294 |         google_access_token: z.string().optional().describe('Google OAuth2 access token (optional if expired)'),
295 |         google_refresh_token: z.string().describe('Google OAuth2 refresh token'),
296 |         google_client_id: z.string().describe('Google OAuth2 client ID for token refresh'),
297 |         google_client_secret: z.string().describe('Google OAuth2 client secret for token refresh')
298 |       },
299 |       async ({ google_access_token, google_refresh_token, google_client_id, google_client_secret }) => {
300 |         try {
301 |           const gmail = new GmailClient({
302 |             accessToken: google_access_token,
303 |             refreshToken: google_refresh_token,
304 |             clientId: google_client_id,
305 |             clientSecret: google_client_secret
306 |           });
307 |           const result = await gmail.refreshAccessToken(google_client_id, google_client_secret);
308 |           return { content: [{ type: 'text', text: result }] };
309 |         } catch (error) {
310 |           return { content: [{ type: 'text', text: JSON.stringify({ error: error.message, status: 'error' }) }] };
311 |         }
312 |       }
313 |     );
314 | 
315 |     server.tool(
316 |       'gmail_get_recent_emails',
317 |       'Get the most recent emails from Gmail (returns metadata, snippets, and first 1k chars of body)',
318 |       {
319 |         google_access_token: z.string().describe('Google OAuth2 access token'),
320 |         max_results: z.number().optional().describe('Maximum number of emails to return (default: 10)'),
321 |         unread_only: z.boolean().optional().describe('Whether to return only unread emails (default: False)')
322 |       },
323 |       async ({ google_access_token, max_results = 10, unread_only = false }) => {
324 |         try {
325 |           const gmail = new GmailClient({
326 |             accessToken: google_access_token
327 |           });
328 |           const result = await gmail.getRecentEmails({ maxResults: max_results, unreadOnly: unread_only });
329 |           return { content: [{ type: 'text', text: result }] };
330 |         } catch (error) {
331 |           return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
332 |         }
333 |       }
334 |     );
335 | 
336 |     server.tool(
337 |       'gmail_get_email_body_chunk',
338 |       'Get a 1k character chunk of an email body starting from the specified offset',
339 |       {
340 |         google_access_token: z.string().describe('Google OAuth2 access token'),
341 |         message_id: z.string().optional().describe('ID of the message to retrieve'),
342 |         thread_id: z.string().optional().describe('ID of the thread to retrieve (will get the first message if multiple exist)'),
343 |         offset: z.number().optional().describe('Offset in characters to start from (default: 0)')
344 |       },
345 |       async ({ google_access_token, message_id, thread_id, offset = 0 }) => {
346 |         try {
347 |           const gmail = new GmailClient({
348 |             accessToken: google_access_token
349 |           });
350 |           const result = await gmail.getEmailBodyChunk({ message_id, thread_id, offset });
351 |           return { content: [{ type: 'text', text: result }] };
352 |         } catch (error) {
353 |           return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
354 |         }
355 |       }
356 |     );
357 | 
358 |     server.tool(
359 |       'gmail_send_email',
360 |       'Send an email via Gmail',
361 |       {
362 |         google_access_token: z.string().describe('Google OAuth2 access token'),
363 |         to: z.string().describe('Recipient email address'),
364 |         subject: z.string().describe('Email subject'),
365 |         body: z.string().describe('Email body content (plain text)'),
366 |         html_body: z.string().optional().describe('Email body content in HTML format (optional)')
367 |       },
368 |       async ({ google_access_token, to, subject, body, html_body }) => {
369 |         try {
370 |           const gmail = new GmailClient({
371 |             accessToken: google_access_token
372 |           });
373 |           const result = await gmail.sendEmail({ to, subject, body, html_body });
374 |           return { content: [{ type: 'text', text: result }] };
375 |         } catch (error) {
376 |           return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
377 |         }
378 |       }
379 |     );
380 | 
381 |     const transport = new StdioServerTransport();
382 |     await server.connect(transport);
383 |     logger.info('MCP server started and ready to receive requests');
384 |   } catch (error) {
385 |     logger.error(`Error starting server: ${error.message}`);
386 |     process.exit(1);
387 |   }
388 | }
389 | 
390 | main(); 
```

--------------------------------------------------------------------------------
/src/mcp_server_headless_gmail/server.py:
--------------------------------------------------------------------------------

```python
  1 | import logging
  2 | from typing import Any, Dict, List, Optional
  3 | import os
  4 | from dotenv import load_dotenv
  5 | from mcp.server.models import InitializationOptions
  6 | import mcp.types as types
  7 | from mcp.server import NotificationOptions, Server
  8 | import mcp.server.stdio
  9 | from pydantic import AnyUrl
 10 | import json
 11 | from datetime import datetime, timedelta
 12 | from dateutil.tz import tzlocal
 13 | import argparse
 14 | import base64
 15 | from email.mime.text import MIMEText
 16 | from email.mime.multipart import MIMEMultipart
 17 | from email.mime.application import MIMEApplication
 18 | from googleapiclient.discovery import build
 19 | from googleapiclient.errors import HttpError
 20 | import google.oauth2.credentials
 21 | import google.auth.exceptions
 22 | import email
 23 | import re
 24 | from google.auth.transport.requests import Request
 25 | 
 26 | # Configure logging
 27 | logging.basicConfig(
 28 |     level=logging.DEBUG,
 29 |     format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
 30 | )
 31 | logger = logging.getLogger('mcp_server_headless_gmail')
 32 | logger.setLevel(logging.DEBUG)
 33 | 
 34 | def convert_datetime_fields(obj: Any) -> Any:
 35 |     """Convert any datetime or tzlocal objects to string in the given object"""
 36 |     if isinstance(obj, dict):
 37 |         return {k: convert_datetime_fields(v) for k, v in obj.items()}
 38 |     elif isinstance(obj, list):
 39 |         return [convert_datetime_fields(item) for item in obj]
 40 |     elif isinstance(obj, datetime):
 41 |         return obj.isoformat()
 42 |     elif isinstance(obj, tzlocal):
 43 |         # Get the current timezone offset
 44 |         offset = datetime.now(tzlocal()).strftime('%z')
 45 |         return f"UTC{offset[:3]}:{offset[3:]}"  # Format like "UTC+08:00" or "UTC-05:00"
 46 |     return obj
 47 | 
 48 | class GmailClient:
 49 |     def __init__(self, access_token: Optional[str] = None, refresh_token: Optional[str] = None, 
 50 |                  client_id: Optional[str] = None, client_secret: Optional[str] = None):
 51 |         if not access_token and not refresh_token:
 52 |             raise ValueError("Either access_token or refresh_token must be provided")
 53 |         
 54 |         # Create credentials from the provided tokens
 55 |         self.credentials = google.oauth2.credentials.Credentials(
 56 |             token=access_token,
 57 |             refresh_token=refresh_token,
 58 |             token_uri="https://oauth2.googleapis.com/token",
 59 |             client_id=client_id,
 60 |             client_secret=client_secret,
 61 |         )
 62 |         
 63 |         # Build the Gmail service if access token is provided
 64 |         if access_token:
 65 |             self.service = build('gmail', 'v1', credentials=self.credentials, cache_discovery=False)
 66 | 
 67 |     def _handle_token_refresh(self, func):
 68 |         """Decorator to handle token refresh errors gracefully"""
 69 |         try:
 70 |             return func()
 71 |         except google.auth.exceptions.RefreshError as e:
 72 |             logger.error(f"Token refresh error: {str(e)}")
 73 |             return json.dumps({
 74 |                 "error": "Token refresh failed. Please provide new access and refresh tokens.",
 75 |                 "details": str(e)
 76 |             })
 77 | 
 78 |     def refresh_token(self, client_id: str, client_secret: str) -> str:
 79 |         """Refresh the access token using the refresh token
 80 |         
 81 |         Args:
 82 |             client_id: Google OAuth2 client ID
 83 |             client_secret: Google OAuth2 client secret
 84 |         """
 85 |         if not self.credentials.refresh_token:
 86 |             return json.dumps({
 87 |                 "error": "No refresh token provided",
 88 |                 "status": "error"
 89 |             })
 90 |             
 91 |         try:
 92 |             # Set client_id and client_secret for refresh
 93 |             self.credentials._client_id = client_id
 94 |             self.credentials._client_secret = client_secret
 95 |             
 96 |             # Force refresh
 97 |             request = Request()
 98 |             self.credentials.refresh(request)
 99 |             
100 |             # Get token expiration time
101 |             expiry = self.credentials.expiry
102 |             
103 |             # Return the new access token and its expiration
104 |             return json.dumps({
105 |                 "access_token": self.credentials.token,
106 |                 "expires_at": expiry.isoformat() if expiry else None,
107 |                 "expires_in": int((expiry - datetime.now(expiry.tzinfo)).total_seconds()) if expiry else None,
108 |                 "status": "success"
109 |             })
110 |             
111 |         except google.auth.exceptions.RefreshError as e:
112 |             logger.error(f"Token refresh error: {str(e)}")
113 |             return json.dumps({
114 |                 "error": "Token refresh failed. Please provide valid client ID and client secret.",
115 |                 "details": str(e),
116 |                 "status": "error"
117 |             })
118 |         except Exception as e:
119 |             logger.error(f"Exception: {str(e)}")
120 |             return json.dumps({
121 |                 "error": str(e),
122 |                 "status": "error"
123 |             })
124 | 
125 |     def extract_plain_text_body(self, msg_payload):
126 |         """Extract plain text body from message payload
127 |         
128 |         Args:
129 |             msg_payload: Gmail API message payload
130 |             
131 |         Returns:
132 |             tuple: (plain_text_body, body_size_in_bytes)
133 |         """
134 |         body_text = ""
135 |         body_size = 0
136 |         
137 |         # Helper function to process message parts recursively
138 |         def extract_from_parts(parts):
139 |             nonlocal body_text, body_size
140 |             
141 |             if not parts:
142 |                 return
143 |                 
144 |             for part in parts:
145 |                 mime_type = part.get('mimeType', '')
146 |                 
147 |                 # If this part is plain text
148 |                 if mime_type == 'text/plain':
149 |                     body_data = part.get('body', {}).get('data', '')
150 |                     if body_data:
151 |                         # Decode base64url encoded data
152 |                         decoded_bytes = base64.urlsafe_b64decode(body_data)
153 |                         body_size += len(decoded_bytes)
154 |                         body_part = decoded_bytes.decode('utf-8', errors='replace')
155 |                         body_text += body_part
156 |                 
157 |                 # If this part has child parts, process them
158 |                 if 'parts' in part:
159 |                     extract_from_parts(part['parts'])
160 |         
161 |         # If body data is directly in the payload
162 |         if 'body' in msg_payload and 'data' in msg_payload['body']:
163 |             body_data = msg_payload['body']['data']
164 |             if body_data:
165 |                 decoded_bytes = base64.urlsafe_b64decode(body_data)
166 |                 body_size += len(decoded_bytes)
167 |                 body_text = decoded_bytes.decode('utf-8', errors='replace')
168 |         
169 |         # If message has parts, process them
170 |         if 'parts' in msg_payload:
171 |             extract_from_parts(msg_payload['parts'])
172 |             
173 |         return body_text, body_size
174 | 
175 |     def get_recent_emails(self, max_results: int = 10, unread_only: bool = False) -> str:
176 |         """Get the most recent emails from Gmail
177 |         
178 |         Args:
179 |             max_results: Maximum number of emails to return (default: 10)
180 |             unread_only: Whether to return only unread emails (default: False)
181 |             
182 |         Returns:
183 |             JSON string with an array of emails containing metadata, snippets, and first 1k chars of body
184 |         """
185 |         try:
186 |             # Check if service is initialized
187 |             if not hasattr(self, 'service'):
188 |                 logger.error("Gmail service not initialized. No valid access token provided.")
189 |                 return json.dumps({
190 |                     "error": "No valid access token provided. Please refresh your token first.",
191 |                     "status": "error"
192 |                 })
193 |                 
194 |             # Define the operation
195 |             def _operation():
196 |                 logger.debug(f"Fetching up to {max_results} recent emails from Gmail")
197 |                 
198 |                 # Get list of recent messages
199 |                 query = 'is:unread' if unread_only else ''
200 |                 logger.debug(f"Calling Gmail API to list messages from INBOX with query: '{query}'")
201 |                 
202 |                 try:
203 |                     response = self.service.users().messages().list(
204 |                         userId='me',
205 |                         maxResults=max_results,
206 |                         labelIds=['INBOX'],
207 |                         q=query
208 |                     ).execute()
209 |                     
210 |                     logger.debug(f"API Response received: {json.dumps(response)[:200]}...")
211 |                 except Exception as e:
212 |                     logger.error(f"Error calling Gmail API list: {str(e)}", exc_info=True)
213 |                     return json.dumps({"error": f"Gmail API list error: {str(e)}"})
214 |                 
215 |                 messages = response.get('messages', [])
216 |                 
217 |                 if not messages:
218 |                     logger.debug("No messages found in the response")
219 |                     return json.dumps({"emails": []})
220 |                 
221 |                 logger.debug(f"Found {len(messages)} messages, processing details")
222 |                 
223 |                 # Fetch detailed information for each message
224 |                 emails = []
225 |                 for i, message in enumerate(messages):
226 |                     logger.debug(f"Fetching details for message {i+1}/{len(messages)}, ID: {message['id']}")
227 |                     msg = self.service.users().messages().get(
228 |                         userId='me',
229 |                         id=message['id'],
230 |                         format='full'
231 |                     ).execute()
232 |                     
233 |                     logger.debug(f"Message {message['id']} details received, extracting fields")
234 |                     
235 |                     # Extract headers
236 |                     headers = {}
237 |                     if 'payload' in msg and 'headers' in msg['payload']:
238 |                         for header in msg['payload']['headers']:
239 |                             name = header.get('name', '').lower()
240 |                             if name in ['from', 'to', 'subject', 'date']:
241 |                                 headers[name] = header.get('value', '')
242 |                     else:
243 |                         logger.debug(f"Message {message['id']} missing payload or headers fields: {json.dumps(msg)[:200]}...")
244 |                     
245 |                     # Extract plain text body and size
246 |                     body_text = ""
247 |                     body_size_bytes = 0
248 |                     contains_full_body = True
249 |                     
250 |                     if 'payload' in msg:
251 |                         body_text, body_size_bytes = self.extract_plain_text_body(msg['payload'])
252 |                         
253 |                         # Check if we're returning the full body or truncating
254 |                         if len(body_text) > 1000:
255 |                             body_text = body_text[:1000]
256 |                             contains_full_body = False
257 |                     
258 |                     # Format the email
259 |                     email_data = {
260 |                         "id": msg['id'],
261 |                         "threadId": msg['threadId'],
262 |                         "labelIds": msg.get('labelIds', []),
263 |                         "snippet": msg.get('snippet', ''),
264 |                         "from": headers.get('from', ''),
265 |                         "to": headers.get('to', ''),
266 |                         "subject": headers.get('subject', ''),
267 |                         "date": headers.get('date', ''),
268 |                         "internalDate": msg.get('internalDate', ''),
269 |                         "body": body_text,
270 |                         "body_size_bytes": body_size_bytes,
271 |                         "contains_full_body": contains_full_body
272 |                     }
273 |                     
274 |                     logger.debug(f"Successfully processed message {message['id']}")
275 |                     emails.append(email_data)
276 |                 
277 |                 logger.debug(f"Successfully processed {len(emails)} emails")
278 |                 return json.dumps({"emails": convert_datetime_fields(emails)})
279 |             
280 |             # Execute the operation with token refresh handling
281 |             return self._handle_token_refresh(_operation)
282 |             
283 |         except HttpError as e:
284 |             logger.error(f"Gmail API Exception: {str(e)}")
285 |             return json.dumps({"error": str(e)})
286 |         except Exception as e:
287 |             logger.error(f"Exception in get_recent_emails: {str(e)}", exc_info=True)
288 |             return json.dumps({"error": str(e)})
289 | 
290 |     def send_email(self, to: str, subject: str, body: str, html_body: Optional[str] = None) -> str:
291 |         """Send an email via Gmail
292 |         
293 |         Args:
294 |             to: Recipient email address
295 |             subject: Email subject
296 |             body: Plain text email body
297 |             html_body: Optional HTML email body
298 |         """
299 |         try:
300 |             # Check if service is initialized
301 |             if not hasattr(self, 'service'):
302 |                 return json.dumps({
303 |                     "error": "No valid access token provided. Please refresh your token first.",
304 |                     "status": "error"
305 |                 })
306 |                 
307 |             # Define the operation
308 |             def _operation():
309 |                 # Create message container
310 |                 message = MIMEMultipart('alternative')
311 |                 message['to'] = to
312 |                 message['subject'] = subject
313 |                 
314 |                 # Attach plain text and HTML parts
315 |                 message.attach(MIMEText(body, 'plain'))
316 |                 if html_body:
317 |                     message.attach(MIMEText(html_body, 'html'))
318 |                 
319 |                 # Encode the message
320 |                 encoded_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
321 |                 
322 |                 # Create the message body
323 |                 create_message = {
324 |                     'raw': encoded_message
325 |                 }
326 |                 
327 |                 # Send the message
328 |                 send_response = self.service.users().messages().send(
329 |                     userId='me', 
330 |                     body=create_message
331 |                 ).execute()
332 |                 
333 |                 return json.dumps({
334 |                     "messageId": send_response['id'],
335 |                     "threadId": send_response.get('threadId', ''),
336 |                     "labelIds": send_response.get('labelIds', [])
337 |                 })
338 |             
339 |             # Execute the operation with token refresh handling
340 |             return self._handle_token_refresh(_operation)
341 |             
342 |         except HttpError as e:
343 |             logger.error(f"API Exception: {str(e)}")
344 |             return json.dumps({"error": str(e)})
345 |         except Exception as e:
346 |             logger.error(f"Exception: {str(e)}")
347 |             return json.dumps({"error": str(e)})
348 | 
349 |     def get_email_body_chunk(self, message_id: str = None, thread_id: str = None, offset: int = 0) -> str:
350 |         """Get a chunk of the email body
351 |         
352 |         Args:
353 |             message_id: ID of the message to retrieve
354 |             thread_id: ID of the thread to retrieve (will get the first message if multiple exist)
355 |             offset: Offset in characters to start from (default: 0)
356 |             
357 |         Returns:
358 |             JSON string with the body chunk and metadata
359 |         """
360 |         try:
361 |             # Check if service is initialized
362 |             if not hasattr(self, 'service'):
363 |                 logger.error("Gmail service not initialized. No valid access token provided.")
364 |                 return json.dumps({
365 |                     "error": "No valid access token provided. Please refresh your token first.",
366 |                     "status": "error"
367 |                 })
368 |                 
369 |             # Define the operation
370 |             def _operation():
371 |                 logger.debug(f"Fetching email body chunk with offset {offset}")
372 |                 
373 |                 # Store message_id in local variable to make it accessible within _operation scope
374 |                 local_message_id = message_id
375 |                 local_thread_id = thread_id
376 |                 
377 |                 # Validate inputs
378 |                 if not local_message_id and not local_thread_id:
379 |                     return json.dumps({
380 |                         "error": "Either message_id or thread_id must be provided",
381 |                         "status": "error"
382 |                     })
383 |                 
384 |                 try:
385 |                     # If thread_id is provided but not message_id, get the first message in thread
386 |                     if local_thread_id and not local_message_id:
387 |                         logger.debug(f"Getting messages in thread {local_thread_id}")
388 |                         thread = self.service.users().threads().get(
389 |                             userId='me',
390 |                             id=local_thread_id
391 |                         ).execute()
392 |                         
393 |                         if not thread or 'messages' not in thread or not thread['messages']:
394 |                             return json.dumps({
395 |                                 "error": f"No messages found in thread {local_thread_id}",
396 |                                 "status": "error"
397 |                             })
398 |                             
399 |                         # Use the first message in the thread
400 |                         local_message_id = thread['messages'][0]['id']
401 |                         logger.debug(f"Using first message {local_message_id} from thread {local_thread_id}")
402 |                     
403 |                     # Get the message
404 |                     logger.debug(f"Getting message {local_message_id}")
405 |                     msg = self.service.users().messages().get(
406 |                         userId='me',
407 |                         id=local_message_id,
408 |                         format='full'
409 |                     ).execute()
410 |                     
411 |                     # Extract the full plain text body
412 |                     body_text = ""
413 |                     body_size_bytes = 0
414 |                     
415 |                     if 'payload' in msg:
416 |                         body_text, body_size_bytes = self.extract_plain_text_body(msg['payload'])
417 |                     
418 |                     # Apply offset and get chunk
419 |                     if offset >= len(body_text):
420 |                         chunk = ""
421 |                     else:
422 |                         chunk = body_text[offset:offset+1000]
423 |                     
424 |                     # Determine if this contains the full remaining body
425 |                     contains_full_body = (offset + len(chunk) >= len(body_text))
426 |                     
427 |                     return json.dumps({
428 |                         "message_id": local_message_id,
429 |                         "thread_id": msg.get('threadId', ''),
430 |                         "body": chunk,
431 |                         "body_size_bytes": body_size_bytes,
432 |                         "offset": offset,
433 |                         "chunk_size": len(chunk),
434 |                         "contains_full_body": contains_full_body,
435 |                         "status": "success"
436 |                     })
437 |                     
438 |                 except Exception as e:
439 |                     logger.error(f"Error processing message: {str(e)}", exc_info=True)
440 |                     return json.dumps({
441 |                         "error": f"Error processing message: {str(e)}",
442 |                         "status": "error"
443 |                     })
444 |             
445 |             # Execute the operation with token refresh handling
446 |             return self._handle_token_refresh(_operation)
447 |             
448 |         except HttpError as e:
449 |             logger.error(f"Gmail API Exception: {str(e)}")
450 |             return json.dumps({"error": str(e)})
451 |         except Exception as e:
452 |             logger.error(f"Exception in get_email_body_chunk: {str(e)}", exc_info=True)
453 |             return json.dumps({"error": str(e)})
454 | 
455 | async def main():
456 |     """Run the Gmail MCP server."""
457 |     logger.info("Gmail server starting")
458 |     server = Server("gmail-client")
459 | 
460 |     @server.list_resources()
461 |     async def handle_list_resources() -> list[types.Resource]:
462 |         return []
463 | 
464 |     @server.read_resource()
465 |     async def handle_read_resource(uri: AnyUrl) -> str:
466 |         if uri.scheme != "gmail":
467 |             raise ValueError(f"Unsupported URI scheme: {uri.scheme}")
468 | 
469 |         path = str(uri).replace("gmail://", "")
470 |         return ""
471 | 
472 |     @server.list_tools()
473 |     async def handle_list_tools() -> list[types.Tool]:
474 |         """List available tools"""
475 |         return [
476 |             types.Tool(
477 |                 name="gmail_refresh_token",
478 |                 description="Refresh the access token using the refresh token and client credentials",
479 |                 inputSchema={
480 |                     "type": "object",
481 |                     "properties": {
482 |                         "google_access_token": {"type": "string", "description": "Google OAuth2 access token (optional if expired)"},
483 |                         "google_refresh_token": {"type": "string", "description": "Google OAuth2 refresh token"},
484 |                         "google_client_id": {"type": "string", "description": "Google OAuth2 client ID for token refresh"},
485 |                         "google_client_secret": {"type": "string", "description": "Google OAuth2 client secret for token refresh"}
486 |                     },
487 |                     "required": ["google_refresh_token", "google_client_id", "google_client_secret"]
488 |                 },
489 |             ),
490 |             types.Tool(
491 |                 name="gmail_get_recent_emails",
492 |                 description="Get the most recent emails from Gmail (returns metadata, snippets, and first 1k chars of body)",
493 |                 inputSchema={
494 |                     "type": "object",
495 |                     "properties": {
496 |                         "google_access_token": {"type": "string", "description": "Google OAuth2 access token"},
497 |                         "max_results": {"type": "integer", "description": "Maximum number of emails to return (default: 10)"},
498 |                         "unread_only": {"type": "boolean", "description": "Whether to return only unread emails (default: False)"}
499 |                     },
500 |                     "required": ["google_access_token"]
501 |                 },
502 |             ),
503 |             types.Tool(
504 |                 name="gmail_get_email_body_chunk",
505 |                 description="Get a 1k character chunk of an email body starting from the specified offset",
506 |                 inputSchema={
507 |                     "type": "object",
508 |                     "properties": {
509 |                         "google_access_token": {"type": "string", "description": "Google OAuth2 access token"},
510 |                         "message_id": {"type": "string", "description": "ID of the message to retrieve"},
511 |                         "thread_id": {"type": "string", "description": "ID of the thread to retrieve (will get the first message if multiple exist)"},
512 |                         "offset": {"type": "integer", "description": "Offset in characters to start from (default: 0)"}
513 |                     },
514 |                     "required": ["google_access_token"]
515 |                 },
516 |             ),
517 |             types.Tool(
518 |                 name="gmail_send_email",
519 |                 description="Send an email via Gmail",
520 |                 inputSchema={
521 |                     "type": "object",
522 |                     "properties": {
523 |                         "google_access_token": {"type": "string", "description": "Google OAuth2 access token"},
524 |                         "to": {"type": "string", "description": "Recipient email address"},
525 |                         "subject": {"type": "string", "description": "Email subject"},
526 |                         "body": {"type": "string", "description": "Email body content (plain text)"},
527 |                         "html_body": {"type": "string", "description": "Email body content in HTML format (optional)"}
528 |                     },
529 |                     "required": ["google_access_token", "to", "subject", "body"]
530 |                 },
531 |             ),
532 |         ]
533 | 
534 |     @server.call_tool()
535 |     async def handle_call_tool(
536 |         name: str, arguments: dict[str, Any] | None
537 |     ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
538 |         """Handle tool execution requests"""
539 |         try:
540 |             if not arguments:
541 |                 raise ValueError(f"Missing arguments for {name}")
542 |             
543 |             if name == "gmail_refresh_token":
544 |                 # For refresh token, we need refresh token, client ID and secret
545 |                 refresh_token = arguments.get("google_refresh_token")
546 |                 client_id = arguments.get("google_client_id")
547 |                 client_secret = arguments.get("google_client_secret")
548 |                 access_token = arguments.get("google_access_token")  # Optional for refresh
549 |                 
550 |                 if not refresh_token:
551 |                     raise ValueError("google_refresh_token is required for token refresh")
552 |                 
553 |                 if not client_id or not client_secret:
554 |                     raise ValueError("Both google_client_id and google_client_secret are required for token refresh")
555 |                 
556 |                 # Initialize Gmail client for token refresh
557 |                 gmail = GmailClient(
558 |                     access_token=access_token, 
559 |                     refresh_token=refresh_token
560 |                 )
561 |                 
562 |                 # Call the refresh_token method
563 |                 results = gmail.refresh_token(client_id=client_id, client_secret=client_secret)
564 |                 return [types.TextContent(type="text", text=results)]
565 |             
566 |             else:
567 |                 # For all other tools, we only need access token
568 |                 access_token = arguments.get("google_access_token")
569 |                 
570 |                 if not access_token:
571 |                     raise ValueError("google_access_token is required")
572 |                 
573 |                 if name == "gmail_get_recent_emails":
574 |                     # Initialize Gmail client with just access token
575 |                     logger.debug(f"Initializing Gmail client for get_recent_emails with access token: {access_token[:10]}...")
576 |                     try:
577 |                         gmail = GmailClient(
578 |                             access_token=access_token
579 |                         )
580 |                         logger.debug("Gmail client initialized successfully")
581 |                         
582 |                         max_results = int(arguments.get("max_results", 10))
583 |                         unread_only = bool(arguments.get("unread_only", False))
584 |                         logger.debug(f"Calling get_recent_emails with max_results={max_results} and unread_only={unread_only}")
585 |                         results = gmail.get_recent_emails(max_results=max_results, unread_only=unread_only)
586 |                         logger.debug(f"get_recent_emails result (first 200 chars): {results[:200]}...")
587 |                         return [types.TextContent(type="text", text=results)]
588 |                     except Exception as e:
589 |                         logger.error(f"Exception in gmail_get_recent_emails handler: {str(e)}", exc_info=True)
590 |                         return [types.TextContent(type="text", text=f"Error: {str(e)}")]
591 |                     
592 |                 elif name == "gmail_get_email_body_chunk":
593 |                     # Initialize Gmail client with just access token
594 |                     gmail = GmailClient(
595 |                         access_token=access_token
596 |                     )
597 |                     
598 |                     message_id = arguments.get("message_id")
599 |                     thread_id = arguments.get("thread_id")
600 |                     offset = int(arguments.get("offset", 0))
601 |                     
602 |                     if not message_id and not thread_id:
603 |                         raise ValueError("Either message_id or thread_id must be provided")
604 |                     
605 |                     results = gmail.get_email_body_chunk(message_id=message_id, thread_id=thread_id, offset=offset)
606 |                     return [types.TextContent(type="text", text=results)]
607 |                     
608 |                 elif name == "gmail_send_email":
609 |                     # Initialize Gmail client with just access token
610 |                     gmail = GmailClient(
611 |                         access_token=access_token
612 |                     )
613 |                     
614 |                     to = arguments.get("to")
615 |                     subject = arguments.get("subject")
616 |                     body = arguments.get("body")
617 |                     html_body = arguments.get("html_body")
618 |                     
619 |                     if not to or not subject or not body:
620 |                         raise ValueError("Missing required parameters: to, subject, and body are required")
621 |                     
622 |                     results = gmail.send_email(to=to, subject=subject, body=body, html_body=html_body)
623 |                     return [types.TextContent(type="text", text=results)]
624 | 
625 |                 else:
626 |                     raise ValueError(f"Unknown tool: {name}")
627 | 
628 |         except Exception as e:
629 |             return [types.TextContent(type="text", text=f"Error: {str(e)}")]
630 | 
631 |     async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
632 |         logger.info("Server running with stdio transport")
633 |         await server.run(
634 |             read_stream,
635 |             write_stream,
636 |             InitializationOptions(
637 |                 server_name="gmail",
638 |                 server_version="0.1.0",
639 |                 capabilities=server.get_capabilities(
640 |                     notification_options=NotificationOptions(),
641 |                     experimental_capabilities={},
642 |                 ),
643 |             ),
644 |         )
645 | 
646 | if __name__ == "__main__":
647 |     import asyncio
648 |     
649 |     # Simplified command-line with no OAuth parameters
650 |     asyncio.run(main()) 
```