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 +29 -10
- package/build/index.integration-with-mock.js +33 -0
- package/node_modules/@pulsemcp/mcp-elicitation/build/config.d.ts +13 -0
- package/node_modules/@pulsemcp/mcp-elicitation/build/config.js +36 -0
- package/node_modules/@pulsemcp/mcp-elicitation/build/elicitation.d.ts +19 -0
- package/node_modules/@pulsemcp/mcp-elicitation/build/elicitation.js +137 -0
- package/node_modules/@pulsemcp/mcp-elicitation/build/index.d.ts +3 -0
- package/node_modules/@pulsemcp/mcp-elicitation/build/index.js +2 -0
- package/node_modules/@pulsemcp/mcp-elicitation/build/types.d.ts +104 -0
- package/node_modules/@pulsemcp/mcp-elicitation/build/types.js +1 -0
- package/node_modules/@pulsemcp/mcp-elicitation/package.json +28 -0
- package/package.json +7 -1
- package/shared/gmail-client/lib/drafts.d.ts +14 -0
- package/shared/gmail-client/lib/drafts.js +25 -0
- package/shared/gmail-client/lib/mime-utils.d.ts +10 -0
- package/shared/gmail-client/lib/mime-utils.js +31 -5
- package/shared/server.d.ts +25 -0
- package/shared/server.js +6 -0
- package/shared/tools/draft-email.d.ts +11 -2
- package/shared/tools/draft-email.js +27 -9
- package/shared/tools/list-draft-emails.d.ts +44 -0
- package/shared/tools/list-draft-emails.js +123 -0
- package/shared/tools/send-email.js +62 -1
- package/shared/tools.js +7 -4
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
|
|
156
|
-
| -------------------- |
|
|
157
|
-
| `readonly` | `list_email_conversations`, `get_email_conversation`, `search_email_conversations` | Low |
|
|
158
|
-
| `readwrite` | All readonly tools + `change_email_conversation`, `
|
|
159
|
-
| `readwrite_external` | All readwrite tools + `send_email`
|
|
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
|
-
###
|
|
257
|
+
### list_draft_emails
|
|
258
258
|
|
|
259
|
-
|
|
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 (
|
|
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 (
|
|
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,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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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.
|
|
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${
|
|
34
|
-
`--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${
|
|
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)
|
package/shared/server.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
66
|
+
export function upsertDraftEmailTool(server, clientFactory) {
|
|
58
67
|
return {
|
|
59
|
-
name: '
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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,
|
|
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:
|
|
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
|
];
|