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 +44 -15
- 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 +159 -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/server.d.ts +25 -0
- package/shared/server.js +6 -0
- package/shared/tools/draft-email.d.ts +31 -13
- package/shared/tools/draft-email.js +81 -21
- package/shared/tools/list-draft-emails.d.ts +44 -0
- package/shared/tools/list-draft-emails.js +123 -0
- package/shared/tools/send-email.d.ts +2 -2
- package/shared/tools/send-email.js +64 -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,24 +254,43 @@ 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
|
-
- `
|
|
264
|
-
- `
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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,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.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
|
*/
|
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,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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
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:
|
|
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
|
|
20
|
+
export const UpsertDraftEmailSchema = z
|
|
16
21
|
.object({
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
50
|
+
const TOOL_DESCRIPTION = `Create, update, or delete a draft email.
|
|
32
51
|
|
|
33
52
|
**Parameters:**
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
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
|
|
86
|
+
export function upsertDraftEmailTool(server, clientFactory) {
|
|
58
87
|
return {
|
|
59
|
-
name: '
|
|
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: [
|
|
134
|
+
required: [],
|
|
98
135
|
},
|
|
99
136
|
handler: async (args) => {
|
|
100
137
|
try {
|
|
101
|
-
const parsed =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
105
|
+
isError: boolean;
|
|
106
106
|
} | {
|
|
107
107
|
content: {
|
|
108
108
|
type: string;
|
|
109
109
|
text: string;
|
|
110
110
|
}[];
|
|
111
|
-
isError
|
|
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
|
|
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 {
|
|
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
|
];
|