gmail-workspace-mcp-server 0.2.1 → 0.4.5

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,24 +254,43 @@ 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
- - `to` (string, required): Recipient email address
264
- - `subject` (string, required): Email subject
265
- - `plaintext_body` (string): Plain text body content (at least one of plaintext_body or html_body required)
266
- - `html_body` (string): HTML body content for rich text formatting (at least one of plaintext_body or html_body required)
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, update, or delete a draft email. Optionally as a reply to an existing conversation.
277
+
278
+ **Parameters:**
279
+
280
+ - `draft_id` (string, optional): ID of an existing draft to update or delete (omit to create a new draft)
281
+ - `delete` (boolean, optional): Set to `true` to delete the draft specified by `draft_id`. All other parameters are ignored when deleting
282
+ - `to` (string, required for create/update): Recipient email address
283
+ - `subject` (string, required for create/update): Email subject
284
+ - `plaintext_body` (string): Plain text body content (at least one of plaintext_body or html_body required for create/update)
285
+ - `html_body` (string): HTML body content for rich text formatting (at least one of plaintext_body or html_body required for create/update)
267
286
  - `cc` (string, optional): CC recipients
268
287
  - `bcc` (string, optional): BCC recipients
269
288
  - `thread_id` (string, optional): Thread ID for replies
270
289
  - `reply_to_email_id` (string, optional): Email ID to reply to (sets References/In-Reply-To headers)
271
290
 
272
- At least one of `plaintext_body` or `html_body` must be provided. If both are provided, a multipart email is sent with both versions.
291
+ For create/update: 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
292
 
274
- **Example (plain text):**
293
+ **Example (create new draft):**
275
294
 
276
295
  ```json
277
296
  {
@@ -281,16 +300,26 @@ At least one of `plaintext_body` or `html_body` must be provided. If both are pr
281
300
  }
282
301
  ```
283
302
 
284
- **Example (HTML):**
303
+ **Example (update existing draft):**
285
304
 
286
305
  ```json
287
306
  {
307
+ "draft_id": "r123456789",
288
308
  "to": "recipient@example.com",
289
- "subject": "Meeting Follow-up",
309
+ "subject": "Meeting Follow-up (revised)",
290
310
  "html_body": "<p>Thanks for the meeting today! Check out <a href=\"https://example.com/notes\">the notes</a>.</p>"
291
311
  }
292
312
  ```
293
313
 
314
+ **Example (delete a draft):**
315
+
316
+ ```json
317
+ {
318
+ "draft_id": "r123456789",
319
+ "delete": true
320
+ }
321
+ ```
322
+
294
323
  ### send_email
295
324
 
296
325
  Send an email directly or from an existing draft.
