gmail-workspace-mcp-server 0.2.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -152,11 +152,11 @@ You can find the `client_email` and `private_key` values in your service account
152
152
 
153
153
  The server supports three tool groups for permission-based access control:
154
154
 
155
- | Group | Tools Included | Risk Level |
156
- | -------------------- | ---------------------------------------------------------------------------------- | ---------- |
157
- | `readonly` | `list_email_conversations`, `get_email_conversation`, `search_email_conversations` | Low |
158
- | `readwrite` | All readonly tools + `change_email_conversation`, `draft_email` | Medium |
159
- | `readwrite_external` | All readwrite tools + `send_email` | High |
155
+ | Group | Tools Included | Risk Level |
156
+ | -------------------- | ------------------------------------------------------------------------------------------------------- | ---------- |
157
+ | `readonly` | `list_email_conversations`, `get_email_conversation`, `search_email_conversations`, `list_draft_emails` | Low |
158
+ | `readwrite` | All readonly tools + `change_email_conversation`, `upsert_draft_email` | Medium |
159
+ | `readwrite_external` | All readwrite tools + `send_email` | High |
160
160
 
161
161
  By default, all tool groups are enabled. To restrict access, set the `GMAIL_ENABLED_TOOLGROUPS` environment variable:
162
162
 
@@ -254,12 +254,30 @@ Modify email labels and status (read/unread, starred, archived).
254
254
  }
255
255
  ```
256
256
 
257
- ### draft_email
257
+ ### list_draft_emails
258
258
 
259
- Create a draft email, optionally as a reply to an existing conversation.
259
+ List draft emails from Gmail with optional thread filtering.
260
260
 
261
261
  **Parameters:**
262
262
 
263
+ - `count` (number, optional): Maximum number of drafts to return (default: 10, max: 100)
264
+ - `thread_id` (string, optional): Filter drafts by conversation thread ID
265
+
266
+ **Example:**
267
+
268
+ ```json
269
+ {
270
+ "thread_id": "18abc123def456"
271
+ }
272
+ ```
273
+
274
+ ### upsert_draft_email
275
+
276
+ Create a new draft email or update an existing one. Optionally as a reply to an existing conversation.
277
+
278
+ **Parameters:**
279
+
280
+ - `draft_id` (string, optional): ID of an existing draft to update (omit to create a new draft)
263
281
  - `to` (string, required): Recipient email address
264
282
  - `subject` (string, required): Email subject
265
283
  - `plaintext_body` (string): Plain text body content (at least one of plaintext_body or html_body required)
@@ -271,7 +289,7 @@ Create a draft email, optionally as a reply to an existing conversation.
271
289
 
272
290
  At least one of `plaintext_body` or `html_body` must be provided. If both are provided, a multipart email is sent with both versions.
273
291
 
274
- **Example (plain text):**
292
+ **Example (create new draft):**
275
293
 
276
294
  ```json
277
295
  {
@@ -281,12 +299,13 @@ At least one of `plaintext_body` or `html_body` must be provided. If both are pr
281
299
  }
282
300
  ```
283
301
 
284
- **Example (HTML):**
302
+ **Example (update existing draft):**
285
303
 
286
304
  ```json
287
305
  {
306
+ "draft_id": "r123456789",
288
307
  "to": "recipient@example.com",
289
- "subject": "Meeting Follow-up",
308
+ "subject": "Meeting Follow-up (revised)",
290
309
  "html_body": "<p>Thanks for the meeting today! Check out <a href=\"https://example.com/notes\">the notes</a>.</p>"
291
310
  }