@@ -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,159 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readElicitationConfig } from './config.js';
3
+ /**
4
+ * The set of action values recognized by the elicitation protocol.
5
+ * Includes 'pending' for completeness, though it is filtered before
6
+ * reaching the validation check in pollElicitationStatus.
7
+ */
8
+ const VALID_ELICITATION_ACTIONS = new Set(['pending', 'accept', 'decline', 'cancel', 'expired']);
9
+ /**
10
+ * Checks whether the connected client supports native form elicitation.
11
+ */
12
+ function clientSupportsElicitation(server) {
13
+ const caps = server.getClientCapabilities();
14
+ if (!caps?.elicitation) {
15
+ return false;
16
+ }
17
+ // If elicitation is declared at all (even empty {}), form mode is supported
18
+ // per the MCP spec's backward compatibility rules.
19
+ return true;
20
+ }
21
+ /**
22
+ * Attempts native elicitation via the MCP SDK's `server.elicitInput()`.
23
+ */
24
+ async function nativeElicit(server, message, requestedSchema) {
25
+ const params = {
26
+ mode: 'form',
27
+ message,
28
+ requestedSchema,
29
+ };
30
+ const result = await server.elicitInput(params);
31
+ // Fail-safe: validate the action even from native elicitation.
32
+ // The TypeScript type says 'accept' | 'decline' | 'cancel', but at runtime
33
+ // the MCP client could return any string over the wire.
34
+ if (!VALID_ELICITATION_ACTIONS.has(result.action)) {
35
+ console.warn(`[elicitation] Unrecognized native elicitation action "${result.action}". ` +
36
+ `Treating as "decline" (fail-safe).`);
37
+ return { action: 'decline' };
38
+ }
39
+ return {
40
+ action: result.action,
41
+ content: result.content ?? undefined,
42
+ };
43
+ }
44
+ /**
45
+ * Posts an elicitation request to the HTTP fallback endpoint.
46
+ */
47
+ async function postElicitationRequest(config, message, requestedSchema, meta) {
48
+ const response = await fetch(config.requestUrl, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({
52
+ mode: 'form',
53
+ message,
54
+ requestedSchema,
55
+ _meta: meta,
56
+ }),
57
+ });
58
+ if (!response.ok) {
59
+ const body = await response.text().catch(() => '');
60
+ throw new Error(`Elicitation POST failed: ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`);
61
+ }
62
+ const data = (await response.json());
63
+ return data;
64
+ }
65
+ /**
66
+ * Polls the HTTP fallback endpoint until the request is resolved or expires.
67
+ */
68
+ async function pollElicitationStatus(config, requestId, expiresAt) {
69
+ const pollUrl = config.pollUrl.endsWith('/')
70
+ ? `${config.pollUrl}${requestId}`
71
+ : `${config.pollUrl}/${requestId}`;
72
+ while (Date.now() < expiresAt) {
73
+ const response = await fetch(pollUrl, {
74
+ method: 'GET',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ });
77
+ if (!response.ok) {
78
+ const body = await response.text().catch(() => '');
79
+ throw new Error(`Elicitation poll failed: ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`);
80
+ }
81
+ const data = (await response.json());
82
+ if (data.action !== 'pending') {
83
+ // Fail-safe: only allow recognized action values through.
84
+ // Unrecognized actions are treated as 'decline' to prevent
85
+ // unintended execution of protected operations.
86
+ if (!VALID_ELICITATION_ACTIONS.has(data.action)) {
87
+ console.warn(`[elicitation] Unrecognized poll action "${data.action}" for request ${requestId}. ` +
88
+ `Treating as "decline" (fail-safe).`);
89
+ return { action: 'decline' };
90
+ }
91
+ return {
92
+ action: data.action,
93
+ content: data.content ?? undefined,
94
+ };
95
+ }
96
+ // Wait before polling again
97
+ await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs));
98
+ }
99
+ return { action: 'expired' };
100
+ }
101
+ /**
102
+ * Requests user confirmation through the best available mechanism.
103
+ *
104
+ * Decision tree:
105
+ * 1. If elicitation is disabled (`ELICITATION_ENABLED=false`), returns `accept` immediately.
106
+ * 2. If the client supports native elicitation, uses `server.elicitInput()`.
107
+ * 3. If HTTP fallback URLs are configured, posts to the external endpoint and polls.
108
+ * 4. Otherwise, throws an error indicating no elicitation mechanism is available.
109
+ *
110
+ * @param options - Configuration for the confirmation request.
111
+ * @param config - Elicitation config (defaults to reading from env vars).
112
+ * @returns The user's response.
113
+ */
114
+ export async function requestConfirmation(options, config) {
115
+ const cfg = config ?? readElicitationConfig();
116
+ // Tier 1: Disabled — skip confirmation entirely
117
+ if (!cfg.enabled) {
118
+ return { action: 'accept' };
119
+ }
120
+ // Tier 2: Native elicitation
121
+ if (clientSupportsElicitation(options.server)) {
122
+ return nativeElicit(options.server, options.message, options.requestedSchema);
123
+ }
124
+ // Tier 3: HTTP fallback
125
+ if (cfg.requestUrl && cfg.pollUrl) {
126
+ const clientRequestId = randomUUID();
127
+ const expiresAt = Date.now() + cfg.ttlMs;
128
+ const meta = {
129
+ 'com.pulsemcp/request-id': clientRequestId,
130
+ 'com.pulsemcp/expires-at': new Date(expiresAt).toISOString(),
131
+ ...(cfg.sessionId && { 'com.pulsemcp/session-id': cfg.sessionId }),
132
+ ...options.meta,
133
+ };
134
+ const postResponse = await postElicitationRequest(cfg, options.message, options.requestedSchema, meta);
135
+ // Use the server-provided requestId if available, otherwise fall back to the client-generated one
136
+ const requestId = postResponse.requestId || clientRequestId;
137
+ return pollElicitationStatus(cfg, requestId, expiresAt);
138
+ }
139
+ // Tier 4: No mechanism available
140
+ throw new Error('Elicitation is enabled but no mechanism is available. ' +
141
+ 'Either the client must support native elicitation, or ' +
142
+ 'ELICITATION_REQUEST_URL and ELICITATION_POLL_URL must be configured for HTTP fallback.');
143
+ }
144
+ /**
145
+ * Creates a simple boolean confirmation schema for common "are you sure?" prompts.
146
+ */
147
+ export function createConfirmationSchema(title = 'Confirm', description) {
148
+ return {
149
+ type: 'object',
150
+ properties: {
151
+ confirm: {
152
+ type: 'boolean',
153
+ title,
154
+ ...(description ? { description } : {}),
155
+ },
156
+ },
157
+ required: ['confirm'],
158
+ };
159
+ }
@@ -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.1",
3
+ "version": "0.4.5",
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
  */