292
311
  ```
@@ -193,6 +193,39 @@ function createMockClient() {
193
193
  mockDrafts.push(draft);
194
194
  return draft;
195
195
  },
196
+ async updateDraft(draftId, options) {
197
+ const index = mockDrafts.findIndex((d) => d.id === draftId);
198
+ if (index === -1) {
199
+ throw new Error(`Draft not found: ${draftId}`);
200
+ }
201
+ const bodyContent = options.plaintextBody || options.htmlBody || '';
202
+ const updatedDraft = {
203
+ id: draftId,
204
+ message: {
205
+ id: mockDrafts[index].message.id,
206
+ threadId: options.threadId || mockDrafts[index].message.threadId,
207
+ labelIds: ['DRAFT'],
208
+ snippet: bodyContent.substring(0, 100),
209
+ historyId: '12347',
210
+ internalDate: String(Date.now()),
211
+ payload: {
212
+ mimeType: options.htmlBody ? 'text/html' : 'text/plain',
213
+ headers: [
214
+ { name: 'Subject', value: options.subject },
215
+ { name: 'From', value: 'me@example.com' },
216
+ { name: 'To', value: options.to },
217
+ { name: 'Date', value: new Date().toISOString() },
218
+ ],
219
+ body: {
220
+ size: bodyContent.length,
221
+ data: Buffer.from(bodyContent).toString('base64url'),
222
+ },
223
+ },
224
+ },
225
+ };
226
+ mockDrafts[index] = updatedDraft;
227
+ return updatedDraft;
228
+ },
196
229
  async getDraft(draftId) {
197
230
  const draft = mockDrafts.find((d) => d.id === draftId);
198
231
  if (!draft) {
@@ -0,0 +1,13 @@
1
+ import type { ElicitationConfig } from './types.js';
2
+ /**
3
+ * Reads elicitation configuration from environment variables.
4
+ *
5
+ * Environment variables:
6
+ * ELICITATION_ENABLED - "true" (default) or "false"
7
+ * ELICITATION_REQUEST_URL - POST endpoint for HTTP fallback
8
+ * ELICITATION_POLL_URL - GET endpoint for HTTP fallback polling
9
+ * ELICITATION_TTL_MS - Request TTL in milliseconds (default: 300000)
10
+ * ELICITATION_POLL_INTERVAL_MS - Poll interval in milliseconds (default: 5000, min: 1000)
11
+ * ELICITATION_SESSION_ID - Session identifier for HTTP fallback `_meta`
12
+ */
13
+ export declare function readElicitationConfig(env?: Record<string, string | undefined>): ElicitationConfig;
@@ -0,0 +1,36 @@
1
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
2
+ const DEFAULT_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds
3
+ const MIN_POLL_INTERVAL_MS = 1000; // 1 second minimum to prevent tight loops
4
+ /**
5
+ * Parses a positive integer from a string, returning the default if invalid.
6
+ */
7
+ function parsePositiveInt(value, defaultValue) {
8
+ if (!value)
9
+ return defaultValue;
10
+ const parsed = parseInt(value, 10);
11
+ return Number.isNaN(parsed) || parsed < 0 ? defaultValue : parsed;
12
+ }
13
+ /**
14
+ * Reads elicitation configuration from environment variables.
15
+ *
16
+ * Environment variables:
17
+ * ELICITATION_ENABLED - "true" (default) or "false"
18
+ * ELICITATION_REQUEST_URL - POST endpoint for HTTP fallback
19
+ * ELICITATION_POLL_URL - GET endpoint for HTTP fallback polling
20
+ * ELICITATION_TTL_MS - Request TTL in milliseconds (default: 300000)
21
+ * ELICITATION_POLL_INTERVAL_MS - Poll interval in milliseconds (default: 5000, min: 1000)
22
+ * ELICITATION_SESSION_ID - Session identifier for HTTP fallback `_meta`
23
+ */
24
+ export function readElicitationConfig(env = process.env) {
25
+ const enabledRaw = env.ELICITATION_ENABLED;
26
+ const enabled = enabledRaw === undefined ? true : enabledRaw.toLowerCase() !== 'false';
27
+ const pollIntervalMs = Math.max(MIN_POLL_INTERVAL_MS, parsePositiveInt(env.ELICITATION_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS));
28
+ return {
29
+ enabled,
30
+ requestUrl: env.ELICITATION_REQUEST_URL,
31
+ pollUrl: env.ELICITATION_POLL_URL,
32
+ ttlMs: parsePositiveInt(env.ELICITATION_TTL_MS, DEFAULT_TTL_MS),
33
+ pollIntervalMs,
34
+ sessionId: env.ELICITATION_SESSION_ID,
35
+ };
36
+ }
@@ -0,0 +1,19 @@
1
+ import type { ElicitationConfig, ElicitationRequestedSchema, ElicitationResult, RequestConfirmationOptions } from './types.js';
2
+ /**
3
+ * Requests user confirmation through the best available mechanism.
4
+ *
5
+ * Decision tree:
6
+ * 1. If elicitation is disabled (`ELICITATION_ENABLED=false`), returns `accept` immediately.
7
+ * 2. If the client supports native elicitation, uses `server.elicitInput()`.
8
+ * 3. If HTTP fallback URLs are configured, posts to the external endpoint and polls.
9
+ * 4. Otherwise, throws an error indicating no elicitation mechanism is available.
10
+ *
11
+ * @param options - Configuration for the confirmation request.
12
+ * @param config - Elicitation config (defaults to reading from env vars).
13
+ * @returns The user's response.
14
+ */
15
+ export declare function requestConfirmation(options: RequestConfirmationOptions, config?: ElicitationConfig): Promise<ElicitationResult>;
16
+ /**
17
+ * Creates a simple boolean confirmation schema for common "are you sure?" prompts.
18
+ */
19
+ export declare function createConfirmationSchema(title?: string, description?: string): ElicitationRequestedSchema;
@@ -0,0 +1,137 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readElicitationConfig } from './config.js';
3
+ /**
4
+ * Checks whether the connected client supports native form elicitation.
5
+ */
6
+ function clientSupportsElicitation(server) {
7
+ const caps = server.getClientCapabilities();
8
+ if (!caps?.elicitation) {
9
+ return false;
10
+ }
11
+ // If elicitation is declared at all (even empty {}), form mode is supported
12
+ // per the MCP spec's backward compatibility rules.
13
+ return true;
14
+ }
15
+ /**
16
+ * Attempts native elicitation via the MCP SDK's `server.elicitInput()`.
17
+ */
18
+ async function nativeElicit(server, message, requestedSchema) {
19
+ const params = {
20
+ mode: 'form',
21
+ message,
22
+ requestedSchema,
23
+ };
24
+ const result = await server.elicitInput(params);
25
+ return {
26
+ action: result.action,
27
+ content: result.content ?? undefined,
28
+ };
29
+ }
30
+ /**
31
+ * Posts an elicitation request to the HTTP fallback endpoint.
32
+ */
33
+ async function postElicitationRequest(config, message, requestedSchema, meta) {
34
+ const response = await fetch(config.requestUrl, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({
38
+ mode: 'form',
39
+ message,
40
+ requestedSchema,
41
+ _meta: meta,
42
+ }),
43
+ });
44
+ if (!response.ok) {
45
+ const body = await response.text().catch(() => '');
46
+ throw new Error(`Elicitation POST failed: ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`);
47
+ }
48
+ const data = (await response.json());
49
+ return data;
50
+ }
51
+ /**
52
+ * Polls the HTTP fallback endpoint until the request is resolved or expires.
53
+ */
54
+ async function pollElicitationStatus(config, requestId, expiresAt) {
55
+ const pollUrl = config.pollUrl.endsWith('/')
56
+ ? `${config.pollUrl}${requestId}`
57
+ : `${config.pollUrl}/${requestId}`;
58
+ while (Date.now() < expiresAt) {
59
+ const response = await fetch(pollUrl, {
60
+ method: 'GET',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ });
63
+ if (!response.ok) {
64
+ const body = await response.text().catch(() => '');
65
+ throw new Error(`Elicitation poll failed: ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`);
66
+ }
67
+ const data = (await response.json());
68
+ if (data.action !== 'pending') {
69
+ return {
70
+ action: data.action,
71
+ content: data.content ?? undefined,
72
+ };
73
+ }
74
+ // Wait before polling again
75
+ await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs));
76
+ }
77
+ return { action: 'expired' };
78
+ }
79
+ /**
80
+ * Requests user confirmation through the best available mechanism.
81
+ *
82
+ * Decision tree:
83
+ * 1. If elicitation is disabled (`ELICITATION_ENABLED=false`), returns `accept` immediately.
84
+ * 2. If the client supports native elicitation, uses `server.elicitInput()`.
85
+ * 3. If HTTP fallback URLs are configured, posts to the external endpoint and polls.
86
+ * 4. Otherwise, throws an error indicating no elicitation mechanism is available.
87
+ *
88
+ * @param options - Configuration for the confirmation request.
89
+ * @param config - Elicitation config (defaults to reading from env vars).
90
+ * @returns The user's response.
91
+ */
92
+ export async function requestConfirmation(options, config) {
93
+ const cfg = config ?? readElicitationConfig();
94
+ // Tier 1: Disabled — skip confirmation entirely
95
+ if (!cfg.enabled) {
96
+ return { action: 'accept' };
97
+ }
98
+ // Tier 2: Native elicitation
99
+ if (clientSupportsElicitation(options.server)) {
100
+ return nativeElicit(options.server, options.message, options.requestedSchema);
101
+ }
102
+ // Tier 3: HTTP fallback
103
+ if (cfg.requestUrl && cfg.pollUrl) {
104
+ const clientRequestId = randomUUID();
105
+ const expiresAt = Date.now() + cfg.ttlMs;
106
+ const meta = {
107
+ 'com.pulsemcp/request-id': clientRequestId,
108
+ 'com.pulsemcp/expires-at': new Date(expiresAt).toISOString(),
109
+ ...(cfg.sessionId && { 'com.pulsemcp/session-id': cfg.sessionId }),
110
+ ...options.meta,
111
+ };
112
+ const postResponse = await postElicitationRequest(cfg, options.message, options.requestedSchema, meta);
113
+ // Use the server-provided requestId if available, otherwise fall back to the client-generated one
114
+ const requestId = postResponse.requestId || clientRequestId;
115
+ return pollElicitationStatus(cfg, requestId, expiresAt);
116
+ }
117
+ // Tier 4: No mechanism available
118
+ throw new Error('Elicitation is enabled but no mechanism is available. ' +
119
+ 'Either the client must support native elicitation, or ' +
120
+ 'ELICITATION_REQUEST_URL and ELICITATION_POLL_URL must be configured for HTTP fallback.');
121
+ }
122
+ /**
123
+ * Creates a simple boolean confirmation schema for common "are you sure?" prompts.
124
+ */
125
+ export function createConfirmationSchema(title = 'Confirm', description) {
126
+ return {
127
+ type: 'object',
128
+ properties: {
129
+ confirm: {
130
+ type: 'boolean',
131
+ title,
132
+ ...(description ? { description } : {}),
133
+ },
134
+ },
135
+ required: ['confirm'],
136
+ };
137
+ }
@@ -0,0 +1,3 @@
1
+ export { requestConfirmation, createConfirmationSchema } from './elicitation.js';
2
+ export { readElicitationConfig } from './config.js';
3
+ export type { ElicitationConfig, ElicitationFieldSchema, ElicitationMeta, ElicitationPollResponse, ElicitationPostResponse, ElicitationRequestedSchema, ElicitationResult, MCPServerLike, RequestConfirmationOptions, } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { requestConfirmation, createConfirmationSchema } from './elicitation.js';
2
+ export { readElicitationConfig } from './config.js';
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Minimal interface for the MCP server, avoiding direct dependency on
3
+ * @modelcontextprotocol/sdk Server type to prevent cross-package type
4
+ * mismatches in monorepo setups with multiple SDK installations.
5
+ */
6
+ export interface MCPServerLike {
7
+ getClientCapabilities(): {
8
+ elicitation?: unknown;
9
+ } | undefined;
10
+ elicitInput(params: unknown): Promise<{
11
+ action: 'accept' | 'decline' | 'cancel';
12
+ content?: Record<string, string | number | boolean | string[]>;
13
+ }>;
14
+ }
15
+ /**
16
+ * Vendor metadata for PulseMCP elicitation requests.
17
+ * Uses reverse-DNS prefix `com.pulsemcp/` per MCP spec conventions.
18
+ */
19
+ export interface ElicitationMeta {
20
+ 'com.pulsemcp/request-id'?: string;
21
+ 'com.pulsemcp/tool-name'?: string;
22
+ 'com.pulsemcp/context'?: string;
23
+ 'com.pulsemcp/session-id'?: string;
24
+ 'com.pulsemcp/expires-at'?: string;
25
+ }
26
+ /**
27
+ * Schema for a single field in a form elicitation request.
28
+ * Maps to PrimitiveSchemaDefinition in the MCP spec.
29
+ */
30
+ export interface ElicitationFieldSchema {
31
+ type: 'string' | 'number' | 'integer' | 'boolean';
32
+ title?: string;
33
+ description?: string;
34
+ default?: string | number | boolean;
35
+ minLength?: number;
36
+ maxLength?: number;
37
+ format?: 'email' | 'uri' | 'date' | 'date-time';
38
+ enum?: string[];
39
+ minimum?: number;
40
+ maximum?: number;
41
+ }
42
+ /**
43
+ * Schema for the form presented to users during elicitation.
44
+ */
45
+ export interface ElicitationRequestedSchema {
46
+ type: 'object';
47
+ properties: Record<string, ElicitationFieldSchema>;
48
+ required?: string[];
49
+ }
50
+ /**
51
+ * Configuration for the elicitation system.
52
+ * Read from environment variables at initialization.
53
+ */
54
+ export interface ElicitationConfig {
55
+ /** Whether elicitation is enabled at all. Default: true (reads from ELICITATION_ENABLED env var). */
56
+ enabled: boolean;
57
+ /** POST endpoint for creating approval requests (HTTP fallback). */
58
+ requestUrl?: string;
59
+ /** Base URL for polling approval status (HTTP fallback). */
60
+ pollUrl?: string;
61
+ /** TTL for elicitation requests in milliseconds. Default: 5 minutes. */
62
+ ttlMs: number;
63
+ /** Poll interval in milliseconds. Default: 5 seconds. */
64
+ pollIntervalMs: number;
65
+ /** Session identifier included as `com.pulsemcp/session-id` in `_meta` of HTTP fallback requests. */
66
+ sessionId?: string;
67
+ }
68
+ /**
69
+ * The result of an elicitation request.
70
+ */
71
+ export interface ElicitationResult {
72
+ action: 'accept' | 'decline' | 'cancel' | 'expired';
73
+ content?: Record<string, string | number | boolean | string[]>;
74
+ }
75
+ /**
76
+ * HTTP fallback response from the polling endpoint.
77
+ */
78
+ export interface ElicitationPollResponse {
79
+ action: 'pending' | 'accept' | 'decline' | 'cancel' | 'expired';
80
+ content?: Record<string, string | number | boolean | string[]> | null;
81
+ _meta?: {
82
+ 'com.pulsemcp/request-id'?: string;
83
+ 'com.pulsemcp/responded-at'?: string | null;
84
+ };
85
+ }
86
+ /**
87
+ * HTTP fallback response from the POST request endpoint.
88
+ */
89
+ export interface ElicitationPostResponse {
90
+ requestId: string;
91
+ }
92
+ /**
93
+ * Options passed to the requestConfirmation function.
94
+ */
95
+ export interface RequestConfirmationOptions {
96
+ /** The MCP server instance (needed for native elicitation). */
97
+ server: MCPServerLike;
98
+ /** Human-readable message explaining what needs confirmation. */
99
+ message: string;
100
+ /** Schema for the form fields to present. */
101
+ requestedSchema: ElicitationRequestedSchema;
102
+ /** Optional vendor metadata. */
103
+ meta?: ElicitationMeta;
104
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@pulsemcp/mcp-elicitation",
3
+ "version": "1.0.1",
4
+ "description": "Elicitation support library for PulseMCP MCP servers - provides native elicitation with HTTP fallback",
5
+ "type": "module",
6
+ "main": "build/index.js",
7
+ "types": "build/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "test": "vitest run",
11
+ "lint": "eslint . --ext .ts,.tsx",
12
+ "lint:fix": "eslint . --ext .ts,.tsx --fix",
13
+ "format": "prettier --write .",
14
+ "format:check": "prettier --check ."
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "elicitation",
19
+ "pulsemcp"
20
+ ],
21
+ "author": "PulseMCP",
22
+ "license": "MIT",
23
+ "devDependencies": {
24
+ "@types/node": "^24.10.12",
25
+ "typescript": "^5.7.3",
26
+ "vitest": "^3.2.3"
27
+ }
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gmail-workspace-mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.4.3",
4
4
  "description": "MCP server for Gmail integration with OAuth2 and service account support",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -12,8 +12,13 @@
12
12
  "build/**/*.d.ts",
13
13
  "shared/**/*.js",
14
14
  "shared/**/*.d.ts",
15
+ "node_modules/@pulsemcp/mcp-elicitation/**/*.js",
16
+ "node_modules/@pulsemcp/mcp-elicitation/package.json",
15
17
  "README.md"
16
18
  ],
19
+ "bundledDependencies": [
20
+ "@pulsemcp/mcp-elicitation"
21
+ ],
17
22
  "scripts": {
18
23
  "build": "tsc && npm run build:integration",
19
24
  "build:integration": "tsc -p tsconfig.integration.json",
@@ -30,6 +35,7 @@
30
35
  },
31
36
  "dependencies": {
32
37
  "@modelcontextprotocol/sdk": "^1.19.1",
38
+ "@pulsemcp/mcp-elicitation": "file:../../../libs/elicitation",
33
39
  "google-auth-library": "^10.5.0",
34
40
  "zod": "^3.24.1"
35
41
  },
@@ -36,6 +36,20 @@ export declare function listDrafts(baseUrl: string, headers: Record<string, stri
36
36
  nextPageToken?: string;
37
37
  resultSizeEstimate?: number;
38
38
  }>;
39
+ /**
40
+ * Updates an existing draft email
41
+ */
42
+ export declare function updateDraft(baseUrl: string, headers: Record<string, string>, from: string, draftId: string, options: {
43
+ to: string;
44
+ subject: string;
45
+ plaintextBody?: string;
46
+ htmlBody?: string;
47
+ cc?: string;
48
+ bcc?: string;
49
+ threadId?: string;
50
+ inReplyTo?: string;
51
+ references?: string;
52
+ }): Promise<Draft>;
39
53
  /**
40
54
  * Deletes a draft
41
55
  */
@@ -66,6 +66,31 @@ export async function listDrafts(baseUrl, headers, options) {
66
66
  resultSizeEstimate: data.resultSizeEstimate,
67
67
  };
68
68
  }
69
+ /**
70
+ * Updates an existing draft email
71
+ */
72
+ export async function updateDraft(baseUrl, headers, from, draftId, options) {
73
+ const url = `${baseUrl}/drafts/${draftId}`;
74
+ const rawMessage = buildMimeMessage(from, options);
75
+ const encodedMessage = toBase64Url(rawMessage);
76
+ const requestBody = {
77
+ message: {
78
+ raw: encodedMessage,
79
+ },
80
+ };
81
+ if (options.threadId) {
82
+ requestBody.message.threadId = options.threadId;
83
+ }
84
+ const response = await fetch(url, {
85
+ method: 'PUT',
86
+ headers,
87
+ body: JSON.stringify(requestBody),
88
+ });
89
+ if (!response.ok) {
90
+ handleApiError(response.status, 'updating draft', draftId);
91
+ }
92
+ return (await response.json());
93
+ }
69
94
  /**
70
95
  * Deletes a draft
71
96
  */
@@ -11,6 +11,16 @@ export interface MimeMessageOptions {
11
11
  inReplyTo?: string;
12
12
  references?: string;
13
13
  }
14
+ /**
15
+ * Encodes a string as an RFC 2047 encoded-word using UTF-8 and Base64.
16
+ * Only encodes if the string contains non-ASCII characters.
17
+ *
18
+ * Note: RFC 2047 limits encoded-words to 75 characters. Very long non-ASCII
19
+ * subjects should technically be split into multiple encoded-words separated
20
+ * by folding whitespace. In practice, Gmail handles oversized encoded-words
21
+ * correctly, so we encode as a single word for simplicity.
22
+ */
23
+ export declare function encodeSubject(subject: string): string;
14
24
  /**
15
25
  * Builds a MIME message from email options.
16
26
  * If both plaintextBody and htmlBody are provided, creates a multipart/alternative message.
@@ -1,6 +1,30 @@
1
1
  /**
2
2
  * MIME message utilities for building and encoding email messages
3
3
  */
4
+ /**
5
+ * Encodes a string as an RFC 2047 encoded-word using UTF-8 and Base64.
6
+ * Only encodes if the string contains non-ASCII characters.
7
+ *
8
+ * Note: RFC 2047 limits encoded-words to 75 characters. Very long non-ASCII
9
+ * subjects should technically be split into multiple encoded-words separated
10
+ * by folding whitespace. In practice, Gmail handles oversized encoded-words
11
+ * correctly, so we encode as a single word for simplicity.
12
+ */
13
+ export function encodeSubject(subject) {
14
+ // eslint-disable-next-line no-control-regex
15
+ if (!/[^\x00-\x7F]/.test(subject)) {
16
+ return subject;
17
+ }
18
+ const encoded = Buffer.from(subject, 'utf-8').toString('base64');
19
+ return `=?UTF-8?B?${encoded}?=`;
20
+ }
21
+ /**
22
+ * Strips leading newline characters (\r\n, \n, and bare \r) from email body content.
23
+ * Prevents extra blank lines at the top of the email when displayed in Gmail.
24
+ */
25
+ function stripLeadingNewlines(body) {
26
+ return body.replace(/^[\r\n]+/, '');
27
+ }
4
28
  /**
5
29
  * Builds a MIME message from email options.
6
30
  * If both plaintextBody and htmlBody are provided, creates a multipart/alternative message.
@@ -10,7 +34,7 @@ export function buildMimeMessage(from, options) {
10
34
  const headers = [
11
35
  `From: ${from}`,
12
36
  `To: ${options.to}`,
13
- `Subject: ${options.subject}`,
37
+ `Subject: ${encodeSubject(options.subject)}`,
14
38
  'MIME-Version: 1.0',
15
39
  ];
16
40
  if (options.cc) {
@@ -29,9 +53,11 @@ export function buildMimeMessage(from, options) {
29
53
  if (options.plaintextBody && options.htmlBody) {
30
54
  const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(2)}`;