@@ -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,9 +1,11 @@
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<{
5
- to: z.ZodString;
6
- subject: z.ZodString;
4
+ export declare const UpsertDraftEmailSchema: z.ZodEffects<z.ZodObject<{
5
+ draft_id: z.ZodOptional<z.ZodString>;
6
+ delete: z.ZodOptional<z.ZodBoolean>;
7
+ to: z.ZodOptional<z.ZodString>;
8
+ subject: z.ZodOptional<z.ZodString>;
7
9
  plaintext_body: z.ZodOptional<z.ZodString>;
8
10
  html_body: z.ZodOptional<z.ZodString>;
9
11
  cc: z.ZodOptional<z.ZodString>;
@@ -11,8 +13,10 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
11
13
  thread_id: z.ZodOptional<z.ZodString>;
12
14
  reply_to_email_id: z.ZodOptional<z.ZodString>;
13
15
  }, "strip", z.ZodTypeAny, {
14
- to: string;
15
- subject: string;
16
+ draft_id?: string | undefined;
17
+ delete?: boolean | undefined;
18
+ to?: string | undefined;
19
+ subject?: string | undefined;
16
20
  plaintext_body?: string | undefined;
17
21
  html_body?: string | undefined;
18
22
  cc?: string | undefined;
@@ -20,8 +24,10 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
20
24
  thread_id?: string | undefined;
21
25
  reply_to_email_id?: string | undefined;
22
26
  }, {
23
- to: string;
24
- subject: string;
27
+ draft_id?: string | undefined;
28
+ delete?: boolean | undefined;
29
+ to?: string | undefined;
30
+ subject?: string | undefined;
25
31
  plaintext_body?: string | undefined;
26
32
  html_body?: string | undefined;
27
33
  cc?: string | undefined;
@@ -29,8 +35,10 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
29
35
  thread_id?: string | undefined;
30
36
  reply_to_email_id?: string | undefined;
31
37
  }>, {
32
- to: string;
33
- subject: string;
38
+ draft_id?: string | undefined;
39
+ delete?: boolean | undefined;
40
+ to?: string | undefined;
41
+ subject?: string | undefined;
34
42
  plaintext_body?: string | undefined;
35
43
  html_body?: string | undefined;
36
44
  cc?: string | undefined;
@@ -38,8 +46,10 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
38
46
  thread_id?: string | undefined;
39
47
  reply_to_email_id?: string | undefined;
40
48
  }, {
41
- to: string;
42
- subject: string;
49
+ draft_id?: string | undefined;
50
+ delete?: boolean | undefined;
51
+ to?: string | undefined;
52
+ subject?: string | undefined;
43
53
  plaintext_body?: string | undefined;
44
54
  html_body?: string | undefined;
45
55
  cc?: string | undefined;
@@ -47,12 +57,20 @@ export declare const DraftEmailSchema: z.ZodEffects<z.ZodObject<{
47
57
  thread_id?: string | undefined;
48
58
  reply_to_email_id?: string | undefined;
49
59
  }>;
50
- export declare function draftEmailTool(server: Server, clientFactory: ClientFactory): {
60
+ export declare function upsertDraftEmailTool(server: Server, clientFactory: ClientFactory): {
51
61
  name: string;
52
62
  description: string;
53
63
  inputSchema: {
54
64
  type: "object";
55
65
  properties: {
66
+ draft_id: {
67
+ type: string;
68
+ description: string;
69
+ };
70
+ delete: {
71
+ type: string;
72
+ description: string;
73
+ };
56
74
  to: {
57
75
  type: string;
58
76
  description: "Recipient email address(es). For multiple recipients, separate with commas.";
@@ -86,7 +104,7 @@ export declare function draftEmailTool(server: Server, clientFactory: ClientFact
86
104
  description: string;
87
105
  };
88
106
  };
89
- required: string[];
107
+ required: never[];
90
108
  };
91
109
  handler: (args: unknown) => Promise<{
92
110
  content: {
@@ -1,6 +1,11 @@
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 or delete. If provided, the draft is replaced in-place ' +
5
+ 'with the new content (or deleted if delete is true). If omitted, a new draft is created. ' +
6
+ 'Get draft IDs from list_draft_emails or from a previous upsert_draft_email response.',
7
+ delete: 'Set to true to delete the draft specified by draft_id. ' +
8
+ 'Requires draft_id. All other parameters are ignored when deleting.',
4
9
  to: 'Recipient email address(es). For multiple recipients, separate with commas.',
5
10
  subject: 'Subject line of the email.',
6
11
  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,10 +17,12 @@ const PARAM_DESCRIPTIONS = {
12
17
  reply_to_email_id: 'Email ID to reply to. If provided, the draft will be formatted as a reply ' +
13
18
  'with proper In-Reply-To and References headers. Also requires thread_id.',
14
19
  };
15
- export const DraftEmailSchema = z
20
+ export const UpsertDraftEmailSchema = z
16
21
  .object({
17
- to: z.string().min(1).describe(PARAM_DESCRIPTIONS.to),
18
- subject: z.string().min(1).describe(PARAM_DESCRIPTIONS.subject),
22
+ draft_id: z.string().optional().describe(PARAM_DESCRIPTIONS.draft_id),
23
+ delete: z.boolean().optional().describe(PARAM_DESCRIPTIONS.delete),
24
+ to: z.string().min(1).optional().describe(PARAM_DESCRIPTIONS.to),
25
+ subject: z.string().min(1).optional().describe(PARAM_DESCRIPTIONS.subject),
19
26
  plaintext_body: z.string().min(1).optional().describe(PARAM_DESCRIPTIONS.plaintext_body),
20
27
  html_body: z.string().min(1).optional().describe(PARAM_DESCRIPTIONS.html_body),
21
28
  cc: z.string().optional().describe(PARAM_DESCRIPTIONS.cc),
@@ -23,44 +30,74 @@ export const DraftEmailSchema = z
23
30
  thread_id: z.string().optional().describe(PARAM_DESCRIPTIONS.thread_id),
24
31
  reply_to_email_id: z.string().optional().describe(PARAM_DESCRIPTIONS.reply_to_email_id),
25
32
  })
26
- .refine((data) => {
27
- return Boolean(data.plaintext_body) || Boolean(data.html_body);
28
- }, {
29
- message: 'At least one of plaintext_body or html_body must be provided.',
33
+ .superRefine((data, ctx) => {
34
+ if (data.delete) {
35
+ if (!data.draft_id) {
36
+ ctx.addIssue({
37
+ code: z.ZodIssueCode.custom,
38
+ message: 'draft_id is required when delete is true.',
39
+ });
40
+ }
41
+ return;
42
+ }
43
+ if (!data.to || !data.subject || (!data.plaintext_body && !data.html_body)) {
44
+ ctx.addIssue({
45
+ code: z.ZodIssueCode.custom,
46
+ message: 'to, subject, and at least one of plaintext_body or html_body must be provided.',
47
+ });
48
+ }
30
49
  });
31
- const TOOL_DESCRIPTION = `Create a draft email that can be reviewed and sent later.
50
+ const TOOL_DESCRIPTION = `Create, update, or delete a draft email.
32
51
 
33
52
  **Parameters:**
34
- - to: Recipient email address(es) (required)
35
- - subject: Email subject line (required)
36
- - plaintext_body: Plain text body content (at least one of plaintext_body or html_body required)
37
- - html_body: HTML body content for rich text formatting (at least one of plaintext_body or html_body required)
53
+ - draft_id: ID of an existing draft to update or delete (optional — omit to create a new draft)
54
+ - delete: Set to true to delete the draft specified by draft_id (optional)
55
+ - to: Recipient email address(es) (required for create/update)
56
+ - subject: Email subject line (required for create/update)
57
+ - plaintext_body: Plain text body content (at least one of plaintext_body or html_body required for create/update)
58
+ - html_body: HTML body content for rich text formatting (at least one of plaintext_body or html_body required for create/update)
38
59
  - cc: CC recipients (optional)
39
60
  - bcc: BCC recipients (optional)
40
61
  - thread_id: Thread ID to reply to an existing conversation (optional)
41
62
  - reply_to_email_id: Email ID to reply to, sets proper reply headers (optional)
42
63
 
43
64
  **Body content:**
44
- At least one of plaintext_body or html_body must be provided. If both are provided, a multipart email is sent with both plain text and HTML versions. Use html_body for rich formatting like hyperlinks, bold text, or lists.
65
+ At least one of plaintext_body or html_body must be provided for create/update. If both are provided, a multipart email is sent with both plain text and HTML versions. Use html_body for rich formatting like hyperlinks, bold text, or lists.
45
66
 
46
67
  **Creating a reply:**
47
68
  To create a draft reply to an existing email:
48
69
  1. Get the thread_id and email_id from get_email_conversation
49
70
  2. Provide both thread_id and reply_to_email_id parameters
50
71
 
72
+ **Updating a draft:**
73
+ 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).
74
+
75
+ **Deleting a draft:**
76
+ To delete a draft, provide the draft_id and set delete to true. All other parameters are ignored when deleting.
77
+
51
78
  **Use cases:**
52
79
  - Draft a new email for later review
53
80
  - Prepare a reply to an email conversation
81
+ - Revise a draft after user feedback (without creating duplicates)
54
82
  - Save an email without sending it immediately
83
+ - Delete a corrupted or unwanted draft
55
84
 
56
85
  **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) {
86
+ export function upsertDraftEmailTool(server, clientFactory) {
58
87
  return {
59
- name: 'draft_email',
88
+ name: 'upsert_draft_email',
60
89
  description: TOOL_DESCRIPTION,
61
90
  inputSchema: {
62
91
  type: 'object',
63
92
  properties: {
93
+ draft_id: {
94
+ type: 'string',
95
+ description: PARAM_DESCRIPTIONS.draft_id,
96
+ },
97
+ delete: {
98
+ type: 'boolean',
99
+ description: PARAM_DESCRIPTIONS.delete,
100
+ },
64
101
  to: {
65
102
  type: 'string',
66
103
  description: PARAM_DESCRIPTIONS.to,
@@ -94,12 +131,24 @@ export function draftEmailTool(server, clientFactory) {
94
131
  description: PARAM_DESCRIPTIONS.reply_to_email_id,
95
132
  },
96
133
  },
97
- required: ['to', 'subject'],
134
+ required: [],
98
135
  },
99
136
  handler: async (args) => {
100
137
  try {
101
- const parsed = DraftEmailSchema.parse(args ?? {});
138
+ const parsed = UpsertDraftEmailSchema.parse(args ?? {});
102
139
  const client = clientFactory();
140
+ // Delete mode
141
+ if (parsed.delete) {
142
+ await client.deleteDraft(parsed.draft_id);
143
+ return {
144
+ content: [
145
+ {
146
+ type: 'text',
147
+ text: `Draft deleted successfully!\n\n**Draft ID:** ${parsed.draft_id}`,
148
+ },
149
+ ],
150
+ };
151
+ }
103
152
  let inReplyTo;
104
153
  let references;
105
154
  // If replying to an email, get the Message-ID for proper threading
@@ -116,7 +165,7 @@ export function draftEmailTool(server, clientFactory) {
116
165
  references = originalReferences ? `${originalReferences} ${messageId}` : messageId;
117
166
  }
118
167
  }
119
- const draft = await client.createDraft({
168
+ const draftOptions = {
120
169
  to: parsed.to,
121
170
  subject: parsed.subject,
122
171
  plaintextBody: parsed.plaintext_body,
@@ -126,8 +175,13 @@ export function draftEmailTool(server, clientFactory) {
126
175
  threadId: parsed.thread_id,
127
176
  inReplyTo,
128
177
  references,
129
- });
130
- let responseText = `Draft created successfully!\n\n**Draft ID:** ${draft.id}`;
178
+ };
179
+ const isUpdate = Boolean(parsed.draft_id);
180
+ const draft = isUpdate
181
+ ? await client.updateDraft(parsed.draft_id, draftOptions)
182
+ : await client.createDraft(draftOptions);
183
+ const action = isUpdate ? 'updated' : 'created';
184
+ let responseText = `Draft ${action} successfully!\n\n**Draft ID:** ${draft.id}`;
131
185
  if (parsed.thread_id) {
132
186
  responseText += `\n**Thread ID:** ${parsed.thread_id}`;
133
187
  responseText += '\n\nThis draft is a reply in an existing conversation.';
@@ -158,11 +212,17 @@ export function draftEmailTool(server, clientFactory) {
158
212
  };
159
213
  }
160
214
  catch (error) {
215
+ const typedArgs = args;
216
+ const errorAction = typedArgs?.delete
217
+ ? 'deleting'
218
+ : typedArgs?.draft_id
219
+ ? 'updating'
220
+ : 'creating';
161
221
  return {
162
222
  content: [
163
223
  {
164
224
  type: 'text',
165
- text: `Error creating draft: ${error instanceof Error ? error.message : 'Unknown error'}`,
225
+ text: `Error ${errorAction} draft: ${error instanceof Error ? error.message : 'Unknown error'}`,
166
226
  },
167
227
  ],
168
228
  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
+ }
@@ -102,12 +102,12 @@ export declare function sendEmailTool(server: Server, clientFactory: ClientFacto
102
102
  type: string;
103
103
  text: string;
104
104
  }[];
105
- isError?: undefined;
105
+ isError: boolean;
106
106
  } | {
107
107
  content: {
108
108
  type: string;
109
109
  text: string;
110
110
  }[];
111
- isError: boolean;
111
+ isError?: undefined;
112
112
  }>;
113
113
  };
@@ -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,68 @@ 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
+ // Fail-safe: only proceed on explicit 'accept'.
143
+ // Any other action (decline, cancel, expired, or unrecognized) blocks the send.
144
+ if (confirmation.action !== 'accept') {
145
+ if (confirmation.action === 'expired') {
146
+ return {
147
+ content: [
148
+ {
149
+ type: 'text',
150
+ text: 'Email sending confirmation expired. Please try again.',
151
+ },
152
+ ],
153
+ isError: true,
154
+ };
155
+ }
156
+ return {
157
+ content: [
158
+ {
159
+ type: 'text',
160
+ text: 'Email sending was cancelled by the user.',
161
+ },
162
+ ],
163
+ };
164
+ }
165
+ // We reach here only when action === 'accept'.
166
+ // Still check if the user explicitly unchecked the confirm checkbox.
167
+ if (confirmation.content &&
168
+ 'confirm' in confirmation.content &&
169
+ confirmation.content.confirm === false) {
170
+ return {
171
+ content: [
172
+ {
173
+ type: 'text',
174
+ text: 'Email sending was not confirmed. The email was not sent.',
175
+ },
176
+ ],
177
+ };
178
+ }
179
+ }
117
180
  // Option 2: Send a draft
118
181
  if (parsed.from_draft_id) {
119
182
  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
  ];