31
55
  headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
56
+ const plainBody = stripLeadingNewlines(options.plaintextBody);
57
+ const htmlBody = stripLeadingNewlines(options.htmlBody);
32
58
  const parts = [
33
- `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${options.plaintextBody}`,
34
- `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${options.htmlBody}`,
59
+ `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${plainBody}`,
60
+ `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${htmlBody}`,
35
61
  `--${boundary}--`,
36
62
  ];
37
63
  return headers.join('\r\n') + '\r\n\r\n' + parts.join('\r\n');
@@ -39,10 +65,10 @@ export function buildMimeMessage(from, options) {
39
65
  // Single content type
40
66
  if (options.htmlBody) {
41
67
  headers.push('Content-Type: text/html; charset=utf-8');
42
- return headers.join('\r\n') + '\r\n\r\n' + options.htmlBody;
68
+ return headers.join('\r\n') + '\r\n\r\n' + stripLeadingNewlines(options.htmlBody);
43
69
  }
44
70
  headers.push('Content-Type: text/plain; charset=utf-8');
45
- return headers.join('\r\n') + '\r\n\r\n' + (options.plaintextBody ?? '');
71
+ return headers.join('\r\n') + '\r\n\r\n' + stripLeadingNewlines(options.plaintextBody ?? '');
46
72
  }
47
73
  /**
48
74
  * Converts a string to base64url encoding (RFC 4648)
@@ -58,6 +58,20 @@ export interface IGmailClient {
58
58
  inReplyTo?: string;
59
59
  references?: string;
60
60
  }): Promise<Draft>;
61
+ /**
62
+ * Update an existing draft email
63
+ */
64
+ updateDraft(draftId: string, options: {
65
+ to: string;
66
+ subject: string;
67
+ plaintextBody?: string;
68
+ htmlBody?: string;
69
+ cc?: string;
70
+ bcc?: string;
71
+ threadId?: string;
72
+ inReplyTo?: string;
73
+ references?: string;
74
+ }): Promise<Draft>;
61
75
  /**
62
76
  * Get a draft by ID
63
77
  */
@@ -177,6 +191,17 @@ declare abstract class BaseGmailClient implements IGmailClient {
177
191
  inReplyTo?: string;
178
192
  references?: string;
179
193
  }): Promise<Draft>;
194
+ updateDraft(draftId: string, options: {
195
+ to: string;
196
+ subject: string;
197
+ plaintextBody?: string;
198
+ htmlBody?: string;
199
+ cc?: string;
200
+ bcc?: string;
201
+ threadId?: string;
202
+ inReplyTo?: string;
203
+ references?: string;
204
+ }): Promise<Draft>;
180
205
  getDraft(draftId: string): Promise<Draft>;
181
206
  listDrafts(options?: {
182
207
  maxResults?: number;
package/shared/server.js CHANGED
@@ -71,6 +71,12 @@ class BaseGmailClient {
71
71
  const { createDraft } = await import('./gmail-client/lib/drafts.js');
72
72
  return createDraft(this.baseUrl, headers, senderEmail, options);
73
73
  }
74
+ async updateDraft(draftId, options) {
75
+ const headers = await this.getHeaders();
76
+ const senderEmail = await this.getSenderEmail();
77
+ const { updateDraft } = await import('./gmail-client/lib/drafts.js');
78
+ return updateDraft(this.baseUrl, headers, senderEmail, draftId, options);
79
+ }
74
80
  async getDraft(draftId) {
75
81
  const headers = await this.getHeaders();
76
82
  const { getDraft } = await import('./gmail-client/lib/drafts.js');
@@ -1,7 +1,8 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { z } from 'zod';
3
3
  import type { ClientFactory } from '../server.js';
4
- export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
4
+ export declare const UpsertDraftEmailSchema: z.ZodEffects<z.ZodObject<{
5
+ draft_id: z.ZodOptional<z.ZodString>;
5
6
  to: z.ZodString;
6
7
  subject: z.ZodString;
7
8
  plaintext_body: z.ZodOptional<z.ZodString>;
@@ -13,6 +14,7 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
13
14
  }, "strip", z.ZodTypeAny, {
14
15
  to: string;
15
16
  subject: string;
17
+ draft_id?: string | undefined;
16
18
  plaintext_body?: string | undefined;
17
19
  html_body?: string | undefined;
18
20
  cc?: string | undefined;
@@ -22,6 +24,7 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
22
24
  }, {
23
25
  to: string;
24
26
  subject: string;
27
+ draft_id?: string | undefined;
25
28
  plaintext_body?: string | undefined;
26
29
  html_body?: string | undefined;
27
30
  cc?: string | undefined;
@@ -31,6 +34,7 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
31
34
  }>, {
32
35
  to: string;
33
36
  subject: string;
37
+ draft_id?: string | undefined;
34
38
  plaintext_body?: string | undefined;
35
39
  html_body?: string | undefined;
36
40
  cc?: string | undefined;
@@ -40,6 +44,7 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
40
44
  }, {
41
45
  to: string;
42
46
  subject: string;
47
+ draft_id?: string | undefined;
43
48
  plaintext_body?: string | undefined;
44
49
  html_body?: string | undefined;
45
50
  cc?: string | undefined;
@@ -47,12 +52,16 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
47
52
  thread_id?: string | undefined;
48
53
  reply_to_email_id?: string | undefined;
49
54
  }>;
50
- export declare function draftEmailTool(server: Server, clientFactory: ClientFactory): {
55
+ export declare function upsertDraftEmailTool(server: Server, clientFactory: ClientFactory): {
51
56
  name: string;
52
57
  description: string;
53
58
  inputSchema: {
54
59
  type: "object";
55
60
  properties: {
61
+ draft_id: {
62
+ type: string;
63
+ description: string;
64
+ };
56
65
  to: {
57
66
  type: string;
58
67
  description: "Recipient email address(es). For multiple recipients, separate with commas.";
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { getHeader } from '../utils/email-helpers.js';
3
3
  const PARAM_DESCRIPTIONS = {
4
+ draft_id: 'ID of an existing draft to update. If provided, the draft is replaced in-place ' +
5
+ 'with the new content. If omitted, a new draft is created. ' +
6
+ 'Get draft IDs from list_draft_emails or from a previous upsert_draft_email response.',
4
7
  to: 'Recipient email address(es). For multiple recipients, separate with commas.',
5
8
  subject: 'Subject line of the email.',
6
9
  plaintext_body: 'Plain text body content of the email. At least one of plaintext_body or html_body must be provided. If both are provided, a multipart email is sent with both versions.',
@@ -12,8 +15,9 @@ const PARAM_DESCRIPTIONS = {
12
15
  reply_to_email_id: 'Email ID to reply to. If provided, the draft will be formatted as a reply ' +
13
16
  'with proper In-Reply-To and References headers. Also requires thread_id.',
14
17
  };
15
- export const DraftEmailSchema = z
18
+ export const UpsertDraftEmailSchema = z
16
19
  .object({
20
+ draft_id: z.string().optional().describe(PARAM_DESCRIPTIONS.draft_id),
17
21
  to: z.string().min(1).describe(PARAM_DESCRIPTIONS.to),
18
22
  subject: z.string().min(1).describe(PARAM_DESCRIPTIONS.subject),
19
23
  plaintext_body: z.string().min(1).optional().describe(PARAM_DESCRIPTIONS.plaintext_body),
@@ -28,9 +32,10 @@ export const DraftEmailSchema = z
28
32
  }, {
29
33
  message: 'At least one of plaintext_body or html_body must be provided.',
30
34
  });
31
- const TOOL_DESCRIPTION = `Create a draft email that can be reviewed and sent later.
35
+ const TOOL_DESCRIPTION = `Create a new draft email or update an existing one.
32
36
 
33
37
  **Parameters:**
38
+ - draft_id: ID of an existing draft to update (optional — omit to create a new draft)
34
39
  - to: Recipient email address(es) (required)
35
40
  - subject: Email subject line (required)
36
41
  - plaintext_body: Plain text body content (at least one of plaintext_body or html_body required)
@@ -48,19 +53,27 @@ To create a draft reply to an existing email:
48
53
  1. Get the thread_id and email_id from get_email_conversation
49
54
  2. Provide both thread_id and reply_to_email_id parameters
50
55
 
56
+ **Updating a draft:**
57
+ To update an existing draft, provide the draft_id from a previous upsert_draft_email response or from list_draft_emails. The draft is replaced in-place — all fields must be provided (not just the ones you want to change).
58
+
51
59
  **Use cases:**
52
60
  - Draft a new email for later review
53
61
  - Prepare a reply to an email conversation
62
+ - Revise a draft after user feedback (without creating duplicates)
54
63
  - Save an email without sending it immediately
55
64
 
56
65
  **Note:** The draft will be saved in Gmail's Drafts folder. Use send_email with from_draft_id to send it.`;
57
- export function draftEmailTool(server, clientFactory) {
66
+ export function upsertDraftEmailTool(server, clientFactory) {
58
67
  return {
59
- name: 'draft_email',
68
+ name: 'upsert_draft_email',
60
69
  description: TOOL_DESCRIPTION,
61
70
  inputSchema: {
62
71
  type: 'object',
63
72
  properties: {
73
+ draft_id: {
74
+ type: 'string',
75
+ description: PARAM_DESCRIPTIONS.draft_id,
76
+ },
64
77
  to: {
65
78
  type: 'string',
66
79
  description: PARAM_DESCRIPTIONS.to,
@@ -98,7 +111,7 @@ export function draftEmailTool(server, clientFactory) {
98
111
  },
99
112
  handler: async (args) => {
100
113
  try {
101
- const parsed = DraftEmailSchema.parse(args ?? {});
114
+ const parsed = UpsertDraftEmailSchema.parse(args ?? {});
102
115
  const client = clientFactory();
103
116
  let inReplyTo;
104
117
  let references;
@@ -116,7 +129,7 @@ export function draftEmailTool(server, clientFactory) {
116
129
  references = originalReferences ? `${originalReferences} ${messageId}` : messageId;
117
130
  }
118
131
  }
119
- const draft = await client.createDraft({
132
+ const draftOptions = {
120
133
  to: parsed.to,
121
134
  subject: parsed.subject,
122
135
  plaintextBody: parsed.plaintext_body,
@@ -126,8 +139,13 @@ export function draftEmailTool(server, clientFactory) {
126
139
  threadId: parsed.thread_id,
127
140
  inReplyTo,
128
141
  references,
129
- });
130
- let responseText = `Draft created successfully!\n\n**Draft ID:** ${draft.id}`;
142
+ };
143
+ const isUpdate = Boolean(parsed.draft_id);
144
+ const draft = isUpdate
145
+ ? await client.updateDraft(parsed.draft_id, draftOptions)
146
+ : await client.createDraft(draftOptions);
147
+ const action = isUpdate ? 'updated' : 'created';
148
+ let responseText = `Draft ${action} successfully!\n\n**Draft ID:** ${draft.id}`;
131
149
  if (parsed.thread_id) {
132
150
  responseText += `\n**Thread ID:** ${parsed.thread_id}`;
133
151
  responseText += '\n\nThis draft is a reply in an existing conversation.';
@@ -162,7 +180,7 @@ export function draftEmailTool(server, clientFactory) {
162
180
  content: [
163
181
  {
164
182
  type: 'text',
165
- text: `Error creating draft: ${error instanceof Error ? error.message : 'Unknown error'}`,
183
+ text: `Error ${args?.draft_id ? 'updating' : 'creating'} draft: ${error instanceof Error ? error.message : 'Unknown error'}`,
166
184
  },
167
185
  ],
168
186
  isError: true,
@@ -0,0 +1,44 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { z } from 'zod';
3
+ import type { ClientFactory } from '../server.js';
4
+ export declare const ListDraftEmailsSchema: z.ZodObject<{
5
+ count: z.ZodDefault<z.ZodNumber>;
6
+ thread_id: z.ZodOptional<z.ZodString>;
7
+ }, "strip", z.ZodTypeAny, {
8
+ count: number;
9
+ thread_id?: string | undefined;
10
+ }, {
11
+ count?: number | undefined;
12
+ thread_id?: string | undefined;
13
+ }>;
14
+ export declare function listDraftEmailsTool(server: Server, clientFactory: ClientFactory): {
15
+ name: string;
16
+ description: string;
17
+ inputSchema: {
18
+ type: "object";
19
+ properties: {
20
+ count: {
21
+ type: string;
22
+ description: "Maximum number of drafts to return. Default: 10. Max: 100.";
23
+ };
24
+ thread_id: {
25
+ type: string;
26
+ description: string;
27
+ };
28
+ };
29
+ required: never[];
30
+ };
31
+ handler: (args: unknown) => Promise<{
32
+ content: {
33
+ type: string;
34
+ text: string;
35
+ }[];
36
+ isError?: undefined;
37
+ } | {
38
+ content: {
39
+ type: string;
40
+ text: string;
41
+ }[];
42
+ isError: boolean;
43
+ }>;
44
+ };
@@ -0,0 +1,123 @@
1
+ import { z } from 'zod';
2
+ import { getHeader } from '../utils/email-helpers.js';
3
+ const PARAM_DESCRIPTIONS = {
4
+ count: 'Maximum number of drafts to return. Default: 10. Max: 100.',
5
+ thread_id: 'Filter drafts by thread ID. Only returns drafts belonging to the specified conversation thread. ' +
6
+ 'Get thread IDs from list_email_conversations or get_email_conversation.',
7
+ };
8
+ export const ListDraftEmailsSchema = z.object({
9
+ count: z.number().positive().max(100).default(10).describe(PARAM_DESCRIPTIONS.count),
10
+ thread_id: z.string().optional().describe(PARAM_DESCRIPTIONS.thread_id),
11
+ });
12
+ const TOOL_DESCRIPTION = `List draft emails from Gmail.
13
+
14
+ **Parameters:**
15
+ - count: Maximum number of drafts to return (default: 10, max: 100)
16
+ - thread_id: Filter drafts by conversation thread ID (optional)
17
+
18
+ **Use cases:**
19
+ - Discover existing drafts before creating a new one
20
+ - Find a draft ID to pass to upsert_draft_email for updating
21
+ - Check which drafts exist for a specific email conversation
22
+
23
+ **Note:** Use the returned draft IDs with upsert_draft_email to update drafts, or with send_email's from_draft_id to send them.`;
24
+ export function listDraftEmailsTool(server, clientFactory) {
25
+ return {
26
+ name: 'list_draft_emails',
27
+ description: TOOL_DESCRIPTION,
28
+ inputSchema: {
29
+ type: 'object',
30
+ properties: {
31
+ count: {
32
+ type: 'number',
33
+ description: PARAM_DESCRIPTIONS.count,
34
+ },
35
+ thread_id: {
36
+ type: 'string',
37
+ description: PARAM_DESCRIPTIONS.thread_id,
38
+ },
39
+ },
40
+ required: [],
41
+ },
42
+ handler: async (args) => {
43
+ try {
44
+ const parsed = ListDraftEmailsSchema.parse(args ?? {});
45
+ const client = clientFactory();
46
+ // Fetch drafts — request more than needed if filtering by thread
47
+ // since the Gmail API doesn't support server-side thread filtering for drafts
48
+ const fetchCount = parsed.thread_id ? 100 : parsed.count;
49
+ const { drafts } = await client.listDrafts({ maxResults: fetchCount });
50
+ if (drafts.length === 0) {
51
+ return {
52
+ content: [
53
+ {
54
+ type: 'text',
55
+ text: 'No drafts found.',
56
+ },
57
+ ],
58
+ };
59
+ }
60
+ // Fetch full draft details to get headers (subject, to, etc.)
61
+ const fullDrafts = await Promise.all(drafts.map((d) => client.getDraft(d.id)));
62
+ // Filter by thread_id if specified
63
+ let filtered = fullDrafts;
64
+ if (parsed.thread_id) {
65
+ filtered = fullDrafts.filter((d) => d.message.threadId === parsed.thread_id);
66
+ }
67
+ // Apply count limit after filtering
68
+ filtered = filtered.slice(0, parsed.count);
69
+ if (filtered.length === 0) {
70
+ let noResultsText = parsed.thread_id
71
+ ? `No drafts found for thread ${parsed.thread_id}.`
72
+ : 'No drafts found.';
73
+ if (parsed.thread_id && drafts.length >= 100) {
74
+ noResultsText +=
75
+ '\n\n*Note: Only the most recent 100 drafts were searched. The draft may exist beyond this limit.*';
76
+ }
77
+ return {
78
+ content: [
79
+ {
80
+ type: 'text',
81
+ text: noResultsText,
82
+ },
83
+ ],
84
+ };
85
+ }
86
+ let responseText = `Found ${filtered.length} draft(s):\n`;
87
+ for (const draft of filtered) {
88
+ const subject = getHeader(draft.message, 'Subject') || '(No Subject)';
89
+ const to = getHeader(draft.message, 'To') || '(No Recipient)';
90
+ responseText += `\n---\n`;
91
+ responseText += `**Draft ID:** ${draft.id}\n`;
92
+ responseText += `**Thread ID:** ${draft.message.threadId}\n`;
93
+ responseText += `**To:** ${to}\n`;
94
+ responseText += `**Subject:** ${subject}\n`;
95
+ if (draft.message.snippet) {
96
+ responseText += `**Preview:** ${draft.message.snippet}\n`;
97
+ }
98
+ }
99
+ responseText +=
100
+ '\n---\n\nUse upsert_draft_email with a draft_id to update a draft, or send_email with from_draft_id to send one.';
101
+ return {
102
+ content: [
103
+ {
104
+ type: 'text',
105
+ text: responseText,
106
+ },
107
+ ],
108
+ };
109
+ }
110
+ catch (error) {
111
+ return {
112
+ content: [
113
+ {
114
+ type: 'text',
115
+ text: `Error listing drafts: ${error instanceof Error ? error.message : 'Unknown error'}`,
116
+ },
117
+ ],
118
+ isError: true,
119
+ };
120
+ }
121
+ },
122
+ };
123
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { getHeader } from '../utils/email-helpers.js';
3
+ import { requestConfirmation, createConfirmationSchema, readElicitationConfig, } from '@pulsemcp/mcp-elicitation';
3
4
  const PARAM_DESCRIPTIONS = {
4
5
  to: 'Recipient email address(es). For multiple recipients, separate with commas.',
5
6
  subject: 'Subject line of the email.',
@@ -61,7 +62,7 @@ To send a reply to an existing email:
61
62
  **Use cases:**
62
63
  - Send a new email immediately
63
64
  - Reply to an existing email conversation
64
- - Send a draft that was created with draft_email
65
+ - Send a draft that was created with upsert_draft_email
65
66
 
66
67
  **Warning:** This action sends the email immediately and cannot be undone.`;
67
68
  export function sendEmailTool(server, clientFactory) {
@@ -114,6 +115,66 @@ export function sendEmailTool(server, clientFactory) {
114
115
  try {
115
116
  const parsed = SendEmailSchema.parse(args ?? {});
116
117
  const client = clientFactory();
118
+ // Build a human-readable summary for the elicitation prompt
119
+ const elicitationConfig = readElicitationConfig();
120
+ if (elicitationConfig.enabled) {
121
+ let confirmMessage;
122
+ if (parsed.from_draft_id) {
123
+ confirmMessage = `About to send draft (ID: ${parsed.from_draft_id}). This action cannot be undone.`;
124
+ }
125
+ else {
126
+ confirmMessage =
127
+ `About to send an email:\n` +
128
+ ` To: ${parsed.to}\n` +
129
+ ` Subject: ${parsed.subject}\n` +
130
+ (parsed.cc ? ` CC: ${parsed.cc}\n` : '') +
131
+ (parsed.bcc ? ` BCC: ${parsed.bcc}\n` : '') +
132
+ `\nThis action cannot be undone.`;
133
+ }
134
+ const confirmation = await requestConfirmation({
135
+ server,
136
+ message: confirmMessage,
137
+ requestedSchema: createConfirmationSchema('Send this email?', 'Confirm that you want to send this email immediately.'),
138
+ meta: {
139
+ 'com.pulsemcp/tool-name': 'send_email',
140
+ },
141
+ }, elicitationConfig);
142
+ if (confirmation.action === 'decline' || confirmation.action === 'cancel') {
143
+ return {
144
+ content: [
145
+ {
146
+ type: 'text',
147
+ text: 'Email sending was cancelled by the user.',
148
+ },
149
+ ],
150
+ };
151
+ }
152
+ if (confirmation.action === 'expired') {
153
+ return {
154
+ content: [
155
+ {
156
+ type: 'text',
157
+ text: 'Email sending confirmation expired. Please try again.',
158
+ },
159
+ ],
160
+ isError: true,
161
+ };
162
+ }
163
+ // action === 'accept' with content.confirm === true, or
164
+ // action === 'accept' from disabled mode (no content)
165
+ if (confirmation.content &&
166
+ 'confirm' in confirmation.content &&
167
+ confirmation.content.confirm === false) {
168
+ return {
169
+ content: [
170
+ {
171
+ type: 'text',
172
+ text: 'Email sending was not confirmed. The email was not sent.',
173
+ },
174
+ ],
175
+ };
176
+ }
177
+ }
117
178
  // Option 2: Send a draft
118
179
  if (parsed.from_draft_id) {
119
180
  const sentEmail = await client.sendDraft(parsed.from_draft_id);
package/shared/tools.js CHANGED
@@ -2,7 +2,8 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
2
2
  import { listEmailConversationsTool } from './tools/list-email-conversations.js';
3
3
  import { getEmailConversationTool } from './tools/get-email-conversation.js';
4
4
  import { changeEmailConversationTool } from './tools/change-email-conversation.js';
5
- import { draftEmailTool } from './tools/draft-email.js';
5
+ import { upsertDraftEmailTool } from './tools/draft-email.js';
6
+ import { listDraftEmailsTool } from './tools/list-draft-emails.js';
6
7
  import { sendEmailTool } from './tools/send-email.js';
7
8
  import { searchEmailConversationsTool } from './tools/search-email-conversations.js';
8
9
  import { downloadEmailAttachmentsTool } from './tools/download-email-attachments.js';
@@ -10,8 +11,8 @@ const ALL_TOOL_GROUPS = ['readonly', 'readwrite', 'readwrite_external'];
10
11
  /**
11
12
  * All available tools with their group assignments
12
13
  *
13
- * readonly: list_email_conversations, get_email_conversation, search_email_conversations, download_email_attachments
14
- * readwrite: all readonly tools + change_email_conversation, draft_email
14
+ * readonly: list_email_conversations, get_email_conversation, search_email_conversations, download_email_attachments, list_draft_emails
15
+ * readwrite: all readonly tools + change_email_conversation, upsert_draft_email
15
16
  * readwrite_external: all readwrite tools + send_email (external communication)
16
17
  */
17
18
  const ALL_TOOLS = [
@@ -26,9 +27,11 @@ const ALL_TOOLS = [
26
27
  factory: downloadEmailAttachmentsTool,
27
28
  groups: ['readonly', 'readwrite', 'readwrite_external'],
28
29
  },
30
+ // List drafts is read-only (doesn't modify mailbox state)
31
+ { factory: listDraftEmailsTool, groups: ['readonly', 'readwrite', 'readwrite_external'] },
29
32
  // Write tools (available in readwrite and readwrite_external)
30
33
  { factory: changeEmailConversationTool, groups: ['readwrite', 'readwrite_external'] },
31
- { factory: draftEmailTool, groups: ['readwrite', 'readwrite_external'] },
34
+ { factory: upsertDraftEmailTool, groups: ['readwrite', 'readwrite_external'] },
32
35
  // External communication tools (only in readwrite_external - most dangerous)
33
36
  { factory: sendEmailTool, groups: ['readwrite_external'] },
34
37
  ];